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 new file mode 100644 index 00000000..07cd1d96 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-flights-map-c1-dictionaries-design.md @@ -0,0 +1,228 @@ +# Flights Map C.1: Dictionaries — Design + +**Date:** 2026-04-17 +**Author:** brainstorming session +**Scope:** Sub-feature C.1 of Gap C (Flights Map rebuild) +**Status:** Approved + +## Goal + +Port the Angular `DictionariesService` data layer into the React app. Deliver a typed `useDictionaries` hook that fetches, transforms, and caches the cities / airports / countries / regions dictionaries once per map session. The map UI keeps its current empty state — marker rendering is C.2. + +## Non-Goals + +- Rendering markers from the dictionaries (C.2). +- Drawing routes or polylines (C.3). +- Popups, domestic/international filtering, auto-fallback (C.4). +- Calendar, exchange button, geolocation pre-fill (C.5). +- Cross-feature sharing of the dictionaries hook (only the map consumes it; we'll generalize later if/when another feature needs it). + +## Architecture + +One module under `src/shared/dictionaries/`. The map feature imports from it. No React Context; the hook holds the state locally and the map is already wrapped in ``, so it mounts exactly once per session. + +``` +src/shared/dictionaries/ +├── types.ts interfaces + raw-response shapes +├── api.ts fetchDictionaries(client, lang) +├── transform.ts pure post-processing (filter, enrich, lookup maps) +├── useDictionaries.ts React hook (replaces the current useCityName stub) +├── index.ts barrel +└── +``` + +`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. + +## Types + +```ts +// Processed / consumer-facing shapes +export interface ICity { + code: string; // "MOW" + name: string; // localized (title[lang]) + location: { lat: number; lon: number }; + country_code: string; // "RU" + countryName: string; // localized country name + has_afl_flights: boolean; + airports: IAirport[]; // belongs to this city, sorted by title[lang] +} + +export interface IAirport { + code: string; // "SVO" + name: string; // localized + city_code: string; // "MOW" + location: { lat: number; lon: number }; + has_afl_flights: boolean; +} + +export interface ICountry { + code: string; // "RU" + name: string; // localized + world_region_id: number; +} + +export interface IRegion { + id: number; + name: string; // localized + countries: ICountry[]; +} + +export interface IDictionaries { + regions: IRegion[]; + countries: ICountry[]; + cities: ICity[]; + airports: IAirport[]; + + // Lookup maps keyed by UPPERCASE code + cityByCode: Map; + airportByCode: Map; + + // Partitioned city-code sets (RU vs non-RU) + ruCityCodes: Set; + otherCityCodes: Set; +} + +export interface IDictionariesState { + dictionaries: IDictionaries | null; // null while loading or on error + loading: boolean; + error: Error | null; +} + +// Raw response shapes (private to api.ts / transform.ts) +interface IRawCity { + code: string; + title: Record; // { ru: "Москва", en: "Moscow", ... } + location?: { lat: number; lon: number }; + country_code: string; + has_afl_flights?: boolean; +} + +interface IRawAirport { + code: string; + city_code: string; + title: Record; + location?: { lat: number; lon: number }; + has_afl_flights: boolean; +} + +interface IRawCountry { + code: string; + title: Record; + world_region_id: number; +} + +interface IRawRegion { + world_region_id: number; + title: Record; +} +``` + +## Data Flow + +``` +FlightsMapStartPage mounts (client-only) + └─ useDictionaries(lang) called + └─ useEffect: fetchDictionaries(client, lang) + └─ Promise.all([ + GET /dictionary/1/world_regions, + GET /dictionary/1/countries, + GET /dictionary/1/cities, + GET /dictionary/1/airports, + ]) + └─ transform(rawRegions, rawCountries, rawCities, rawAirports, lang) + └─ setState({ dictionaries, loading: false, error: null }) +``` + +### Transform rules (parity with Angular `DictionariesService`) + +Applied in order: + +1. **City ASCII-title filter.** Drop cities where `title.ru` matches `^[a-zA-Z.,:; ]+$` (Angular garbage-data guard). +2. **Airport filter.** Keep airports with `has_afl_flights === true` AND `title.ru` not purely ASCII. +3. **Partition cities.** Build `ruCityCodes` (`country_code === "RU"`) and `otherCityCodes` (everything else). +4. **Build lookup maps.** `cityByCode` / `airportByCode`, keyed by `code.toUpperCase()`. +5. **Enrich cities:** + - `name = title[lang]` + - `airports = allAirports.filter(a => a.city_code === city.code)` sorted by `title[lang]` + - `has_afl_flights = airports.length > 0` + - `countryName = countries.find(c => c.code === country_code)?.title[lang] ?? ""` +6. **Drop cities with no afl flights.** Keep only `has_afl_flights === true`. +7. **Build regions flat model.** For each region, sort countries by `title[lang]`; Russia first. Drop Australia (`world_region_id === 500373`). This is used by form-filter UI in Angular; the map ignores it, but we port it so the shape stays consistent for future consumers. + +### Helpers (free functions, exported from `transform.ts`) + +```ts +getCityOrAirport(d: IDictionaries, code: string): ICity | IAirport | undefined +getCityByCode(d: IDictionaries, code: string): ICity | undefined +getAirportByCode(d: IDictionaries, code: string): IAirport | undefined +getCityCodeByAirportCode(d: IDictionaries, airportCode: string): string | undefined +findCityByCoord(d: IDictionaries, lat: number, lon: number): ICity | undefined // nearest, via haversine +``` + +All accept a dictionaries argument; no hidden state. + +## Error Handling + +- Any fetch rejection → `{ dictionaries: null, loading: false, error }`. +- Transform is total (pure): on malformed input it returns an `IDictionaries` with the survivable data; it never throws. +- `FlightsMapStartPage` already has a `map-error` branch; widen it to display when `dictionaries.error !== null`. UI text stays the same. +- No retries in C.1. If retry is needed later, add exposed `reload()` from the hook; out of scope now. + +## Lang Sourcing + +The hook takes `lang` as an argument. The call site (`FlightsMapStartPage`) pulls `lang` from `useParams<{ lang: string }>()` and passes it in. This keeps the hook pure of router coupling and makes tests trivially runnable without a router fixture. + +## SSR + +`FlightsMapStartPage` is already wrapped in ``; the dictionaries hook only fires on the client. SSR renders a loading placeholder and the hook runs after hydration. + +## Testing + +Four test files, all vitest. + +### `transform.test.ts` (bulk of coverage, no DOM) + +Covers every rule with small hand-built fixtures: + +- ASCII-title rejection: city with `title.ru = "Moscow"` dropped; with `title.ru = "Москва"` kept. +- Airport filter: `has_afl_flights=false` dropped; `title.ru = "SVO"` (ASCII) dropped; valid airport kept. +- Partition: `ruCityCodes` contains RU cities only; `otherCityCodes` contains non-RU only. +- Lookup maps keyed by uppercase (lookup with lowercase succeeds). +- Enrichment: city.name picks `title[lang]`; city.countryName from matching country; airports attached and sorted. +- `has_afl_flights=false` city dropped after airport attachment. +- Regions: Russia first, Australia dropped. +- Helpers: `findCityByCoord` returns nearest city by haversine distance; `getCityCodeByAirportCode` round-trips; `getCityOrAirport` matches cities before airports on collision. + +### `api.test.ts` + +- Mocks `ApiClient.get`. Verifies four calls to `/dictionary/1/world_regions`, `/countries`, `/cities`, `/airports`. +- Verifies returned object has raw arrays in `{ regions, countries, cities, airports }`. +- Verifies a single fetch rejection propagates (no silent fallback). + +### `useDictionaries.test.tsx` (@vitest-environment jsdom) + +Using `@testing-library/react`'s `renderHook`: + +- Initial state: `{ dictionaries: null, loading: true, error: null }`. +- After fetch resolves with a fixture response, `dictionaries` is populated and `loading=false`. +- Fetch rejection sets `error`, keeps `dictionaries: null`. +- Unmount during in-flight fetch does not call `setState` (no warning). + +### `FlightsMapStartPage.test.tsx` update + +- Add one assertion: when dictionaries are loading, the existing loader overlay renders; when error is set, `map-error` renders. +- Regression: markers array still empty (C.2 introduces population). + +## Success Criteria + +- All new tests pass, full suite green. +- `pnpm tsc --noEmit` clean. +- Mocking the dictionaries endpoints with Angular-shaped fixtures produces a fully populated `IDictionaries` object. +- The map page continues to render exactly as today (empty markers, current error/loading states preserved). +- No regressions in existing online-board / schedule / popular tests. + +## Dependencies & Open Questions + +- **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.