# 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` 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

```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.** Left untouched in C.1 because of active consumers; see Architecture note above.
