diff --git a/docs/superpowers/plans/2026-04-15-phase-3b-api-hooks.md b/docs/superpowers/plans/2026-04-15-phase-3b-api-hooks.md new file mode 100644 index 00000000..29fa97ae --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-phase-3b-api-hooks.md @@ -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 diff --git a/src/features/schedule/api.test.ts b/src/features/schedule/api.test.ts new file mode 100644 index 00000000..625a9c8d --- /dev/null +++ b/src/features/schedule/api.test.ts @@ -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().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): URL { + const call = mockFetch.mock.calls[0] as [string, ...unknown[]]; + return new URL(call[0]); +} + +function extractBody(mockFetch: ReturnType): unknown { + const call = mockFetch.mock.calls[0] as [string, RequestInit]; + return JSON.parse(call[1].body as string); +} + +function extractMethod(mockFetch: ReturnType): 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"); + }); +}); diff --git a/src/features/schedule/api.ts b/src/features/schedule/api.ts new file mode 100644 index 00000000..113bf11c --- /dev/null +++ b/src/features/schedule/api.ts @@ -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 { + return client.post("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 { + const query: Record = { + 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("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 { + 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(path); + return parseCalendarDays(response.days); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function parseCalendarDays(days: string): string[] { + if (!days) return []; + return days.split(",").map((d) => d.trim()).filter(Boolean); +} diff --git a/src/features/schedule/hooks/useScheduleCalendar.ts b/src/features/schedule/hooks/useScheduleCalendar.ts new file mode 100644 index 00000000..73f902db --- /dev/null +++ b/src/features/schedule/hooks/useScheduleCalendar.ts @@ -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([]); + 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 }; +} diff --git a/src/features/schedule/hooks/useScheduleDetails.ts b/src/features/schedule/hooks/useScheduleDetails.ts new file mode 100644 index 00000000..a20cf461 --- /dev/null +++ b/src/features/schedule/hooks/useScheduleDetails.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/src/features/schedule/hooks/useScheduleSearch.ts b/src/features/schedule/hooks/useScheduleSearch.ts new file mode 100644 index 00000000..15cfab7e --- /dev/null +++ b/src/features/schedule/hooks/useScheduleSearch.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +}