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:
2026-04-15 09:22:50 +03:00
parent 1c5e85ea8e
commit 7ad61554cb
6 changed files with 583 additions and 0 deletions
@@ -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
+256
View File
@@ -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");
});
});
+96
View File
@@ -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 };
}