From aa610492296372a0a0bae8143106aa148aac185d Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 09:40:19 +0300 Subject: [PATCH] Add flights-map types, API functions, hooks, and feature flag Phase 4A: Define IFlightRoute, IMapMarker, IMapPolyline types; implement searchDestinations and getFlightsMapCalendar API functions with 11 tests; add useFlightsMapSearch, useFlightsMapCalendar hooks; add FEATURE_FLIGHTS_MAP env var for feature flag gating. --- src/env/index.ts | 3 + src/features/flights-map/api.test.ts | 217 ++++++++++++++++++ src/features/flights-map/api.ts | 118 ++++++++++ .../flights-map/hooks/useFeatureFlag.ts | 28 +++ .../hooks/useFlightsMapCalendar.ts | 69 ++++++ .../flights-map/hooks/useFlightsMapSearch.ts | 83 +++++++ src/features/flights-map/types.ts | 122 ++++++++++ 7 files changed, 640 insertions(+) create mode 100644 src/features/flights-map/api.test.ts create mode 100644 src/features/flights-map/api.ts create mode 100644 src/features/flights-map/hooks/useFeatureFlag.ts create mode 100644 src/features/flights-map/hooks/useFlightsMapCalendar.ts create mode 100644 src/features/flights-map/hooks/useFlightsMapSearch.ts create mode 100644 src/features/flights-map/types.ts diff --git a/src/env/index.ts b/src/env/index.ts index cc6dac93..945bf3cf 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -18,6 +18,7 @@ const EnvSchema = z.object({ ANALYTICS_CTM: boolish.default("false"), ANALYTICS_VARIOCUBE: boolish.default("false"), ANALYTICS_DYNATRACE: boolish.default("false"), + FEATURE_FLIGHTS_MAP: boolish.default("false"), VERSION: z.string().min(1), }); @@ -33,6 +34,7 @@ export interface Env { OTEL_EXPORTER_OTLP_HEADERS?: string; LOGS_ENDPOINT?: string; ANALYTICS_ENABLED: AnalyticsProviders; + FEATURE_FLIGHTS_MAP: boolean; VERSION: string; } @@ -62,6 +64,7 @@ export function getEnv(): Env { variocube: raw.ANALYTICS_VARIOCUBE, dynatrace: raw.ANALYTICS_DYNATRACE, }, + FEATURE_FLIGHTS_MAP: raw.FEATURE_FLIGHTS_MAP, VERSION: raw.VERSION, }; if (raw.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined) { diff --git a/src/features/flights-map/api.test.ts b/src/features/flights-map/api.test.ts new file mode 100644 index 00000000..05e3ad95 --- /dev/null +++ b/src/features/flights-map/api.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi } from "vitest"; +import { ApiClient } from "@/shared/api/client"; +import { searchDestinations, getFlightsMapCalendar } from "./api"; +import type { + FlightsMapSearchParams, + FlightsMapCalendarParams, + IDestinationsResponse, + IFlightsMapDaysResponse, +} 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]); +} + +const DESTINATIONS_RESPONSE: IDestinationsResponse = { + data: { + routes: [ + { route: ["SVO", "LED"], isDirect: true }, + { route: ["SVO", "DME", "LED"], isDirect: false }, + ], + }, +}; + +const DAYS_RESPONSE: IFlightsMapDaysResponse = { + days: "01101", +}; + +// --------------------------------------------------------------------------- +// searchDestinations +// --------------------------------------------------------------------------- + +describe("searchDestinations", () => { + it("sends GET to destinations/1 with query params", async () => { + const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE); + + const params: FlightsMapSearchParams = { + departure: "SVO", + arrival: "LED", + dateFrom: "20250601", + dateTo: "20251201", + }; + + await searchDestinations(client, params); + + const url = extractUrl(mockFetch); + expect(url.pathname).toBe("/destinations/1"); + expect(url.searchParams.get("departure")).toBe("SVO"); + expect(url.searchParams.get("arrival")).toBe("LED"); + expect(url.searchParams.get("dateFrom")).toBe("20250601"); + expect(url.searchParams.get("dateTo")).toBe("20251201"); + expect(url.searchParams.get("connections")).toBe("0"); + }); + + it("omits arrival param when not provided", async () => { + const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE); + + await searchDestinations(client, { + departure: "SVO", + dateFrom: "20250601", + dateTo: "20251201", + }); + + const url = extractUrl(mockFetch); + expect(url.searchParams.has("arrival")).toBe(false); + }); + + it("sends connections param when specified", async () => { + const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE); + + await searchDestinations(client, { + departure: "SVO", + arrival: "LED", + dateFrom: "20250601", + dateTo: "20251201", + connections: 1, + }); + + const url = extractUrl(mockFetch); + expect(url.searchParams.get("connections")).toBe("1"); + }); + + it("returns the deserialized response", async () => { + const { client } = createMockClient(DESTINATIONS_RESPONSE); + + const result = await searchDestinations(client, { + departure: "SVO", + arrival: "LED", + dateFrom: "20250601", + dateTo: "20251201", + }); + + expect(result).toEqual(DESTINATIONS_RESPONSE); + }); + + it("throws on server error", async () => { + const { client } = createMockClient({ error: "internal" }, 500); + + await expect( + searchDestinations(client, { + departure: "SVO", + dateFrom: "20250601", + dateTo: "20251201", + }), + ).rejects.toThrow("HTTP 500"); + }); +}); + +// --------------------------------------------------------------------------- +// getFlightsMapCalendar +// --------------------------------------------------------------------------- + +describe("getFlightsMapCalendar", () => { + it("builds correct path for direct route", async () => { + const { client, mockFetch } = createMockClient(DAYS_RESPONSE); + + await getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + arrival: "LED", + connections: false, + }); + + const url = extractUrl(mockFetch); + expect(url.pathname).toBe("/days/20250601/200/route/SVO-LED/flights-map/v1"); + }); + + it("builds correct path for connecting route", async () => { + const { client, mockFetch } = createMockClient(DAYS_RESPONSE); + + await getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + arrival: "LED", + connections: true, + }); + + const url = extractUrl(mockFetch); + expect(url.pathname).toBe( + "/days/20250601/200/connections/SVO-LED-1/flights-map/v1", + ); + }); + + it("builds correct path for departure-only (spider mode)", async () => { + const { client, mockFetch } = createMockClient(DAYS_RESPONSE); + + await getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + connections: false, + }); + + const url = extractUrl(mockFetch); + expect(url.pathname).toBe("/days/20250601/200/departure/SVO/flights-map/v1"); + }); + + it("parses binary days string into available date strings", async () => { + const { client } = createMockClient({ days: "10110" }); + + const result = await getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + arrival: "LED", + connections: false, + }); + + expect(result).toEqual(["20250601", "20250603", "20250604"]); + }); + + it("returns empty array for empty days string", async () => { + const { client } = createMockClient({ days: "" }); + + const result = await getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + arrival: "LED", + connections: false, + }); + + expect(result).toEqual([]); + }); + + it("throws on server error", async () => { + const { client } = createMockClient({ error: "internal" }, 500); + + await expect( + getFlightsMapCalendar(client, { + date: "20250601", + departure: "SVO", + arrival: "LED", + connections: false, + }), + ).rejects.toThrow("HTTP 500"); + }); +}); diff --git a/src/features/flights-map/api.ts b/src/features/flights-map/api.ts new file mode 100644 index 00000000..222f0be8 --- /dev/null +++ b/src/features/flights-map/api.ts @@ -0,0 +1,118 @@ +/** + * Flights Map 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 { + FlightsMapSearchParams, + FlightsMapCalendarParams, + IDestinationsResponse, + IFlightsMapDaysResponse, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// API functions +// --------------------------------------------------------------------------- + +/** + * Search flight destinations/routes on the map. + * Maps to: `GET destinations/1?departure=...&arrival=...&dateFrom=...&dateTo=...&connections=0` + */ +export async function searchDestinations( + client: ApiClient, + params: FlightsMapSearchParams, +): Promise { + const query: Record = { + departure: params.departure, + dateFrom: params.dateFrom, + dateTo: params.dateTo, + connections: String(params.connections ?? 0), + }; + + if (params.arrival) { + query["arrival"] = params.arrival; + } + + return client.get("destinations/1", query); +} + +/** + * Get available calendar days for a flights-map route. + * Maps to: `GET days/{date}/200/{routeSegment}/flights-map/v1` + * + * The `days` response field is a binary string ("0110...") where each + * character represents one day starting from `date`. We parse it into + * an array of available date strings. + */ +export async function getFlightsMapCalendar( + client: ApiClient, + params: FlightsMapCalendarParams, +): Promise { + const routeSegment = buildRouteSegment(params); + if (!routeSegment) return []; + + const path = `days/${params.date}/200/${routeSegment}/flights-map/v1`; + const response = await client.get(path); + return parseBinaryDays(response.days, params.date); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function buildRouteSegment(params: FlightsMapCalendarParams): string | null { + if (params.departure && params.arrival) { + return params.connections + ? `connections/${params.departure}-${params.arrival}-1` + : `route/${params.departure}-${params.arrival}`; + } + + if (params.departure) { + return `departure/${params.departure}`; + } + + return null; +} + +/** + * Parse a binary days string ("01101...") into an array of ISO date strings + * representing the available (=1) days, starting from `startDate`. + */ +function parseBinaryDays(days: string, startDate: string): string[] { + if (!days) return []; + + const result: string[] = []; + const start = parseYyyymmdd(startDate); + if (!start) return []; + + for (let i = 0; i < days.length; i++) { + if (days[i] === "1") { + const d = new Date(start); + d.setDate(d.getDate() + i); + result.push(formatYyyymmdd(d)); + } + } + + return result; +} + +function parseYyyymmdd(s: string): Date | null { + if (s.length !== 8) return null; + const year = Number(s.slice(0, 4)); + const month = Number(s.slice(4, 6)) - 1; + const day = Number(s.slice(6, 8)); + const d = new Date(year, month, day); + return Number.isNaN(d.getTime()) ? null : d; +} + +function formatYyyymmdd(d: Date): string { + const y = d.getFullYear().toString(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}${m}${day}`; +} diff --git a/src/features/flights-map/hooks/useFeatureFlag.ts b/src/features/flights-map/hooks/useFeatureFlag.ts new file mode 100644 index 00000000..08d9d687 --- /dev/null +++ b/src/features/flights-map/hooks/useFeatureFlag.ts @@ -0,0 +1,28 @@ +/** + * Feature flag hook for the flights-map feature. + * + * Reads from environment configuration. In future phases this can be + * extended to read from a remote feature flag service. + * + * @module + */ + +import { getEnv } from "@/env/index.js"; + +/** + * Known feature flag names. + */ +type FeatureFlagName = "flightsMap"; + +/** + * Check whether a feature flag is enabled. + * Currently backed by env vars; extend to remote config as needed. + */ +export function useFeatureFlag(flag: FeatureFlagName): boolean { + const env = getEnv(); + + switch (flag) { + case "flightsMap": + return env.FEATURE_FLIGHTS_MAP ?? false; + } +} diff --git a/src/features/flights-map/hooks/useFlightsMapCalendar.ts b/src/features/flights-map/hooks/useFlightsMapCalendar.ts new file mode 100644 index 00000000..84ff5e72 --- /dev/null +++ b/src/features/flights-map/hooks/useFlightsMapCalendar.ts @@ -0,0 +1,69 @@ +/** + * React hook for flights map calendar day availability. + * + * Calls `getFlightsMapCalendar` on param change, manages loading/days state. + * + * @module + */ + +import { useState, useEffect, useRef } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getFlightsMapCalendar } from "../api.js"; +import type { FlightsMapCalendarParams } from "../types.js"; + +export interface UseFlightsMapCalendarResult { + availableDays: string[]; + loading: boolean; +} + +/** + * Hook for the calendar strip. Fetches available flights-map days for the + * given route context. + */ +export function useFlightsMapCalendar( + params: FlightsMapCalendarParams | null, +): UseFlightsMapCalendarResult { + const client = useApiClient(); + const [availableDays, setAvailableDays] = useState([]); + const [loading, setLoading] = useState(false); + + const paramsRef = useRef(params); + paramsRef.current = params; + + useEffect(() => { + if (!paramsRef.current) { + setAvailableDays([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + + getFlightsMapCalendar(client, paramsRef.current) + .then((result) => { + if (!cancelled) { + setAvailableDays(result); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setAvailableDays([]); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + client, + params?.date, + params?.departure, + params?.arrival, + params?.connections, + ]); + + return { availableDays, loading }; +} diff --git a/src/features/flights-map/hooks/useFlightsMapSearch.ts b/src/features/flights-map/hooks/useFlightsMapSearch.ts new file mode 100644 index 00000000..6194da18 --- /dev/null +++ b/src/features/flights-map/hooks/useFlightsMapSearch.ts @@ -0,0 +1,83 @@ +/** + * React hook for flights map destination search. + * + * Calls `searchDestinations` on param change, manages loading/error/data state. + * No SignalR -- map data is static. + * + * @module + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { searchDestinations } from "../api.js"; +import type { FlightsMapSearchParams, IDestinationsResponse, IFlightRoute } from "../types.js"; +import type { ApiError } from "@/shared/api/errors.js"; + +export interface UseFlightsMapSearchResult { + routes: IFlightRoute[]; + loading: boolean; + error: ApiError | null; + refresh: () => void; +} + +/** + * Hook for the flights map. Fetches destination routes based on search params + * and provides refresh capability. + */ +export function useFlightsMapSearch( + params: FlightsMapSearchParams | null, +): UseFlightsMapSearchResult { + const client = useApiClient(); + const [routes, setRoutes] = useState([]); + const [loading, setLoading] = useState(false); + 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(() => { + if (!paramsRef.current) { + setRoutes([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + searchDestinations(client, paramsRef.current) + .then((response: IDestinationsResponse) => { + if (!cancelled) { + setRoutes(response.data.routes); + setLoading(false); + } + }) + .catch((err: ApiError) => { + if (!cancelled) { + setError(err); + setRoutes([]); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + client, + params?.departure, + params?.arrival, + params?.dateFrom, + params?.dateTo, + params?.connections, + refreshKey, + ]); + + return { routes, loading, error, refresh }; +} diff --git a/src/features/flights-map/types.ts b/src/features/flights-map/types.ts new file mode 100644 index 00000000..9234bd75 --- /dev/null +++ b/src/features/flights-map/types.ts @@ -0,0 +1,122 @@ +/** + * Data model types for the Flights Map feature. + * + * Covers API request/response shapes, map marker/polyline props, + * and filter state. + * + * @module + */ + +// --------------------------------------------------------------------------- +// API request types +// --------------------------------------------------------------------------- + +/** + * Parameters for GET destinations/1 search. + */ +export interface FlightsMapSearchParams { + departure: string; + arrival?: string; + dateFrom: string; + dateTo: string; + connections?: number; +} + +/** + * Parameters for GET days/{date}/200/{route}/flights-map/v1 calendar. + */ +export interface FlightsMapCalendarParams { + date: string; + departure: string; + arrival?: string; + connections: boolean; +} + +// --------------------------------------------------------------------------- +// API response types +// --------------------------------------------------------------------------- + +/** + * A single flight route from the destinations API. + */ +export interface IFlightRoute { + route: string[]; + isDirect: boolean; +} + +/** + * Response from GET destinations/1. + */ +export interface IDestinationsResponse { + data: { + routes: IFlightRoute[]; + }; +} + +/** + * Response from GET days/.../flights-map/v1. + * The `days` field is a string of "0" and "1" characters, one per day. + */ +export interface IFlightsMapDaysResponse { + days: string; +} + +// --------------------------------------------------------------------------- +// Map component prop types +// --------------------------------------------------------------------------- + +/** + * Marker style variant for the map. + */ +export type MarkerStyle = "blue" | "blue-small" | "orange"; + +/** + * A marker to render on the map. + */ +export interface IMapMarker { + id: string; + lat: number; + lng: number; + style: MarkerStyle; + label?: string; + tooltipPermanent?: boolean; +} + +/** + * Polyline style variant for the map. + */ +export type PolylineStyle = "direct" | "connecting"; + +/** + * A polyline to render on the map. + */ +export interface IMapPolyline { + id: string; + points: Array<{ lat: number; lng: number }>; + style: PolylineStyle; +} + +/** + * Popup content to show on the map. + */ +export interface IMapPopup { + lat: number; + lng: number; + content: string; +} + +// --------------------------------------------------------------------------- +// Filter state +// --------------------------------------------------------------------------- + +/** + * State shape for the flights map filter. + */ +export interface IFlightsMapFilterState { + departure?: string; + arrival?: string; + date?: string; + connections: boolean; + domestic: boolean; + international: boolean; +}