Add Phase 2C: Online Board API functions and React hooks
Pure API functions (searchFlights, getFlightDetails, getCalendarDays) with dependency-injected ApiClient, plus three thin React hooks (useOnlineBoard, useFlightDetails, useCalendarDays) for search, details, and calendar pages. 17 TDD tests for API layer covering URL construction, response mapping, and error handling.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# Phase 2C — API Client Functions + React Hooks
|
||||
|
||||
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2C)
|
||||
|
||||
## Goal
|
||||
|
||||
Wrap the three Online Board REST endpoints in typed, pure API functions and thin React hooks. API functions are dependency-injected with `ApiClient`; hooks use `useApiClient()` from context.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 1 `ApiClient` (`src/shared/api/client.ts`) and `useApiClient` (`src/shared/api/provider.tsx`) exist.
|
||||
- Phase 2A types (`src/features/online-board/types.ts`) exist: `ISimpleFlight`, `IBoardResponse`, `IDaysResponse`, `IParsedFlightId`, `FlightRequestType`.
|
||||
- Phase 2B URL serializer (`src/features/online-board/url.ts`) exists.
|
||||
|
||||
## Deliverables
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/features/online-board/api.ts` | Pure API functions: `searchFlights`, `getFlightDetails`, `getCalendarDays` |
|
||||
| `src/features/online-board/api.test.ts` | TDD tests for API functions (mock fetch, verify URL construction + response mapping) |
|
||||
| `src/features/online-board/hooks/useOnlineBoard.ts` | React hook for search pages |
|
||||
| `src/features/online-board/hooks/useFlightDetails.ts` | React hook for details page |
|
||||
| `src/features/online-board/hooks/useCalendarDays.ts` | React hook for calendar availability |
|
||||
| `src/features/online-board/index.ts` | Updated barrel exports |
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1: Define API function param types and write `api.ts`
|
||||
|
||||
**File:** `src/features/online-board/api.ts`
|
||||
|
||||
Three pure functions, each taking `ApiClient` as first param:
|
||||
|
||||
1. `searchFlights(client, params)` — builds `GET /{locale}/board?...` with optional query params: `flightNumber`, `dateFrom`, `dateTo`, `departure`, `arrival`, `timeFrom`, `timeTo`. Returns `IBoardResponse`.
|
||||
|
||||
2. `getFlightDetails(client, params)` — builds `GET /{locale}/onlineboard/details?flights={carrier}{number}&dates={date}`. Returns `IBoardResponse`.
|
||||
|
||||
3. `getCalendarDays(client, params)` — builds `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`. Returns parsed `string[]` from `IDaysResponse.days`.
|
||||
|
||||
**Key design decisions:**
|
||||
- Functions are PURE — no context, no hooks, no side effects.
|
||||
- Use `ApiClient.get<T>(path, query)` for query-param endpoints.
|
||||
- For calendar endpoint, build the path string directly (it's path-based, not query-based).
|
||||
- Map `IDaysResponse.days` (a single string) to `string[]` by splitting on comma.
|
||||
|
||||
### T2: Write TDD tests for `api.ts`
|
||||
|
||||
**File:** `src/features/online-board/api.test.ts`
|
||||
|
||||
Test cases:
|
||||
- `searchFlights`: correct URL + query params for flight-number search, route search, departure-only, arrival-only; response deserialization
|
||||
- `getFlightDetails`: correct URL + query params; response deserialization
|
||||
- `getCalendarDays`: correct path construction for flight type, route type, departure type, arrival type; response mapping from comma-separated string to array
|
||||
- Error cases: 404 throws `ApiHttpError`, timeout throws `ApiTimeoutError`
|
||||
|
||||
Mock strategy: create `ApiClient` with a mock `fetchImpl` that captures the request URL and returns canned responses.
|
||||
|
||||
### T3: Write `useOnlineBoard` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useOnlineBoard.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for flights, loading, error
|
||||
- `useEffect` calls `searchFlights` on param change
|
||||
- Returns `{ flights, loading, error, refresh }`
|
||||
|
||||
### T4: Write `useFlightDetails` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useFlightDetails.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for flight, loading, error
|
||||
- `useEffect` calls `getFlightDetails` on param change
|
||||
- Returns `{ flight, loading, error }`
|
||||
|
||||
### T5: Write `useCalendarDays` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useCalendarDays.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for days, loading
|
||||
- `useEffect` calls `getCalendarDays` on param change
|
||||
- Returns `{ days, loading }`
|
||||
|
||||
### T6: Update barrel exports in `index.ts`
|
||||
|
||||
Add API functions and hooks to the public barrel.
|
||||
|
||||
### T7: Verify — `pnpm typecheck && pnpm lint && pnpm test`
|
||||
|
||||
## Exit gate
|
||||
|
||||
- All API function tests pass (URL construction, response mapping, error handling).
|
||||
- `pnpm typecheck` green — zero `any` types.
|
||||
- `pnpm lint` green.
|
||||
- Hooks compile and export correctly.
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import {
|
||||
searchFlights,
|
||||
getFlightDetails,
|
||||
getCalendarDays,
|
||||
} from "./api";
|
||||
import type {
|
||||
SearchFlightsParams,
|
||||
FlightDetailsParams,
|
||||
CalendarParams,
|
||||
} from "./api";
|
||||
import type { IBoardResponse, IDaysResponse } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an ApiClient with a mock fetch that captures the request URL
|
||||
* and returns a canned response.
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/** Minimal valid IBoardResponse for testing */
|
||||
const BOARD_RESPONSE: IBoardResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: [],
|
||||
daysOfFlight: ["2025-01-15"],
|
||||
},
|
||||
};
|
||||
|
||||
const DAYS_RESPONSE: IDaysResponse = {
|
||||
days: "2025-01-15,2025-01-16,2025-01-17",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchFlights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchFlights", () => {
|
||||
it("sends dateFrom and dateTo as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const params: SearchFlightsParams = {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
};
|
||||
|
||||
await searchFlights(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/board");
|
||||
expect(url.searchParams.get("dateFrom")).toBe("20250115");
|
||||
expect(url.searchParams.get("dateTo")).toBe("20250116");
|
||||
});
|
||||
|
||||
it("includes flightNumber when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
flightNumber: "SU100",
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("flightNumber")).toBe("SU100");
|
||||
});
|
||||
|
||||
it("includes departure and arrival when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("departure")).toBe("SVO");
|
||||
expect(url.searchParams.get("arrival")).toBe("JFK");
|
||||
});
|
||||
|
||||
it("includes timeFrom and timeTo when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
timeFrom: "0800",
|
||||
timeTo: "2000",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("timeFrom")).toBe("0800");
|
||||
expect(url.searchParams.get("timeTo")).toBe("2000");
|
||||
});
|
||||
|
||||
it("omits optional params when not provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.has("flightNumber")).toBe(false);
|
||||
expect(url.searchParams.has("departure")).toBe(false);
|
||||
expect(url.searchParams.has("arrival")).toBe(false);
|
||||
expect(url.searchParams.has("timeFrom")).toBe(false);
|
||||
expect(url.searchParams.has("timeTo")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the deserialized IBoardResponse", async () => {
|
||||
const { client } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const result = await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
expect(result).toEqual(BOARD_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
searchFlights(client, { dateFrom: "20250115", dateTo: "20250116" }),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getFlightDetails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFlightDetails", () => {
|
||||
it("sends flights and dates as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const params: FlightDetailsParams = {
|
||||
flights: "SU100",
|
||||
dates: "2025-01-15",
|
||||
};
|
||||
|
||||
await getFlightDetails(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/onlineboard/details");
|
||||
expect(url.searchParams.get("flights")).toBe("SU100");
|
||||
expect(url.searchParams.get("dates")).toBe("2025-01-15");
|
||||
});
|
||||
|
||||
it("returns the deserialized IBoardResponse", async () => {
|
||||
const { client } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const result = await getFlightDetails(client, {
|
||||
flights: "SU100",
|
||||
dates: "2025-01-15",
|
||||
});
|
||||
|
||||
expect(result).toEqual(BOARD_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
getFlightDetails(client, { flights: "SU999", dates: "2025-01-15" }),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCalendarDays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCalendarDays", () => {
|
||||
it("builds correct path for flight type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const params: CalendarParams = {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
};
|
||||
|
||||
await getCalendarDays(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/flight/SU100/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for departure type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "departure",
|
||||
departure: "SVO",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/departure/SVO/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for arrival type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "arrival",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/arrival/JFK/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for route type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "route",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe(
|
||||
"/v1/days/2025-01-15/31/route/SVO-JFK/board/",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses comma-separated days string into array", async () => {
|
||||
const { client } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const result = await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
});
|
||||
|
||||
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 getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Online Board 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 {
|
||||
IBoardResponse,
|
||||
IDaysResponse,
|
||||
FlightRequestType,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchFlightsParams {
|
||||
/** Flight number search: e.g. "SU100" */
|
||||
flightNumber?: string;
|
||||
/** Date range start — yyyyMMdd format (used by the API as `dateFrom`) */
|
||||
dateFrom: string;
|
||||
/** Date range end — yyyyMMdd format (used by the API as `dateTo`) */
|
||||
dateTo: string;
|
||||
/** Departure airport IATA code */
|
||||
departure?: string;
|
||||
/** Arrival airport IATA code */
|
||||
arrival?: string;
|
||||
/** Time range start — HHmm format */
|
||||
timeFrom?: string;
|
||||
/** Time range end — HHmm format */
|
||||
timeTo?: string;
|
||||
}
|
||||
|
||||
export interface FlightDetailsParams {
|
||||
/** Carrier code + flight number, e.g. "SU100" */
|
||||
flights: string;
|
||||
/** Date in yyyy-MM-dd format */
|
||||
dates: string;
|
||||
}
|
||||
|
||||
export interface CalendarParams {
|
||||
/** Base date — yyyy-MM-dd format */
|
||||
date: string;
|
||||
/** Search type discriminator */
|
||||
searchType: FlightRequestType;
|
||||
/** Flight number (for "flight" type), e.g. "SU100" */
|
||||
flightNumber?: string;
|
||||
/** Departure airport IATA code (for "departure" or "route" type) */
|
||||
departure?: string;
|
||||
/** Arrival airport IATA code (for "arrival" or "route" type) */
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search flights on the online board.
|
||||
* Maps to: `GET /board?flightNumber=...&dateFrom=...&dateTo=...&...`
|
||||
*/
|
||||
export async function searchFlights(
|
||||
client: ApiClient,
|
||||
params: SearchFlightsParams,
|
||||
): Promise<IBoardResponse> {
|
||||
const query: Record<string, string> = {
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
};
|
||||
|
||||
if (params.flightNumber) query["flightNumber"] = params.flightNumber;
|
||||
if (params.departure) query["departure"] = params.departure;
|
||||
if (params.arrival) query["arrival"] = params.arrival;
|
||||
if (params.timeFrom) query["timeFrom"] = params.timeFrom;
|
||||
if (params.timeTo) query["timeTo"] = params.timeTo;
|
||||
|
||||
return client.get<IBoardResponse>("board", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flight details.
|
||||
* Maps to: `GET /onlineboard/details?flights=SU100&dates=2025-01-15`
|
||||
*/
|
||||
export async function getFlightDetails(
|
||||
client: ApiClient,
|
||||
params: FlightDetailsParams,
|
||||
): Promise<IBoardResponse> {
|
||||
return client.get<IBoardResponse>("onlineboard/details", {
|
||||
flights: params.flights,
|
||||
dates: params.dates,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available calendar days for a given search context.
|
||||
* Maps to: `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`
|
||||
*
|
||||
* The API returns `{ days: "2025-01-01,2025-01-02,..." }` — a single
|
||||
* comma-separated string. This function splits it into `string[]`.
|
||||
*/
|
||||
export async function getCalendarDays(
|
||||
client: ApiClient,
|
||||
params: CalendarParams,
|
||||
): Promise<string[]> {
|
||||
const searchSegment = buildCalendarSearchSegment(params);
|
||||
const path = `v1/days/${params.date}/31/${searchSegment}/board/`;
|
||||
|
||||
const response = await client.get<IDaysResponse>(path);
|
||||
return parseCalendarDays(response.days);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildCalendarSearchSegment(params: CalendarParams): string {
|
||||
switch (params.searchType) {
|
||||
case "flight":
|
||||
return `flight/${params.flightNumber ?? ""}`;
|
||||
case "departure":
|
||||
return `departure/${params.departure ?? ""}`;
|
||||
case "arrival":
|
||||
return `arrival/${params.arrival ?? ""}`;
|
||||
case "route": {
|
||||
const dep = params.departure ?? "";
|
||||
const arr = params.arrival ?? "";
|
||||
return `route/${dep}-${arr}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCalendarDays(days: string): string[] {
|
||||
if (!days) return [];
|
||||
return days.split(",").map((d) => d.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* React hook for calendar day availability.
|
||||
*
|
||||
* Calls `getCalendarDays` on param change, manages loading/days state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getCalendarDays } from "../api.js";
|
||||
import type { CalendarParams } from "../api.js";
|
||||
|
||||
export interface UseCalendarDaysResult {
|
||||
days: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the calendar strip. Fetches available flight days for the
|
||||
* given search context.
|
||||
*/
|
||||
export function useCalendarDays(params: CalendarParams): UseCalendarDaysResult {
|
||||
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);
|
||||
|
||||
getCalendarDays(client, paramsRef.current)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDays(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params.date,
|
||||
params.searchType,
|
||||
params.flightNumber,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
]);
|
||||
|
||||
return { days, loading };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* React hook for the flight details page.
|
||||
*
|
||||
* Calls `getFlightDetails` on param change, manages loading/error/data state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getFlightDetails } from "../api.js";
|
||||
import type { FlightDetailsParams } from "../api.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseFlightDetailsResult {
|
||||
flight: ISimpleFlight | null;
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the flight details page. Fetches a single flight's details
|
||||
* based on flight ID params.
|
||||
*/
|
||||
export function useFlightDetails(
|
||||
params: FlightDetailsParams,
|
||||
): UseFlightDetailsResult {
|
||||
const client = useApiClient();
|
||||
const [flight, setFlight] = useState<ISimpleFlight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getFlightDetails(client, paramsRef.current)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
// Details endpoint returns a board response; take the first route
|
||||
const firstFlight = response.data.routes[0] ?? null;
|
||||
setFlight(firstFlight);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, params.flights, params.dates]);
|
||||
|
||||
return { flight, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* React hook for the online board search pages.
|
||||
*
|
||||
* Calls `searchFlights` on param change, manages loading/error/data state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { searchFlights } from "../api.js";
|
||||
import type { SearchFlightsParams } from "../api.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseOnlineBoardResult {
|
||||
flights: ISimpleFlight[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for online board search pages. Fetches flights based on search params
|
||||
* and provides refresh capability.
|
||||
*/
|
||||
export function useOnlineBoard(
|
||||
params: SearchFlightsParams,
|
||||
): UseOnlineBoardResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<ISimpleFlight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Serialize params for useEffect dependency
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
searchFlights(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,
|
||||
params.flightNumber,
|
||||
params.dateFrom,
|
||||
params.dateTo,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
params.timeFrom,
|
||||
params.timeTo,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
return { flights, loading, error, refresh };
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
// must import exclusively from "@/features/online-board", never from
|
||||
// deeper paths. See docs/superpowers/phase-1/frozen-barrels.md for the rule.
|
||||
|
||||
export type { OnlineBoardParams } from "./url";
|
||||
// 2B — URL serializer/parser
|
||||
export type { OnlineBoardParams } from "./url.js";
|
||||
export {
|
||||
parseOnlineBoardUrl,
|
||||
buildOnlineBoardUrl,
|
||||
@@ -11,4 +12,20 @@ export {
|
||||
buildFlightUrlParams,
|
||||
parseStationUrlParams,
|
||||
parseRouteUrlParams,
|
||||
} from "./url";
|
||||
} from "./url.js";
|
||||
|
||||
// 2C — API functions
|
||||
export type {
|
||||
SearchFlightsParams,
|
||||
FlightDetailsParams,
|
||||
CalendarParams,
|
||||
} from "./api.js";
|
||||
export { searchFlights, getFlightDetails, getCalendarDays } from "./api.js";
|
||||
|
||||
// 2C — React hooks
|
||||
export { useOnlineBoard } from "./hooks/useOnlineBoard.js";
|
||||
export type { UseOnlineBoardResult } from "./hooks/useOnlineBoard.js";
|
||||
export { useFlightDetails } from "./hooks/useFlightDetails.js";
|
||||
export type { UseFlightDetailsResult } from "./hooks/useFlightDetails.js";
|
||||
export { useCalendarDays } from "./hooks/useCalendarDays.js";
|
||||
export type { UseCalendarDaysResult } from "./hooks/useCalendarDays.js";
|
||||
|
||||
Reference in New Issue
Block a user