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.
This commit is contained in:
2026-04-15 09:40:19 +03:00
parent 6703c5a2f2
commit aa61049229
7 changed files with 640 additions and 0 deletions
+3
View File
@@ -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) {
+217
View File
@@ -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<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]);
}
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");
});
});
+118
View File
@@ -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<IDestinationsResponse> {
const query: Record<string, string> = {
departure: params.departure,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
connections: String(params.connections ?? 0),
};
if (params.arrival) {
query["arrival"] = params.arrival;
}
return client.get<IDestinationsResponse>("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<string[]> {
const routeSegment = buildRouteSegment(params);
if (!routeSegment) return [];
const path = `days/${params.date}/200/${routeSegment}/flights-map/v1`;
const response = await client.get<IFlightsMapDaysResponse>(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}`;
}
@@ -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;
}
}
@@ -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<string[]>([]);
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 };
}
@@ -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<IFlightRoute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(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 };
}
+122
View File
@@ -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;
}