Add schedule API functions and React hooks (Phase 3B)
POST schedule/1 for search, GET schedule/details with indexed query params, GET days/.../schedule/v1 for calendar. Three hooks wrap the API functions with loading/error state management.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Phase 3B -- Schedule API + Hooks
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3A (types)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/schedule/api.ts` -- three API functions
|
||||
2. `src/features/schedule/api.test.ts` -- unit tests
|
||||
3. `src/features/schedule/hooks/useScheduleSearch.ts`
|
||||
4. `src/features/schedule/hooks/useScheduleDetails.ts`
|
||||
5. `src/features/schedule/hooks/useScheduleCalendar.ts`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST schedule/1` -- search (body: IScheduleSearchRequest)
|
||||
- `GET schedule/details` -- multi-flight details (query: flights[], dates[], departure, arrival)
|
||||
- `GET days/{date}/382/{param}/schedule/v1` -- calendar days
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import {
|
||||
searchSchedule,
|
||||
getScheduleDetails,
|
||||
getScheduleCalendarDays,
|
||||
} from "./api";
|
||||
import type {
|
||||
IScheduleSearchRequest,
|
||||
IScheduleResponse,
|
||||
IScheduleDetailsResponse,
|
||||
IScheduleDaysResponse,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockClient(responseBody: unknown, status = 200) {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.test.com",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
|
||||
return { client, mockFetch };
|
||||
}
|
||||
|
||||
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
|
||||
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
|
||||
return new URL(call[0]);
|
||||
}
|
||||
|
||||
function extractBody(mockFetch: ReturnType<typeof vi.fn>): unknown {
|
||||
const call = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
function extractMethod(mockFetch: ReturnType<typeof vi.fn>): string {
|
||||
const call = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return call[1].method ?? "GET";
|
||||
}
|
||||
|
||||
/** Minimal valid schedule response for testing */
|
||||
const SCHEDULE_RESPONSE: IScheduleResponse = [];
|
||||
|
||||
const DETAILS_RESPONSE: IScheduleDetailsResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: [],
|
||||
daysOfFlight: ["2025-01-15"],
|
||||
},
|
||||
};
|
||||
|
||||
const DAYS_RESPONSE: IScheduleDaysResponse = {
|
||||
days: "2025-01-15,2025-01-16,2025-01-17",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchSchedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchSchedule", () => {
|
||||
it("sends POST to schedule/1", async () => {
|
||||
const { client, mockFetch } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const params: IScheduleSearchRequest = {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
};
|
||||
|
||||
await searchSchedule(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/schedule/1");
|
||||
expect(extractMethod(mockFetch)).toBe("POST");
|
||||
});
|
||||
|
||||
it("sends params as JSON body", async () => {
|
||||
const { client, mockFetch } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const params: IScheduleSearchRequest = {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
connections: 1,
|
||||
};
|
||||
|
||||
await searchSchedule(client, params);
|
||||
|
||||
const body = extractBody(mockFetch);
|
||||
expect(body).toEqual(params);
|
||||
});
|
||||
|
||||
it("returns the deserialized response", async () => {
|
||||
const { client } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const result = await searchSchedule(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
});
|
||||
|
||||
expect(result).toEqual(SCHEDULE_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
searchSchedule(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getScheduleDetails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getScheduleDetails", () => {
|
||||
it("sends indexed flights and dates as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(DETAILS_RESPONSE);
|
||||
|
||||
await getScheduleDetails(client, {
|
||||
flights: ["SU0012", "SU0013"],
|
||||
dates: ["2025-01-15", "2025-01-16"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/schedule/details");
|
||||
expect(url.searchParams.get("flights[0]")).toBe("SU0012");
|
||||
expect(url.searchParams.get("flights[1]")).toBe("SU0013");
|
||||
expect(url.searchParams.get("dates[0]")).toBe("2025-01-15");
|
||||
expect(url.searchParams.get("dates[1]")).toBe("2025-01-16");
|
||||
expect(url.searchParams.get("departure")).toBe("SVO");
|
||||
expect(url.searchParams.get("arrival")).toBe("LED");
|
||||
});
|
||||
|
||||
it("returns the deserialized response", async () => {
|
||||
const { client } = createMockClient(DETAILS_RESPONSE);
|
||||
|
||||
const result = await getScheduleDetails(client, {
|
||||
flights: ["SU0012"],
|
||||
dates: ["2025-01-15"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
});
|
||||
|
||||
expect(result).toEqual(DETAILS_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
getScheduleDetails(client, {
|
||||
flights: ["SU999"],
|
||||
dates: ["2025-01-15"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getScheduleCalendarDays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getScheduleCalendarDays", () => {
|
||||
it("builds correct path for route without connections", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/382/route/SVO-LED/schedule/");
|
||||
});
|
||||
|
||||
it("builds correct path for route with connections", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: true,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/382/connections/SVO-LED-1/schedule/");
|
||||
});
|
||||
|
||||
it("parses comma-separated days string into array", async () => {
|
||||
const { client } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const result = await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
const result = await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Schedule API functions.
|
||||
*
|
||||
* Pure functions -- each takes an `ApiClient` as a parameter (dependency
|
||||
* injection). No React hooks, no context, no side effects.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@/shared/api/client.js";
|
||||
import type {
|
||||
IScheduleSearchRequest,
|
||||
IScheduleResponse,
|
||||
IScheduleDetailsResponse,
|
||||
IScheduleCalendarParams,
|
||||
IScheduleDaysResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search schedule flights.
|
||||
* Maps to: `POST schedule/1` with body parameters.
|
||||
*
|
||||
* The API response is an array of flight objects (IFlight[]).
|
||||
*/
|
||||
export async function searchSchedule(
|
||||
client: ApiClient,
|
||||
params: IScheduleSearchRequest,
|
||||
): Promise<IScheduleResponse> {
|
||||
return client.post<IScheduleResponse>("schedule/1", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multi-flight schedule details.
|
||||
* Maps to: `GET schedule/details?flights[0]=...&dates[0]=...&departure=...&arrival=...`
|
||||
*
|
||||
* The Angular service uses indexed query params (flights[0], flights[1], etc.).
|
||||
* We build the query object to match that format.
|
||||
*/
|
||||
export async function getScheduleDetails(
|
||||
client: ApiClient,
|
||||
params: {
|
||||
flights: string[];
|
||||
dates: string[];
|
||||
departure: string;
|
||||
arrival: string;
|
||||
},
|
||||
): Promise<IScheduleDetailsResponse> {
|
||||
const query: Record<string, string> = {
|
||||
departure: params.departure,
|
||||
arrival: params.arrival,
|
||||
};
|
||||
|
||||
for (let i = 0; i < params.flights.length; i++) {
|
||||
query[`flights[${i}]`] = params.flights[i]!;
|
||||
}
|
||||
|
||||
for (let i = 0; i < params.dates.length; i++) {
|
||||
query[`dates[${i}]`] = params.dates[i]!;
|
||||
}
|
||||
|
||||
return client.get<IScheduleDetailsResponse>("schedule/details", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available calendar days for a given route.
|
||||
* Maps to: `GET days/{date}/382/{param}/schedule/v1`
|
||||
*
|
||||
* The API returns `{ days: "2025-01-01,2025-01-02,..." }` -- a single
|
||||
* comma-separated string. This function splits it into `string[]`.
|
||||
*/
|
||||
export async function getScheduleCalendarDays(
|
||||
client: ApiClient,
|
||||
params: IScheduleCalendarParams,
|
||||
): Promise<string[]> {
|
||||
const routeSegment = params.connections
|
||||
? `connections/${params.departure}-${params.arrival}-1`
|
||||
: `route/${params.departure}-${params.arrival}`;
|
||||
|
||||
const path = `v1/days/${params.date}/382/${routeSegment}/schedule/`;
|
||||
|
||||
const response = await client.get<IScheduleDaysResponse>(path);
|
||||
return parseCalendarDays(response.days);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCalendarDays(days: string): string[] {
|
||||
if (!days) return [];
|
||||
return days.split(",").map((d) => d.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* React hook for schedule calendar day availability.
|
||||
*
|
||||
* Calls `getScheduleCalendarDays` on param change, manages loading/days state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getScheduleCalendarDays } from "../api.js";
|
||||
import type { IScheduleCalendarParams } from "../types.js";
|
||||
|
||||
export interface UseScheduleCalendarResult {
|
||||
days: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the calendar strip. Fetches available schedule days for the
|
||||
* given route context.
|
||||
*/
|
||||
export function useScheduleCalendar(
|
||||
params: IScheduleCalendarParams,
|
||||
): UseScheduleCalendarResult {
|
||||
const client = useApiClient();
|
||||
const [days, setDays] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
getScheduleCalendarDays(client, paramsRef.current)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDays(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params.date,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
params.connections,
|
||||
]);
|
||||
|
||||
return { days, loading };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* React hook for schedule flight details page.
|
||||
*
|
||||
* Calls `getScheduleDetails` on param change, manages loading/error/data state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getScheduleDetails } from "../api.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseScheduleDetailsParams {
|
||||
flights: string[];
|
||||
dates: string[];
|
||||
departure: string;
|
||||
arrival: string;
|
||||
}
|
||||
|
||||
export interface UseScheduleDetailsResult {
|
||||
flights: ISimpleFlight[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for schedule details page. Fetches multi-flight details
|
||||
* based on flight IDs and dates.
|
||||
*/
|
||||
export function useScheduleDetails(
|
||||
params: UseScheduleDetailsParams,
|
||||
): UseScheduleDetailsResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<ISimpleFlight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
// Serialize array params for dependency tracking
|
||||
const flightsKey = params.flights.join(",");
|
||||
const datesKey = params.dates.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getScheduleDetails(client, paramsRef.current)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setFlights(response.data.routes);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, flightsKey, datesKey, params.departure, params.arrival]);
|
||||
|
||||
return { flights, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* React hook for schedule search pages.
|
||||
*
|
||||
* Calls `searchSchedule` on param change, manages loading/error/data state.
|
||||
* No SignalR -- schedule data is static.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { searchSchedule } from "../api.js";
|
||||
import type { IScheduleSearchRequest, IFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseScheduleSearchResult {
|
||||
flights: IFlight[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for schedule search pages. Fetches flights based on search params
|
||||
* and provides refresh capability.
|
||||
*/
|
||||
export function useScheduleSearch(
|
||||
params: IScheduleSearchRequest,
|
||||
): UseScheduleSearchResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<IFlight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
searchSchedule(client, paramsRef.current)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setFlights(response);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
params.dateFrom,
|
||||
params.dateTo,
|
||||
params.timeFrom,
|
||||
params.timeTo,
|
||||
params.connections,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
return { flights, loading, error, refresh };
|
||||
}
|
||||
Reference in New Issue
Block a user