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:
@@ -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.
|
||||
Reference in New Issue
Block a user