plan/react-rewrite #1
Vendored
+3
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user