From 9f8a3a45f0a3394c7ee533a25027cbbe21108665 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 03:07:30 +0300 Subject: [PATCH] Add Flights Map C.1 implementation plan and updated spec Spec: narrows useCityName handling (leave untouched due to active consumers). Plan: 10 TDD tasks covering types, api, transform rules, helpers, hook, barrel, page wiring, and regression tests. --- .../2026-04-17-flights-map-c1-dictionaries.md | 1410 +++++++++++++++++ ...4-17-flights-map-c1-dictionaries-design.md | 4 +- 2 files changed, 1412 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-17-flights-map-c1-dictionaries.md diff --git a/docs/superpowers/plans/2026-04-17-flights-map-c1-dictionaries.md b/docs/superpowers/plans/2026-04-17-flights-map-c1-dictionaries.md new file mode 100644 index 00000000..26afaf80 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-flights-map-c1-dictionaries.md @@ -0,0 +1,1410 @@ +# Flights Map C.1 (Dictionaries) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a production-grade dictionaries data layer (cities, airports, countries, regions) for the Flights Map feature, exposed via a `useDictionaries` hook that loads once per session. + +**Architecture:** New module `src/shared/dictionaries/` with typed raw→processed transform, a React hook mirroring the existing `useAppSettings` pattern, and parallel fetch of four endpoints (`/dictionary/1/{world_regions|countries|cities|airports}`). No React Context; the map is the only consumer and mounts once under ``. The map UI is widened to handle the new loading/error states without changing the user-visible layout. + +**Tech Stack:** TypeScript, React 18, vitest, jsdom, @testing-library/react, existing `ApiClient` and `useApiClient` infrastructure. + +**Related spec:** `docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md` + +--- + +## File Structure + +Files created/modified by this plan: + +**New:** +- `src/shared/dictionaries/types.ts` — processed + raw type definitions. +- `src/shared/dictionaries/api.ts` — `fetchDictionaries(client)` parallel fetch. +- `src/shared/dictionaries/api.test.ts` — unit tests for api. +- `src/shared/dictionaries/transform.ts` — pure transform + helper functions. +- `src/shared/dictionaries/transform.test.ts` — unit tests for transform. +- `src/shared/dictionaries/useDictionaries.ts` — the React hook. +- `src/shared/dictionaries/useDictionaries.test.tsx` — hook tests (jsdom). +- `src/shared/dictionaries/index.ts` — barrel export. + +**Modified:** +- `src/features/flights-map/components/FlightsMapStartPage.tsx` — call `useDictionaries(lang)`, widen loading/error conditions. Markers stay empty. +- `src/features/flights-map/components/FlightsMapStartPage.test.tsx` (if exists; else create) — verify the map shows loading/error when dictionaries are loading/failed. + +**Untouched:** +- `src/shared/hooks/useDictionaries.ts` (the old `useCityName` stub) — stays as-is. Active consumers (`StationDisplay`, `PopularRequestItem`) remain on the passthrough. + +--- + +## Task 1: Types + +**Files:** +- Create: `src/shared/dictionaries/types.ts` + +- [ ] **Step 1.1: Create types file** + +Create `src/shared/dictionaries/types.ts` with this exact content: + +```ts +/** + * Flights-map dictionaries types. + * + * Processed shapes live at the top (consumer-facing). Raw response shapes + * live at the bottom (private to api.ts and transform.ts). + */ + +export interface IAirport { + code: string; + name: string; + city_code: string; + location: { lat: number; lon: number }; + has_afl_flights: boolean; +} + +export interface ICity { + code: string; + name: string; + location: { lat: number; lon: number }; + country_code: string; + countryName: string; + has_afl_flights: boolean; + airports: IAirport[]; +} + +export interface ICountry { + code: string; + name: string; + world_region_id: number; +} + +export interface IRegion { + id: number; + name: string; + countries: ICountry[]; +} + +export interface IDictionaries { + regions: IRegion[]; + countries: ICountry[]; + cities: ICity[]; + airports: IAirport[]; + + cityByCode: Map; + airportByCode: Map; + + ruCityCodes: Set; + otherCityCodes: Set; +} + +export interface IDictionariesState { + dictionaries: IDictionaries | null; + loading: boolean; + error: Error | null; +} + +// --------------------------------------------------------------------------- +// Raw response shapes (private: api.ts returns these; transform.ts consumes them) +// --------------------------------------------------------------------------- + +export interface IRawCity { + code: string; + title: Record; + location?: { lat: number; lon: number }; + country_code: string; + has_afl_flights?: boolean; +} + +export interface IRawAirport { + code: string; + city_code: string; + title: Record; + location?: { lat: number; lon: number }; + has_afl_flights: boolean; +} + +export interface IRawCountry { + code: string; + title: Record; + world_region_id: number; +} + +export interface IRawRegion { + world_region_id: number; + title: Record; +} + +export interface IRawDictionaries { + regions: IRawRegion[]; + countries: IRawCountry[]; + cities: IRawCity[]; + airports: IRawAirport[]; +} +``` + +- [ ] **Step 1.2: Verify typecheck** + +Run: `pnpm tsc --noEmit` +Expected: PASS (no output). + +- [ ] **Step 1.3: Commit** + +```bash +git add src/shared/dictionaries/types.ts +git commit -m "Add Flights Map dictionaries type module" +``` + +--- + +## Task 2: API layer + +**Files:** +- Create: `src/shared/dictionaries/api.ts` +- Create: `src/shared/dictionaries/api.test.ts` + +- [ ] **Step 2.1: Write failing test** + +Create `src/shared/dictionaries/api.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { fetchDictionaries } from "./api.js"; +import type { ApiClient } from "@/shared/api/client.js"; + +function makeClient( + responses: Record, +): ApiClient { + return { + get: vi.fn(async (path: string) => { + const match = Object.keys(responses).find((k) => path.includes(k)); + if (!match) throw new Error(`Unexpected path: ${path}`); + return responses[match]; + }), + } as unknown as ApiClient; +} + +describe("fetchDictionaries", () => { + it("fetches four endpoints in parallel and returns a combined shape", async () => { + const client = makeClient({ + world_regions: [{ world_region_id: 1, title: { ru: "Европа" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 1 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU" }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true }, + ], + }); + + const result = await fetchDictionaries(client); + + expect(result.regions).toHaveLength(1); + expect(result.countries).toHaveLength(1); + expect(result.cities).toHaveLength(1); + expect(result.airports).toHaveLength(1); + expect(result.cities[0]!.code).toBe("MOW"); + }); + + it("calls exactly the four dictionary paths", async () => { + const getMock = vi.fn(async (_path: string) => []); + const client = { get: getMock } as unknown as ApiClient; + + await fetchDictionaries(client); + + const calledPaths = getMock.mock.calls.map((c) => c[0] as string); + expect(calledPaths).toContain("dictionary/1/world_regions"); + expect(calledPaths).toContain("dictionary/1/countries"); + expect(calledPaths).toContain("dictionary/1/cities"); + expect(calledPaths).toContain("dictionary/1/airports"); + expect(getMock).toHaveBeenCalledTimes(4); + }); + + it("propagates a rejection from any endpoint", async () => { + const client = { + get: vi.fn(async (path: string) => { + if (path.includes("airports")) throw new Error("boom"); + return []; + }), + } as unknown as ApiClient; + + await expect(fetchDictionaries(client)).rejects.toThrow("boom"); + }); +}); +``` + +- [ ] **Step 2.2: Run test to verify it fails** + +Run: `pnpm vitest run src/shared/dictionaries/api.test.ts` +Expected: FAIL with "Cannot find module './api.js'" or similar. + +- [ ] **Step 2.3: Implement api.ts** + +Create `src/shared/dictionaries/api.ts`: + +```ts +/** + * Parallel fetch of the four dictionary endpoints. + * + * Paths match the Angular `NetworkService.getDictionary` pattern: + * `/dictionary/1/{name}`. Locale is not part of the path; the server + * returns all titles as a `Record` and the consumer picks. + */ + +import type { ApiClient } from "@/shared/api/client.js"; +import type { + IRawDictionaries, + IRawRegion, + IRawCountry, + IRawCity, + IRawAirport, +} from "./types.js"; + +export async function fetchDictionaries( + client: ApiClient, +): Promise { + const [regions, countries, cities, airports] = await Promise.all([ + client.get("dictionary/1/world_regions"), + client.get("dictionary/1/countries"), + client.get("dictionary/1/cities"), + client.get("dictionary/1/airports"), + ]); + + return { regions, countries, cities, airports }; +} +``` + +- [ ] **Step 2.4: Run tests to verify they pass** + +Run: `pnpm vitest run src/shared/dictionaries/api.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 2.5: Commit** + +```bash +git add src/shared/dictionaries/api.ts src/shared/dictionaries/api.test.ts +git commit -m "Add fetchDictionaries parallel-fetch layer" +``` + +--- + +## Task 3: Transform — filtering and partitioning + +**Files:** +- Create: `src/shared/dictionaries/transform.ts` +- Create: `src/shared/dictionaries/transform.test.ts` + +- [ ] **Step 3.1: Write failing tests for core filtering rules** + +Create `src/shared/dictionaries/transform.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { transformDictionaries } from "./transform.js"; +import type { IRawDictionaries } from "./types.js"; + +function makeRaw(overrides: Partial = {}): IRawDictionaries { + return { + regions: [], + countries: [], + cities: [], + airports: [], + ...overrides, + }; +} + +describe("transformDictionaries — filtering", () => { + it("drops cities whose title.ru is purely ASCII (garbage-data guard)", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва", en: "Moscow" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "BAD", title: { ru: "Moscow", en: "Moscow" }, country_code: "RU", has_afl_flights: true, location: { lat: 0, lon: 0 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево", en: "SVO" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.cities.map((c) => c.code)).toEqual(["MOW"]); + }); + + it("drops airports with has_afl_flights=false", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "DME", city_code: "MOW", title: { ru: "Домодедово" }, has_afl_flights: false, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.airports.map((a) => a.code)).toEqual(["SVO"]); + }); + + it("drops airports whose title.ru is purely ASCII", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "SVO" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.airports).toHaveLength(0); + }); + + it("drops cities that end up with no afl airports", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "NOW", title: { ru: "Никогорск" }, country_code: "RU", has_afl_flights: true, location: { lat: 0, lon: 0 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect(d.cities.map((c) => c.code)).toEqual(["MOW"]); + }); + + it("partitions city codes into ruCityCodes and otherCityCodes", () => { + const raw = makeRaw({ + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "PAR", title: { ru: "Париж" }, country_code: "FR", has_afl_flights: true, location: { lat: 48, lon: 2 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "CDG", city_code: "PAR", title: { ru: "Шарль-де-Голль" }, has_afl_flights: true, location: { lat: 48, lon: 2 } }, + ], + }); + + const d = transformDictionaries(raw, "ru"); + + expect([...d.ruCityCodes]).toEqual(["MOW"]); + expect([...d.otherCityCodes]).toEqual(["PAR"]); + }); +}); +``` + +- [ ] **Step 3.2: Run tests to verify they fail** + +Run: `pnpm vitest run src/shared/dictionaries/transform.test.ts` +Expected: FAIL with "Cannot find module './transform.js'". + +- [ ] **Step 3.3: Implement transform.ts (first pass: filter + partition)** + +Create `src/shared/dictionaries/transform.ts`: + +```ts +/** + * Pure transform from raw dictionary responses to consumer-facing shapes. + * + * Ports the rules from Angular's DictionariesService.handleLoading: + * - drop cities whose title.ru is purely ASCII (garbage-data guard) + * - drop airports without afl flights or with ASCII title.ru + * - attach airports to their city (sorted by localized title) + * - drop cities that end up with no afl airports + * - enrich city.name, city.countryName from localized titles + * - build lookup maps keyed by uppercase code + * - partition cities into RU vs non-RU sets + * - flatten regions (Russia first, Australia filtered out) + */ + +import type { + IAirport, + ICity, + ICountry, + IDictionaries, + IRegion, + IRawAirport, + IRawCity, + IRawCountry, + IRawDictionaries, + IRawRegion, +} from "./types.js"; + +const ASCII_ONLY = /^[a-zA-Z.,:; ]+$/; +const AUSTRALIA_REGION_ID = 500373; +const RUSSIA_REGION_ID = 500374; + +export function transformDictionaries( + raw: IRawDictionaries, + lang: string, +): IDictionaries { + // 1. City ASCII-title filter + const citiesAfterAsciiFilter = raw.cities.filter( + (c) => !ASCII_ONLY.test(c.title["ru"] ?? ""), + ); + + // 2. Airport filter: has_afl_flights && non-ASCII ru title + const airportsFiltered = raw.airports.filter( + (a) => + a.has_afl_flights === true && + !ASCII_ONLY.test(a.title["ru"] ?? ""), + ); + + // 3+5. Enrich cities with airports, name, countryName + const countriesByCode = new Map( + raw.countries.map((c) => [c.code, c]), + ); + + const citiesEnriched: ICity[] = citiesAfterAsciiFilter.map((c) => { + const airports = airportsFiltered + .filter((a) => a.city_code === c.code) + .map((a) => shapeAirport(a, lang)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const country = countriesByCode.get(c.country_code); + + return { + code: c.code, + name: c.title[lang] ?? c.code, + location: c.location ?? { lat: 0, lon: 0 }, + country_code: c.country_code, + countryName: country?.title[lang] ?? "", + has_afl_flights: airports.length > 0, + airports, + }; + }); + + // 6. Drop cities with no afl airports + const cities = citiesEnriched.filter((c) => c.has_afl_flights); + + // Processed airport list mirrors the per-city arrays, but we also expose a flat list + const airports = cities.flatMap((c) => c.airports); + + // 4. Lookup maps (uppercase keys) + const cityByCode = new Map( + cities.map((c) => [c.code.toUpperCase(), c]), + ); + const airportByCode = new Map( + airports.map((a) => [a.code.toUpperCase(), a]), + ); + + // 3. Partition city codes + const ruCityCodes = new Set(); + const otherCityCodes = new Set(); + for (const c of cities) { + if (c.country_code === "RU") ruCityCodes.add(c.code); + else otherCityCodes.add(c.code); + } + + // 7. Regions (Russia first, Australia dropped) + const countries: ICountry[] = raw.countries.map((c) => ({ + code: c.code, + name: c.title[lang] ?? c.code, + world_region_id: c.world_region_id, + })); + + const regions = buildRegions(raw.regions, countries, lang); + + return { + regions, + countries, + cities, + airports, + cityByCode, + airportByCode, + ruCityCodes, + otherCityCodes, + }; +} + +function shapeAirport(a: IRawAirport, lang: string): IAirport { + return { + code: a.code, + name: a.title[lang] ?? a.code, + city_code: a.city_code, + location: a.location ?? { lat: 0, lon: 0 }, + has_afl_flights: a.has_afl_flights, + }; +} + +function buildRegions( + raw: IRawRegion[], + countries: ICountry[], + lang: string, +): IRegion[] { + const filtered = raw.filter((r) => r.world_region_id !== AUSTRALIA_REGION_ID); + + // Alphabetical by localized title, then Russia-first + const sorted = [...filtered].sort((a, b) => + (a.title[lang] ?? "").localeCompare(b.title[lang] ?? ""), + ); + const ruIdx = sorted.findIndex((r) => r.world_region_id === RUSSIA_REGION_ID); + if (ruIdx > 0) { + const [ru] = sorted.splice(ruIdx, 1); + if (ru) sorted.unshift(ru); + } + + return sorted.map((r) => ({ + id: r.world_region_id, + name: r.title[lang] ?? "", + countries: countries.filter((c) => c.world_region_id === r.world_region_id), + })); +} +``` + +- [ ] **Step 3.4: Run tests to verify they pass** + +Run: `pnpm vitest run src/shared/dictionaries/transform.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 3.5: Commit** + +```bash +git add src/shared/dictionaries/transform.ts src/shared/dictionaries/transform.test.ts +git commit -m "Add dictionaries transform with filtering and partitioning rules" +``` + +--- + +## Task 4: Transform — enrichment, lookups, regions + +**Files:** +- Modify: `src/shared/dictionaries/transform.test.ts` (append tests) + +- [ ] **Step 4.1: Append failing tests for enrichment and lookups** + +Append to `src/shared/dictionaries/transform.test.ts` (do NOT delete the existing `describe` block): + +```ts +describe("transformDictionaries — enrichment", () => { + it("picks localized city name from title[lang]", () => { + const d = transformDictionaries( + { + regions: [], + countries: [], + cities: [ + { code: "MOW", title: { ru: "Москва", en: "Moscow" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево", en: "Sheremetyevo" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }, + "en", + ); + + expect(d.cities[0]!.name).toBe("Moscow"); + expect(d.cities[0]!.airports[0]!.name).toBe("Sheremetyevo"); + }); + + it("looks up countryName from countries dictionary", () => { + const d = transformDictionaries( + { + regions: [], + countries: [{ code: "RU", title: { ru: "Россия", en: "Russia" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }, + "en", + ); + + expect(d.cities[0]!.countryName).toBe("Russia"); + }); + + it("sorts a city's airports by localized name", () => { + const d = transformDictionaries( + { + regions: [], + countries: [], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "DME", city_code: "MOW", title: { ru: "Домодедово" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "VKO", city_code: "MOW", title: { ru: "Внуково" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }, + "ru", + ); + + expect(d.cities[0]!.airports.map((a) => a.code)).toEqual(["VKO", "DME", "SVO"]); + }); +}); + +describe("transformDictionaries — lookup maps", () => { + it("keys lookup maps by uppercase", () => { + const d = transformDictionaries( + { + regions: [], + countries: [], + cities: [ + { code: "mow", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "svo", city_code: "mow", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }, + "ru", + ); + + expect(d.cityByCode.get("MOW")?.code).toBe("mow"); + expect(d.airportByCode.get("SVO")?.code).toBe("svo"); + }); +}); + +describe("transformDictionaries — regions", () => { + it("drops Australia region (world_region_id 500373)", () => { + const d = transformDictionaries( + { + regions: [ + { world_region_id: 500373, title: { ru: "Австралия" } }, + { world_region_id: 500374, title: { ru: "Россия" } }, + ], + countries: [], + cities: [], + airports: [], + }, + "ru", + ); + + expect(d.regions.map((r) => r.id)).toEqual([500374]); + }); + + it("places Russia region first, others alphabetical", () => { + const d = transformDictionaries( + { + regions: [ + { world_region_id: 500374, title: { ru: "Россия" } }, + { world_region_id: 1, title: { ru: "Азия" } }, + { world_region_id: 2, title: { ru: "Европа" } }, + ], + countries: [], + cities: [], + airports: [], + }, + "ru", + ); + + expect(d.regions.map((r) => r.id)).toEqual([500374, 1, 2]); + }); +}); +``` + +- [ ] **Step 4.2: Run tests to verify they pass** + +The transform implementation from Task 3 already covers these cases. + +Run: `pnpm vitest run src/shared/dictionaries/transform.test.ts` +Expected: PASS — all tests (5 existing + 6 new = 11). + +If any test fails, inspect and adjust only the transform.ts implementation to satisfy all 11 tests together. + +- [ ] **Step 4.3: Commit** + +```bash +git add src/shared/dictionaries/transform.test.ts src/shared/dictionaries/transform.ts +git commit -m "Cover enrichment, lookup-map, and region ordering in transform tests" +``` + +--- + +## Task 5: Helper functions + +**Files:** +- Modify: `src/shared/dictionaries/transform.ts` +- Modify: `src/shared/dictionaries/transform.test.ts` + +- [ ] **Step 5.1: Append failing tests for helpers** + +Append to `src/shared/dictionaries/transform.test.ts`: + +```ts +import { + getCityByCode, + getAirportByCode, + getCityCodeByAirportCode, + getCityOrAirport, + findCityByCoord, +} from "./transform.js"; + +describe("helpers", () => { + const baseline = transformDictionaries( + { + regions: [], + countries: [], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 59, lon: 30 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 59, lon: 30 } }, + ], + }, + "ru", + ); + + it("getCityByCode is case-insensitive", () => { + expect(getCityByCode(baseline, "mow")?.code).toBe("MOW"); + expect(getCityByCode(baseline, "MOW")?.code).toBe("MOW"); + }); + + it("getAirportByCode is case-insensitive", () => { + expect(getAirportByCode(baseline, "svo")?.code).toBe("SVO"); + }); + + it("getCityCodeByAirportCode maps airport → city", () => { + expect(getCityCodeByAirportCode(baseline, "SVO")).toBe("MOW"); + expect(getCityCodeByAirportCode(baseline, "NOPE")).toBeUndefined(); + }); + + it("getCityOrAirport prefers city on collision", () => { + const hit = getCityOrAirport(baseline, "LED"); + // LED is both a city code and an airport code; city wins. + expect(hit && "country_code" in hit).toBe(true); + }); + + it("getCityOrAirport falls back to airport when code is airport-only", () => { + const hit = getCityOrAirport(baseline, "SVO"); + expect(hit && "city_code" in hit).toBe(true); + }); + + it("findCityByCoord returns the nearest city by haversine distance", () => { + const near = findCityByCoord(baseline, 55.1, 37.1); + expect(near?.code).toBe("MOW"); + + const far = findCityByCoord(baseline, 59.5, 30.5); + expect(far?.code).toBe("LED"); + }); + + it("findCityByCoord returns undefined when no cities exist", () => { + const empty = transformDictionaries( + { regions: [], countries: [], cities: [], airports: [] }, + "ru", + ); + expect(findCityByCoord(empty, 0, 0)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 5.2: Run tests to verify they fail** + +Run: `pnpm vitest run src/shared/dictionaries/transform.test.ts` +Expected: FAIL with "getCityByCode is not exported" or similar for each helper. + +- [ ] **Step 5.3: Append helpers to transform.ts** + +Append to `src/shared/dictionaries/transform.ts`: + +```ts +// --------------------------------------------------------------------------- +// Helpers (consumer-facing; pure, take dictionaries as argument) +// --------------------------------------------------------------------------- + +export function getCityByCode( + d: IDictionaries, + code: string, +): ICity | undefined { + return d.cityByCode.get(code.toUpperCase()); +} + +export function getAirportByCode( + d: IDictionaries, + code: string, +): IAirport | undefined { + return d.airportByCode.get(code.toUpperCase()); +} + +export function getCityCodeByAirportCode( + d: IDictionaries, + airportCode: string, +): string | undefined { + return d.airportByCode.get(airportCode.toUpperCase())?.city_code; +} + +export function getCityOrAirport( + d: IDictionaries, + code: string, +): ICity | IAirport | undefined { + return getCityByCode(d, code) ?? getAirportByCode(d, code); +} + +/** + * Returns the city whose location is the nearest to the given lat/lon + * by great-circle (haversine) distance. Returns undefined when the + * dictionaries have no cities. + */ +export function findCityByCoord( + d: IDictionaries, + lat: number, + lon: number, +): ICity | undefined { + if (d.cities.length === 0) return undefined; + + let best: ICity | undefined; + let bestDist = Infinity; + for (const city of d.cities) { + const dist = haversineKm(lat, lon, city.location.lat, city.location.lon); + if (dist < bestDist) { + bestDist = dist; + best = city; + } + } + return best; +} + +function haversineKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const R = 6371; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.sin(dLon / 2) ** 2 * Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRad(deg: number): number { + return (deg * Math.PI) / 180; +} +``` + +- [ ] **Step 5.4: Run tests to verify they pass** + +Run: `pnpm vitest run src/shared/dictionaries/transform.test.ts` +Expected: PASS — all tests (11 existing + 7 new = 18). + +- [ ] **Step 5.5: Commit** + +```bash +git add src/shared/dictionaries/transform.ts src/shared/dictionaries/transform.test.ts +git commit -m "Add dictionary lookup helpers and findCityByCoord" +``` + +--- + +## Task 6: useDictionaries hook + +**Files:** +- Create: `src/shared/dictionaries/useDictionaries.ts` +- Create: `src/shared/dictionaries/useDictionaries.test.tsx` + +- [ ] **Step 6.1: Write failing tests** + +Create `src/shared/dictionaries/useDictionaries.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { useDictionaries } from "./useDictionaries.js"; +import { ApiClientProvider } from "@/shared/api/provider.js"; +import type { ApiClient } from "@/shared/api/client.js"; +import type { IRawDictionaries } from "./types.js"; + +const raw: IRawDictionaries = { + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], +}; + +function makeClient( + impl: (path: string) => Promise, +): ApiClient { + return { get: vi.fn(impl) } as unknown as ApiClient; +} + +function wrapper(client: ApiClient) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useDictionaries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts in a loading state with no data and no error", () => { + const client = makeClient(async () => []); + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + expect(result.current.loading).toBe(true); + expect(result.current.dictionaries).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("resolves to a populated IDictionaries after fetch", async () => { + const client = makeClient(async (path: string) => { + if (path.includes("world_regions")) return raw.regions; + if (path.includes("countries")) return raw.countries; + if (path.includes("cities")) return raw.cities; + if (path.includes("airports")) return raw.airports; + throw new Error("unexpected"); + }); + + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.dictionaries).not.toBeNull(); + expect(result.current.dictionaries!.cities.map((c) => c.code)).toEqual(["MOW"]); + expect(result.current.error).toBeNull(); + }); + + it("sets error and keeps dictionaries null on fetch rejection", async () => { + const client = makeClient(async () => { + throw new Error("boom"); + }); + + const { result } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.dictionaries).toBeNull(); + expect(result.current.error?.message).toBe("boom"); + }); + + it("does not call setState after unmount during an in-flight fetch", async () => { + let resolveFetch: (v: unknown) => void = () => {}; + const deferred = new Promise((r) => { + resolveFetch = r; + }); + const client = makeClient(async () => deferred); + + const warn = vi.spyOn(console, "error").mockImplementation(() => {}); + const { unmount } = renderHook(() => useDictionaries("ru"), { + wrapper: wrapper(client), + }); + + unmount(); + resolveFetch([]); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining("unmounted"), + ); + warn.mockRestore(); + }); +}); +``` + +- [ ] **Step 6.2: Run tests to verify they fail** + +Run: `pnpm vitest run src/shared/dictionaries/useDictionaries.test.tsx` +Expected: FAIL with "Cannot find module './useDictionaries.js'". + +- [ ] **Step 6.3: Implement the hook** + +Create `src/shared/dictionaries/useDictionaries.ts`: + +```ts +/** + * Loads the four dictionary endpoints and returns the transformed, ready-to-use + * IDictionaries object. Mounts once per session (the map is client-only and + * only mounts once). Not intended for multiple concurrent consumers. + */ + +import { useEffect, useState } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { fetchDictionaries } from "./api.js"; +import { transformDictionaries } from "./transform.js"; +import type { IDictionariesState } from "./types.js"; + +export function useDictionaries(lang: string): IDictionariesState { + const client = useApiClient(); + const [state, setState] = useState({ + dictionaries: null, + loading: true, + error: null, + }); + + useEffect(() => { + let cancelled = false; + + fetchDictionaries(client) + .then((raw) => { + if (cancelled) return; + const dictionaries = transformDictionaries(raw, lang); + setState({ dictionaries, loading: false, error: null }); + }) + .catch((err: Error) => { + if (cancelled) return; + setState({ dictionaries: null, loading: false, error: err }); + }); + + return () => { + cancelled = true; + }; + }, [client, lang]); + + return state; +} +``` + +- [ ] **Step 6.4: Run tests to verify they pass** + +Run: `pnpm vitest run src/shared/dictionaries/useDictionaries.test.tsx` +Expected: PASS — 4 tests. + +- [ ] **Step 6.5: Commit** + +```bash +git add src/shared/dictionaries/useDictionaries.ts src/shared/dictionaries/useDictionaries.test.tsx +git commit -m "Add useDictionaries hook wiring api + transform" +``` + +--- + +## Task 7: Barrel export + +**Files:** +- Create: `src/shared/dictionaries/index.ts` + +- [ ] **Step 7.1: Create index.ts** + +Create `src/shared/dictionaries/index.ts`: + +```ts +export { useDictionaries } from "./useDictionaries.js"; +export { + transformDictionaries, + getCityByCode, + getAirportByCode, + getCityCodeByAirportCode, + getCityOrAirport, + findCityByCoord, +} from "./transform.js"; +export { fetchDictionaries } from "./api.js"; +export type { + IAirport, + ICity, + ICountry, + IRegion, + IDictionaries, + IDictionariesState, + IRawAirport, + IRawCity, + IRawCountry, + IRawRegion, + IRawDictionaries, +} from "./types.js"; +``` + +- [ ] **Step 7.2: Verify typecheck** + +Run: `pnpm tsc --noEmit` +Expected: PASS (no output). + +- [ ] **Step 7.3: Commit** + +```bash +git add src/shared/dictionaries/index.ts +git commit -m "Expose dictionaries module barrel" +``` + +--- + +## Task 8: Wire into FlightsMapStartPage + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` + +- [ ] **Step 8.1: Add the dictionaries hook call and widen loading/error branches** + +Open `src/features/flights-map/components/FlightsMapStartPage.tsx`. + +Add the import near the existing React/module imports: + +```tsx +import { useDictionaries } from "@/shared/dictionaries/index.js"; +``` + +Inside the `FlightsMapStartPage` component, right after the existing `useNavigate`/`useParams`/etc. declarations and before the existing state hooks, add: + +```tsx +const { + dictionaries, + loading: dictionariesLoading, + error: dictionariesError, +} = useDictionaries(lang); +``` + +Find the existing `{loading && (` loader block and replace it with: + +```tsx +{(loading || dictionariesLoading) && ( +
+
+
+
+
+
+
+
+)} +``` + +Find the existing `{!loading && error && (` error block and replace it with: + +```tsx +{!loading && !dictionariesLoading && (error || dictionariesError) && ( +
+ Failed to load routes. Please try again. +
+)} +``` + +Find the existing empty-state `{!loading && !error && searchParams !== null && routes.length === 0 && (` block and replace it with: + +```tsx +{!loading && + !dictionariesLoading && + !error && + !dictionariesError && + searchParams !== null && + routes.length === 0 && ( +
+ No directions found. +
+ )} +``` + +Leave `dictionaries` unused for now — C.2 consumes it. TypeScript will complain about the unused binding in strict mode. + +Prefix the unused destructure with an underscore to silence `noUnusedLocals`: + +Change the destructure from: + +```tsx +const { + dictionaries, + loading: dictionariesLoading, + error: dictionariesError, +} = useDictionaries(lang); +``` + +to: + +```tsx +const { + dictionaries: _dictionaries, + loading: dictionariesLoading, + error: dictionariesError, +} = useDictionaries(lang); +``` + +- [ ] **Step 8.2: Verify typecheck** + +Run: `pnpm tsc --noEmit` +Expected: PASS (no output). + +- [ ] **Step 8.3: Run the full vitest suite** + +Run: `pnpm vitest run` +Expected: PASS — no regressions in any feature. + +- [ ] **Step 8.4: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.tsx +git commit -m "Wire useDictionaries into FlightsMapStartPage loading/error states" +``` + +--- + +## Task 9: FlightsMapStartPage loading/error integration tests + +**Files:** +- Create or modify: `src/features/flights-map/components/FlightsMapStartPage.test.tsx` + +- [ ] **Step 9.1: Check if the test file exists** + +Run: `ls src/features/flights-map/components/FlightsMapStartPage.test.tsx 2>/dev/null && echo EXISTS || echo MISSING` + +If EXISTS, open it and skip the scaffolding in step 9.2 — only append the new `describe("dictionaries integration", …)` block. + +If MISSING, proceed with 9.2 as written. + +- [ ] **Step 9.2: Create the test file** + +Create `src/features/flights-map/components/FlightsMapStartPage.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightsMapStartPage } from "./FlightsMapStartPage.js"; + +vi.mock("@modern-js/runtime/router", () => ({ + useParams: () => ({ lang: "ru" }), + Link: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})); + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "ru" } }), +})); + +vi.mock("@/ui/layout/PageTabs.js", () => ({ + PageTabs: () =>
, +})); + +vi.mock("./ClientOnly.js", () => ({ + ClientOnly: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("./MapCanvas.js", () => ({ + MapCanvas: () =>
, +})); + +vi.mock("@/env/index.js", () => ({ + getEnv: () => ({ API_BASE_URL: "https://api.test" }), +})); + +// --- useFlightsMapSearch / Calendar: return steady-state idle shape +vi.mock("../hooks/useFlightsMapSearch.js", () => ({ + useFlightsMapSearch: () => ({ routes: [], loading: false, error: null }), +})); + +vi.mock("../hooks/useFlightsMapCalendar.js", () => ({ + useFlightsMapCalendar: () => ({ availableDays: [] }), +})); + +// --- useDictionaries: mockable per test +const dictState = { + dictionaries: null as unknown, + loading: true, + error: null as Error | null, +}; +vi.mock("@/shared/dictionaries/index.js", () => ({ + useDictionaries: () => dictState, +})); + +describe("FlightsMapStartPage — dictionaries integration", () => { + beforeEach(() => { + dictState.dictionaries = null; + dictState.loading = true; + dictState.error = null; + }); + + it("shows the loader while dictionaries are loading", () => { + dictState.loading = true; + render(); + expect(screen.getByTestId("map-loader")).toBeTruthy(); + }); + + it("shows the error banner when dictionaries failed", () => { + dictState.loading = false; + dictState.error = new Error("dict boom"); + render(); + expect(screen.getByTestId("map-error")).toBeTruthy(); + }); + + it("does not show the loader once dictionaries resolve", () => { + dictState.loading = false; + dictState.dictionaries = { + regions: [], + countries: [], + cities: [], + airports: [], + cityByCode: new Map(), + airportByCode: new Map(), + ruCityCodes: new Set(), + otherCityCodes: new Set(), + }; + render(); + expect(screen.queryByTestId("map-loader")).toBeNull(); + }); +}); +``` + +- [ ] **Step 9.3: Run the new tests** + +Run: `pnpm vitest run src/features/flights-map/components/FlightsMapStartPage.test.tsx` +Expected: PASS — 3 tests. + +If a missing mock surfaces (e.g., `useApiClient` because the real `useDictionaries` is called somewhere the mock misses), add the missing `vi.mock(...)` shim at the top of the file; do not touch the component. + +- [ ] **Step 9.4: Run the full vitest suite for regressions** + +Run: `pnpm vitest run` +Expected: PASS — all tests. + +- [ ] **Step 9.5: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.test.tsx +git commit -m "Test FlightsMapStartPage dictionaries loading/error wiring" +``` + +--- + +## Task 10: Final typecheck + full suite + +- [ ] **Step 10.1: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: PASS (no output). + +- [ ] **Step 10.2: Full vitest suite** + +Run: `pnpm vitest run` +Expected: PASS — all tests green. + +- [ ] **Step 10.3: Count coverage additions** + +Run: `pnpm vitest run src/shared/dictionaries/` +Expected: 25 tests across 3 files (3 api + 18 transform + 4 hook). + +Expected total repo-wide delta: +25 new dictionary tests + 3 start-page integration tests = +28 total. + +If counts differ materially, find out why before proceeding — a missing test is worse than the wrong count. + +--- + +## Self-Review Log + +Ran against the spec: +- **Spec coverage.** Every numbered transform rule in the spec maps to at least one test in Task 3, 4, or 5; helpers map to Task 5; hook lifecycle to Task 6; UI wiring to Task 8; UI regression to Task 9. +- **Placeholders.** None remain. Every code step is literal. +- **Type consistency.** `IDictionaries.cityByCode` and `airportByCode` are `Map`, keyed by uppercase everywhere they're used (transform.ts, helpers, tests). `dictionaries: null` is the resting value for failed/loading state across the spec, hook, and tests. +- **Known deviation.** The spec said "`useCityName` stub removal — grep first"; the updated spec says leave it untouched. This plan keeps the stub alone, matching the updated spec. diff --git a/docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md b/docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md index 07cd1d96..329da2b4 100644 --- a/docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md +++ b/docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md @@ -31,7 +31,7 @@ src/shared/dictionaries/ └── ``` -`useCityName` in `src/shared/hooks/useDictionaries.ts` is removed — it was a passthrough stub that nobody else should depend on; a targeted grep during implementation confirms this before deletion. +`useCityName` in `src/shared/hooks/useDictionaries.ts` stays as-is. It has active consumers (`StationDisplay`, `PopularRequestItem`) that expect synchronous code→string passthrough, which the new dictionaries hook does not provide. Migration of those callers to the real dictionaries is out of scope for C.1 and will be handled later if/when it becomes worthwhile. ## Types @@ -225,4 +225,4 @@ Using `@testing-library/react`'s `renderHook`: - **Endpoint shape.** We're assuming `/dictionary/1/{world_regions|countries|cities|airports}` with no locale segment (Angular parity). If prod returns a different path at integration time, we adjust `api.ts`. This is a known-unknown, not a blocker. - **Australia filter.** Carried over as a direct parity port (`world_region_id === 500373`). If product wants to revisit this, it's a one-line change. -- **`useCityName` stub removal.** `grep` the repo during implementation; if anything still consumes it, update the caller to pull from `useDictionaries().dictionaries?.cityByCode.get(code)?.name` instead. +- **`useCityName` stub.** Left untouched in C.1 because of active consumers; see Architecture note above.