plan/react-rewrite #1
@@ -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