# 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 `<ClientOnly>`. 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<string, ICity>;
  airportByCode: Map<string, IAirport>;

  ruCityCodes: Set<string>;
  otherCityCodes: Set<string>;
}

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<string, string>;
  location?: { lat: number; lon: number };
  country_code: string;
  has_afl_flights?: boolean;
}

export interface IRawAirport {
  code: string;
  city_code: string;
  title: Record<string, string>;
  location?: { lat: number; lon: number };
  has_afl_flights: boolean;
}

export interface IRawCountry {
  code: string;
  title: Record<string, string>;
  world_region_id: number;
}

export interface IRawRegion {
  world_region_id: number;
  title: Record<string, string>;
}

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<string, unknown>,
): 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<lang, string>` 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<IRawDictionaries> {
  const [regions, countries, cities, airports] = await Promise.all([
    client.get<IRawRegion[]>("dictionary/1/world_regions"),
    client.get<IRawCountry[]>("dictionary/1/countries"),
    client.get<IRawCity[]>("dictionary/1/cities"),
    client.get<IRawAirport[]>("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> = {}): 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<string, ICity>(
    cities.map((c) => [c.code.toUpperCase(), c]),
  );
  const airportByCode = new Map<string, IAirport>(
    airports.map((a) => [a.code.toUpperCase(), a]),
  );

  // 3. Partition city codes
  const ruCityCodes = new Set<string>();
  const otherCityCodes = new Set<string>();
  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<unknown>,
): ApiClient {
  return { get: vi.fn(impl) } as unknown as ApiClient;
}

function wrapper(client: ApiClient) {
  return ({ children }: { children: ReactNode }) => (
    <ApiClientProvider client={client}>{children}</ApiClientProvider>
  );
}

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<IDictionariesState>({
    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) && (
  <div
    className="flights-map-start__loader"
    aria-busy="true"
    data-testid="map-loader"
  >
    <div className="page-loader__loader">
      <div className="loader-circle" />
      <div className="loader-line-mask">
        <div className="loader-line" />
      </div>
    </div>
  </div>
)}
```

Find the existing `{!loading && error && (` error block and replace it with:

```tsx
{!loading && !dictionariesLoading && (error || dictionariesError) && (
  <div
    className="flights-map-start__error"
    role="alert"
    data-testid="map-error"
  >
    Failed to load routes. Please try again.
  </div>
)}
```

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 && (
    <div
      className="flights-map-start__empty"
      data-testid="map-no-directions"
    >
      No directions found.
    </div>
  )}
```

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 }) => <a {...props}>{children}</a>,
}));

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (key: string) => key, i18n: { language: "ru" } }),
}));

vi.mock("@/ui/layout/PageTabs.js", () => ({
  PageTabs: () => <div data-testid="page-tabs" />,
}));

vi.mock("./ClientOnly.js", () => ({
  ClientOnly: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

vi.mock("./MapCanvas.js", () => ({
  MapCanvas: () => <div data-testid="map-canvas" />,
}));

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(<FlightsMapStartPage />);
    expect(screen.getByTestId("map-loader")).toBeTruthy();
  });

  it("shows the error banner when dictionaries failed", () => {
    dictState.loading = false;
    dictState.error = new Error("dict boom");
    render(<FlightsMapStartPage />);
    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(<FlightsMapStartPage />);
    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<string, …>`, 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.
