Add design spec for Flights Map C.1 (Dictionaries)

Captures the data-layer scope for the flights-map rebuild: useDictionaries
hook, parallel fetch of world_regions/countries/cities/airports, transform
rules matching Angular DictionariesService, and the testing contract.
This commit is contained in:
2026-04-17 02:51:22 +03:00
parent 31c6bf1788
commit 397dc2a575
@@ -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 `<ClientOnly>`, 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
└── <co-located .test.ts / .test.tsx files>
```
`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<string, ICity>;
airportByCode: Map<string, IAirport>;
// Partitioned city-code sets (RU vs non-RU)
ruCityCodes: Set<string>;
otherCityCodes: Set<string>;
}
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<string, string>; // { ru: "Москва", en: "Moscow", ... }
location?: { lat: number; lon: number };
country_code: string;
has_afl_flights?: boolean;
}
interface IRawAirport {
code: string;
city_code: string;
title: Record<string, string>;
location?: { lat: number; lon: number };
has_afl_flights: boolean;
}
interface IRawCountry {
code: string;
title: Record<string, string>;
world_region_id: number;
}
interface IRawRegion {
world_region_id: number;
title: Record<string, string>;
}
```
## 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 `<ClientOnly>`; 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.