plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
7 changed files with 759 additions and 2 deletions
Showing only changes of commit 8218dffcd9 - Show all commits
@@ -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.
+297
View File
@@ -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");
});
});
+139
View File
@@ -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 };
}
+19 -2
View File
@@ -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";