# Flights Map C.4 (Popups + Filtering + Fallback) 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:** Close the remaining Angular parity gaps for the Flights Map — add client-side route filtering, auto-fallback to connections=1 when direct routes are empty, departure/arrival buy-ticket popups in route mode, and airport→city-code normalization in the polyline pipeline.

**Architecture:** Two new pure modules (`filterRoutes.ts`, `buyTicketUrl.ts`) are consumed by `FlightsMapStartPage`. `routesToPolylines.ts` gains a `dictionaries` arg and normalizes each route-city-code via `getCityCodeByAirportCode` so API responses containing airport codes still resolve to markers. The page adds `effectiveConnections` state + two effects for auto-fallback + mirror; a `filteredRoutes` memo feeds polylines/intermediates/popups; a `popups` memo renders only in route mode.

**Tech Stack:** TypeScript, React 18, vitest, jsdom, @testing-library/react. No Leaflet work — popup infrastructure already exists in `MapCanvas`.

**Related spec:** `docs/superpowers/specs/2026-04-17-flights-map-c4-popups-filtering-fallback-design.md`

---

## File Structure

**New:**
- `src/features/flights-map/filterRoutes.ts` — pure `filterRoutes(routes, filter, dictionaries)`.
- `src/features/flights-map/filterRoutes.test.ts`
- `src/features/flights-map/buyTicketUrl.ts` — pure `buildBuyTicketUrl(dep, arr, date)` + `escapeHtml`.
- `src/features/flights-map/buyTicketUrl.test.ts`

**Modified:**
- `src/features/flights-map/routesToPolylines.ts` — both functions take `dictionaries` + normalize airport→city codes.
- `src/features/flights-map/routesToPolylines.test.ts` — update existing calls to pass a dictionaries fixture; add normalization tests.
- `src/features/flights-map/components/FlightsMapStartPage.tsx` — add `effectiveConnections` + 2 effects, `filteredRoutes` memo, `popups` memo, pass `popups` prop.
- `src/features/flights-map/components/FlightsMapStartPage.test.tsx` — extend mocks; append 5 integration tests.

**Untouched:**
- `MapCanvas.tsx` — popup sync effect already handles the `popups` prop.
- Dictionaries module.
- `useFlightsMapSearch` hook.

---

## Task 1: `filterRoutes` module

**Files:**
- Create: `src/features/flights-map/filterRoutes.ts`
- Create: `src/features/flights-map/filterRoutes.test.ts`

- [ ] **Step 1.1: Write failing tests**

Create `src/features/flights-map/filterRoutes.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import { filterRoutes } from "./filterRoutes.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";

const raw: IRawDictionaries = {
  regions: [],
  countries: [
    { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 },
    { code: "US", title: { ru: "США" }, world_region_id: 1 },
  ],
  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: 60, lon: 30 } },
    { code: "NYC", title: { ru: "Нью-Йорк" }, country_code: "US", has_afl_flights: true, location: { lat: 40, lon: -74 } },
  ],
  airports: [
    { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
    { code: "JFK", city_code: "NYC", title: { ru: "Джон Кеннеди" }, has_afl_flights: true, location: { lat: 40, lon: -74 } },
    { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } },
  ],
};

const d: IDictionaries = transformDictionaries(raw, "ru");

function filter(
  overrides: Partial<IFlightsMapFilterState> = {},
): IFlightsMapFilterState {
  return {
    connections: false,
    domestic: false,
    international: false,
    ...overrides,
  };
}

describe("filterRoutes — empty and passthrough", () => {
  it("returns [] for no routes", () => {
    expect(filterRoutes([], filter(), d)).toEqual([]);
  });

  it("returns routes unchanged when no toggles are active", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "NYC"], isDirect: true },
    ];
    expect(filterRoutes(routes, filter(), d)).toEqual(routes);
  });
});

describe("filterRoutes — domestic", () => {
  it("keeps only routes where every city is RU", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "NYC"], isDirect: true },
    ];
    const out = filterRoutes(routes, filter({ domestic: true }), d);
    expect(out).toHaveLength(1);
    expect(out[0]!.route).toEqual(["MOW", "LED"]);
  });
});

describe("filterRoutes — international", () => {
  it("keeps only routes with at least one non-RU city", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "NYC"], isDirect: true },
    ];
    const out = filterRoutes(routes, filter({ international: true }), d);
    expect(out).toHaveLength(1);
    expect(out[0]!.route).toEqual(["MOW", "NYC"]);
  });
});

describe("filterRoutes — both domestic AND international", () => {
  it("applies no domestic/intl filter when both are true", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "NYC"], isDirect: true },
    ];
    const out = filterRoutes(
      routes,
      filter({ domestic: true, international: true }),
      d,
    );
    expect(out).toEqual(routes);
  });
});

describe("filterRoutes — connections", () => {
  it("keeps only non-direct routes", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "X", "LED"], isDirect: false },
    ];
    const out = filterRoutes(routes, filter({ connections: true }), d);
    expect(out).toHaveLength(1);
    expect(out[0]!.route).toEqual(["MOW", "X", "LED"]);
  });
});

describe("filterRoutes — airport-code normalization", () => {
  it("treats airport codes (SVO, JFK) as their city codes (MOW, NYC)", () => {
    // SVO is an RU airport, JFK is US. international=true should keep SVO→JFK
    // because JFK resolves to NYC (non-RU).
    const routes: IFlightRoute[] = [
      { route: ["SVO", "JFK"], isDirect: true },
      { route: ["SVO", "LED"], isDirect: true }, // both RU via resolution
    ];
    const intl = filterRoutes(routes, filter({ international: true }), d);
    expect(intl).toHaveLength(1);
    expect(intl[0]!.route).toEqual(["SVO", "JFK"]);

    const dom = filterRoutes(routes, filter({ domestic: true }), d);
    expect(dom).toHaveLength(1);
    expect(dom[0]!.route).toEqual(["SVO", "LED"]);
  });

  it("drops routes containing unknown codes when any toggle is active", () => {
    const routes: IFlightRoute[] = [{ route: ["MOW", "???"], isDirect: true }];
    expect(filterRoutes(routes, filter({ domestic: true }), d)).toEqual([]);
    expect(filterRoutes(routes, filter({ international: true }), d)).toEqual([]);
  });
});

describe("filterRoutes — combo", () => {
  it("applies domestic + connections together", () => {
    const routes: IFlightRoute[] = [
      { route: ["MOW", "LED"], isDirect: true },     // RU + direct → dropped (connections)
      { route: ["MOW", "X", "LED"], isDirect: false }, // RU + non-direct → kept
      { route: ["MOW", "NYC"], isDirect: false },   // non-RU → dropped (domestic)
    ];
    const out = filterRoutes(
      routes,
      filter({ domestic: true, connections: true }),
      d,
    );
    expect(out).toHaveLength(1);
    expect(out[0]!.route).toEqual(["MOW", "X", "LED"]);
  });
});
```

- [ ] **Step 1.2: Run failing tests**

Run: `pnpm vitest run src/features/flights-map/filterRoutes.test.ts`
Expected: FAIL — module not found.

- [ ] **Step 1.3: Implement `filterRoutes.ts`**

Create `src/features/flights-map/filterRoutes.ts`:

```ts
/**
 * Client-side route filter matching Angular `filterRoutes`.
 *
 * Rules:
 *   - domestic && !international → keep routes where every city is RU.
 *   - international && !domestic → keep routes with at least one non-RU city.
 *   - both domestic && international → no domestic/intl filter (show all).
 *   - connections → keep only routes where isDirect is false.
 *
 * City codes and airport codes both appear in route arrays; we normalize each
 * entry to its city code before set-membership checks.
 */

import type { IDictionaries } from "@/shared/dictionaries/index.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";

export function filterRoutes(
  routes: IFlightRoute[],
  filter: Pick<IFlightsMapFilterState, "domestic" | "international" | "connections">,
  dictionaries: IDictionaries,
): IFlightRoute[] {
  const { domestic, international, connections } = filter;

  const toCityCode = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const isDomestic = (r: IFlightRoute): boolean =>
    r.route.every((code) => dictionaries.ruCityCodes.has(toCityCode(code)));

  const isInternational = (r: IFlightRoute): boolean =>
    r.route.some((code) => dictionaries.otherCityCodes.has(toCityCode(code)));

  const hasConnections = (r: IFlightRoute): boolean => !r.isDirect;

  const predicates: Array<(r: IFlightRoute) => boolean> = [];

  if (domestic && !international) predicates.push(isDomestic);
  else if (international && !domestic) predicates.push(isInternational);

  if (connections) predicates.push(hasConnections);

  if (predicates.length === 0) return routes;
  return routes.filter((r) => predicates.every((p) => p(r)));
}
```

- [ ] **Step 1.4: Run tests to verify they pass**

Run: `pnpm vitest run src/features/flights-map/filterRoutes.test.ts`
Expected: PASS — 9 tests.

- [ ] **Step 1.5: Commit**

```bash
git add src/features/flights-map/filterRoutes.ts src/features/flights-map/filterRoutes.test.ts
git commit -m "Add filterRoutes pure helper with airport-code normalization"
```

---

## Task 2: `buyTicketUrl` module

**Files:**
- Create: `src/features/flights-map/buyTicketUrl.ts`
- Create: `src/features/flights-map/buyTicketUrl.test.ts`

- [ ] **Step 2.1: Write failing tests**

Create `src/features/flights-map/buyTicketUrl.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import { buildBuyTicketUrl, escapeHtml } from "./buyTicketUrl.js";

describe("buildBuyTicketUrl", () => {
  it("builds a URL with the correct base", () => {
    const url = buildBuyTicketUrl("MOW", "LED", "20260501");
    expect(url.startsWith("https://www.aeroflot.ru/sb/app/ru-ru#/search?")).toBe(true);
  });

  it("includes the routes triple dep.date.arr", () => {
    const url = buildBuyTicketUrl("MOW", "LED", "20260501");
    expect(url).toContain("routes=MOW.20260501.LED");
  });

  it("includes the stable UTM and adults/cabin params", () => {
    const url = buildBuyTicketUrl("MOW", "LED", "20260501");
    expect(url).toContain("adults=1");
    expect(url).toContain("cabin=economy");
    expect(url).toContain("autosearch=Y");
    expect(url).toContain("utm_source=aflwebbot");
  });
});

describe("escapeHtml", () => {
  it("escapes &, <, >, and double-quote", () => {
    expect(escapeHtml("A & B < C > D \"E\"")).toBe(
      "A &amp; B &lt; C &gt; D &quot;E&quot;",
    );
  });

  it("returns the input unchanged when no special chars", () => {
    expect(escapeHtml("Москва")).toBe("Москва");
  });
});
```

- [ ] **Step 2.2: Run failing tests**

Run: `pnpm vitest run src/features/flights-map/buyTicketUrl.test.ts`
Expected: FAIL — module not found.

- [ ] **Step 2.3: Implement `buyTicketUrl.ts`**

Create `src/features/flights-map/buyTicketUrl.ts`:

```ts
/**
 * Buy-ticket URL for the arrival popup (Angular parity: `getLink`).
 *
 * Base path + fixed params are hardcoded to the Russian-locale Aeroflot
 * search page. The routes triple `{dep}.{date}.{arr}` is the only variable
 * part of the URL.
 */

const BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
const FIXED_PARAMS =
  "adults=1&cabin=economy&children=0&infants=0&autosearch=Y" +
  "&utm_source=aflwebbot&utm_medium=referral" +
  "&utm_campaign=ref_3015_general_rf_button.index__all_flight.map";

/**
 * @param departure 3-letter city code (e.g. "MOW").
 * @param arrival 3-letter city code (e.g. "LED").
 * @param date YYYYMMDD — typically filter.date or today.
 */
export function buildBuyTicketUrl(
  departure: string,
  arrival: string,
  date: string,
): string {
  return `${BASE}?${FIXED_PARAMS}&routes=${departure}.${date}.${arrival}`;
}

/**
 * Minimal HTML escape for popup content — prevents injection via
 * city names containing `& < > "`.
 */
export function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
```

- [ ] **Step 2.4: Run tests to verify they pass**

Run: `pnpm vitest run src/features/flights-map/buyTicketUrl.test.ts`
Expected: PASS — 5 tests.

- [ ] **Step 2.5: Commit**

```bash
git add src/features/flights-map/buyTicketUrl.ts src/features/flights-map/buyTicketUrl.test.ts
git commit -m "Add buildBuyTicketUrl + escapeHtml helpers for popup content"
```

---

## Task 3: `routesToPolylines` airport→city normalization

**Files:**
- Modify: `src/features/flights-map/routesToPolylines.ts`
- Modify: `src/features/flights-map/routesToPolylines.test.ts`

- [ ] **Step 3.1: Update `routesToPolylines.ts` signature and implementation**

Open `src/features/flights-map/routesToPolylines.ts`. Replace the entire file contents with:

```ts
/**
 * Transform API-returned routes into polylines the map can render.
 *
 * Two modes, mirroring Angular `fetchAndDraw`:
 * - Spider (departure only): one straight polyline from departure to each
 *   unique destination across all routes.
 * - Route (departure + arrival): one polyline per route following its city
 *   sequence; direct routes solid, connecting routes dashed.
 *
 * Route entries may contain airport codes in addition to city codes; both
 * functions normalize each code to its city code via the dictionaries before
 * building polylines. This ensures polylines resolve against MapCanvas'
 * city-code-keyed marker index regardless of what the API returns.
 *
 * `intermediateCityIds` returns marker IDs whose tooltips must be force-opened
 * along multi-hop routes, matching Angular `updateIntermediateTooltip`.
 */

import type { IDictionaries } from "@/shared/dictionaries/index.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import type {
  IFlightRoute,
  IFlightsMapFilterState,
  IMapPolyline,
} from "./types.js";

export function routesToPolylines(
  routes: IFlightRoute[],
  filterState: Pick<IFlightsMapFilterState, "departure" | "arrival">,
  dictionaries: IDictionaries,
): IMapPolyline[] {
  if (routes.length === 0) return [];

  const toCity = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const hasDeparture = Boolean(filterState.departure);
  const hasArrival = Boolean(filterState.arrival);
  const isSpiderMode = hasDeparture && !hasArrival;

  if (isSpiderMode) {
    const fromCode = toCity(filterState.departure!);
    const destCodes = new Set<string>();
    for (const r of routes) {
      if (r.route.length > 1) {
        const dest = toCity(r.route[r.route.length - 1]!);
        if (dest !== fromCode) destCodes.add(dest);
      }
    }
    return [...destCodes].map((dest) => ({
      id: `spider-${fromCode}-${dest}`,
      cityIds: [fromCode, dest],
      style: "direct",
    }));
  }

  return routes.map((r, i) => {
    const normalized = r.route.map(toCity);
    return {
      id: `route-${i}-${normalized.join("-")}`,
      cityIds: normalized,
      style: r.isDirect ? "direct" : "connecting",
    };
  });
}

export function intermediateCityIds(
  routes: IFlightRoute[],
  dictionaries: IDictionaries,
): string[] {
  const toCity = (code: string): string =>
    getCityCodeByAirportCode(dictionaries, code) ?? code;

  const ids = new Set<string>();
  for (const r of routes) {
    if (r.route.length <= 2) continue;
    for (let i = 1; i < r.route.length - 1; i++) ids.add(toCity(r.route[i]!));
  }
  return [...ids];
}
```

- [ ] **Step 3.2: Update existing tests to pass a dictionaries fixture**

Open `src/features/flights-map/routesToPolylines.test.ts`. At the top, replace the imports with:

```ts
import { describe, it, expect } from "vitest";
import {
  routesToPolylines,
  intermediateCityIds,
} from "./routesToPolylines.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type {
  IFlightRoute,
  IFlightsMapFilterState,
} from "./types.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";

// Empty-dictionary fixture for tests that don't care about normalization.
const EMPTY_DICT: IDictionaries = transformDictionaries(
  { regions: [], countries: [], cities: [], airports: [] },
  "ru",
);

function filter(
  overrides: Partial<IFlightsMapFilterState> = {},
): IFlightsMapFilterState {
  return {
    connections: false,
    domestic: false,
    international: false,
    ...overrides,
  };
}
```

Then, in every test that calls `routesToPolylines(...)`, add `EMPTY_DICT` as the third argument. Similarly, `intermediateCityIds(...)` calls gain `EMPTY_DICT` as the second argument.

Every existing test body has a literal call like:

```ts
routesToPolylines(routes, filter({ departure: "A" }))
```

It becomes:

```ts
routesToPolylines(routes, filter({ departure: "A" }), EMPTY_DICT)
```

Similarly for `intermediateCityIds(someRoutes)` → `intermediateCityIds(someRoutes, EMPTY_DICT)`.

The mechanical change affects all 12 existing test bodies. Do not remove or rename any existing test — just update the argument list.

- [ ] **Step 3.3: Append airport-code normalization tests**

At the END of `src/features/flights-map/routesToPolylines.test.ts`, append:

```ts
// ---------------------------------------------------------------------------
// Airport-code normalization (C.4)
// ---------------------------------------------------------------------------

const rawWithAirports: IRawDictionaries = {
  regions: [],
  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 } },
    { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, 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: 60, lon: 30 } },
  ],
};
const DICT_WITH_AIRPORTS: IDictionaries = transformDictionaries(rawWithAirports, "ru");

describe("routesToPolylines — airport-code normalization", () => {
  it("normalizes route-mode cityIds from airport codes to city codes", () => {
    const routes: IFlightRoute[] = [{ route: ["SVO", "LED"], isDirect: true }];
    const pls = routesToPolylines(
      routes,
      filter({ departure: "MOW", arrival: "LED" }),
      DICT_WITH_AIRPORTS,
    );
    expect(pls).toHaveLength(1);
    expect(pls[0]!.cityIds).toEqual(["MOW", "LED"]);
  });

  it("normalizes spider-mode cityIds", () => {
    const routes: IFlightRoute[] = [{ route: ["SVO", "LED"], isDirect: true }];
    const pls = routesToPolylines(
      routes,
      filter({ departure: "MOW" }),
      DICT_WITH_AIRPORTS,
    );
    expect(pls).toHaveLength(1);
    expect(pls[0]!.cityIds).toEqual(["MOW", "LED"]);
  });

  it("intermediateCityIds returns city codes when airport codes appear inside a multi-hop route", () => {
    // Mid city: SVO resolves to MOW.
    const routes: IFlightRoute[] = [
      { route: ["LED", "SVO", "MOW"], isDirect: false },
    ];
    expect(intermediateCityIds(routes, DICT_WITH_AIRPORTS)).toEqual(["MOW"]);
  });
});
```

- [ ] **Step 3.4: Run tests**

Run: `pnpm vitest run src/features/flights-map/routesToPolylines.test.ts`
Expected: PASS — 12 existing + 3 new = 15 tests.

- [ ] **Step 3.5: Typecheck**

Run: `pnpm tsc --noEmit`

Expected: errors inside `FlightsMapStartPage.tsx` where `routesToPolylines` and `intermediateCityIds` are called without the new `dictionaries` arg. These are fixed in Task 4. **Do not commit until Task 4 compiles.**

- [ ] **Step 3.6: Commit (together with Task 4)**

Task 3's changes will commit alongside Task 4 to keep the tree compiling. See Task 4's commit step.

---

## Task 4: Wire filtering, auto-fallback, and popups into `FlightsMapStartPage`

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx`

This task has many sub-edits but they're all in one file.

- [ ] **Step 4.1: Add new imports**

At the top of `src/features/flights-map/components/FlightsMapStartPage.tsx`, near the existing `@/` and relative imports, add:

```tsx
import { filterRoutes } from "../filterRoutes.js";
import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js";
import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js";
import type { IFlightRoute, IMapPopup } from "../types.js";
```

If any of these types is already imported, merge the import rather than duplicating.

- [ ] **Step 4.2: Add `effectiveConnections` state and sync effect**

Find the existing `const [filterState, setFilterState] = useState<IFlightsMapFilterState>(...)` block. Immediately after that state declaration, add:

```tsx
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
  filterState.connections ? 1 : 0,
);

// Sync user toggle → effective.
useEffect(() => {
  setEffectiveConnections(filterState.connections ? 1 : 0);
}, [filterState.connections]);
```

- [ ] **Step 4.3: Change `searchParams` to use `effectiveConnections`**

Find the existing `searchParams` memo. It uses `filterState.connections ? 1 : 0` in the `connections` field. Replace that expression with `effectiveConnections` and update the dep array:

```tsx
const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
  if (!filterState.departure) return null;

  const today = todayYyyymmdd();
  return {
    departure: filterState.departure,
    arrival: filterState.arrival,
    dateFrom: today,
    dateTo: addMonthsYyyymmdd(today, 6),
    connections: effectiveConnections,
  };
}, [filterState.departure, filterState.arrival, effectiveConnections]);
```

- [ ] **Step 4.4: Add auto-fallback and mirror effects**

Find the existing `const { routes, loading, error } = useFlightsMapSearch(searchParams);` line. After it, add:

```tsx
// Auto-fallback: empty result, route mode, connections=0 → retry with 1.
useEffect(() => {
  if (loading || error) return;
  if (effectiveConnections !== 0) return;
  if (!filterState.departure || !filterState.arrival) return;
  if (routes.length > 0) return;
  setEffectiveConnections(1);
}, [
  loading,
  error,
  effectiveConnections,
  filterState.departure,
  filterState.arrival,
  routes,
]);

// Reflect fallback in the UI toggle once.
useEffect(() => {
  if (effectiveConnections === 1 && !filterState.connections) {
    setFilterState((prev) => ({ ...prev, connections: true }));
  }
}, [effectiveConnections, filterState.connections]);
```

- [ ] **Step 4.5: Replace the `polylines` memo with filtered routes + polylines + intermediate IDs**

Find the existing block:

```tsx
const polylines = useMemo<IMapPolyline[]>(
  () => routesToPolylines(routes, filterState),
  [routes, filterState.departure, filterState.arrival],
);

const intermediateIds = useMemo<string[]>(
  () => intermediateCityIds(routes),
  [routes],
);
```

Replace with:

```tsx
const filteredRoutes = useMemo<IFlightRoute[]>(
  () => (dictionaries ? filterRoutes(routes, filterState, dictionaries) : []),
  [
    routes,
    filterState.domestic,
    filterState.international,
    filterState.connections,
    dictionaries,
  ],
);

const polylines = useMemo<IMapPolyline[]>(
  () =>
    dictionaries
      ? routesToPolylines(filteredRoutes, filterState, dictionaries)
      : [],
  [filteredRoutes, filterState.departure, filterState.arrival, dictionaries],
);

const intermediateIds = useMemo<string[]>(
  () => (dictionaries ? intermediateCityIds(filteredRoutes, dictionaries) : []),
  [filteredRoutes, dictionaries],
);
```

- [ ] **Step 4.6: Add the `popups` memo**

Find the `intermediateIds` memo you just added. Right after it, add:

```tsx
const popups = useMemo<IMapPopup[]>(() => {
  if (!dictionaries) return [];
  if (!filterState.departure || !filterState.arrival) return [];
  if (filteredRoutes.length === 0) return [];

  const first = filteredRoutes[0]!.route;
  const depCode = first[0]!;
  const arrCode = first[first.length - 1]!;

  const depCityCode = getCityCodeByAirportCode(dictionaries, depCode) ?? depCode;
  const arrCityCode = getCityCodeByAirportCode(dictionaries, arrCode) ?? arrCode;

  const depCity = dictionaries.cityByCode.get(depCityCode);
  const arrCity = dictionaries.cityByCode.get(arrCityCode);
  if (!depCity || !arrCity) return [];

  const date = filterState.date ?? todayYyyymmdd();
  const buyUrl = buildBuyTicketUrl(depCityCode, arrCityCode, date);

  const depHtml = `
    <div class="popup-header-test"><span>${escapeHtml(depCity.name)}</span></div>
  `;

  const arrHtml = `
    <div class="popup-header-test"><span>${escapeHtml(arrCity.name)}</span></div>
    <div style="text-align:center;">
      <a href="${buyUrl}" target="_blank" class="popup-buy-ticket">
        ${t("FLIGHTS-MAP.BUY-TICKET")}
      </a>
    </div>
  `;

  return [
    { lat: depCity.location.lat, lng: depCity.location.lon, content: depHtml },
    { lat: arrCity.location.lat, lng: arrCity.location.lon, content: arrHtml },
  ];
}, [
  dictionaries,
  filterState.departure,
  filterState.arrival,
  filterState.date,
  filteredRoutes,
  t,
]);
```

- [ ] **Step 4.7: Pass `popups` to `<MapCanvas />`**

Find the existing `<MapCanvas ... />` call. Add the `popups={popups}` prop (keep existing props intact):

```tsx
<MapCanvas
  markers={markers}
  polylines={polylines}
  popups={popups}
  tileUrl={tileUrl}
  onMarkerClick={handleMarkerClick}
  className="flights-map-start__map"
  domestic={filterState.domestic}
  international={filterState.international}
  intermediateIds={intermediateIds}
/>
```

- [ ] **Step 4.8: Typecheck**

Run: `pnpm tsc --noEmit`
Expected: no output. If there are errors, common culprits:
- `IMapPopup` not imported (add to the types import).
- `IFlightRoute` not imported.
- `setFilterState` not in scope — it's returned from the existing `useState<IFlightsMapFilterState>` call; the name may differ (the existing code might destructure it as `setFilter`). Use whatever name the existing code uses.

- [ ] **Step 4.9: Run the full test suite**

Run: `pnpm vitest run`
Expected: all tests pass. Page-level tests from C.3 continue to pass because the mock `MapCanvas` ignores the new `popups` prop (but `popups` is now captured in `lastMapCanvasProps`).

- [ ] **Step 4.10: Commit Tasks 3 + 4 together**

```bash
git add \
  src/features/flights-map/routesToPolylines.ts \
  src/features/flights-map/routesToPolylines.test.ts \
  src/features/flights-map/components/FlightsMapStartPage.tsx
git commit -m "Wire filterRoutes, auto-fallback, and buy-ticket popups into Flights Map

routesToPolylines + intermediateCityIds now normalize airport codes to city
codes via the dictionaries so API responses resolve correctly. The page adds
effectiveConnections state + two effects for Angular-parity fallback
(retry connections=1 on empty direct-route result, then flip the UI toggle),
a filterRoutes memo feeding polylines and intermediateIds, and a popups memo
rendering departure + arrival buy-ticket popups in route mode only."
```

---

## Task 5: Integration tests in `FlightsMapStartPage.test.tsx`

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.test.tsx`

- [ ] **Step 5.1: Extend the `useFlightsMapSearch` mock to record call args**

Find the existing `vi.mock("../hooks/useFlightsMapSearch.js", ...)` block. Replace its factory body with:

```tsx
const searchCalls: Array<FlightsMapSearchParams | null> = [];
vi.mock("../hooks/useFlightsMapSearch.js", () => ({
  useFlightsMapSearch: (params: FlightsMapSearchParams | null) => {
    searchCalls.push(params);
    return { ...searchState, refresh: vi.fn() };
  },
}));
```

You'll need the import `import type { FlightsMapSearchParams } from "../types.js";` near the top of the test file if not already present.

- [ ] **Step 5.2: Extend the `dictState` mock shape with an ICity full enough for popups**

Find the existing `dictState` definition in the test file. Extend its `cities` shape to include the properties C.4 consumes. The existing simplified mock returns a plain object; we need the full `IDictionaries` shape with `cityByCode`, `airportByCode`, `ruCityCodes`, `otherCityCodes`.

Replace the `dictState` and its mock with:

```tsx
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";

function buildDictionaries(raw?: IRawDictionaries): IDictionaries {
  return transformDictionaries(
    raw ?? { regions: [], countries: [], cities: [], airports: [] },
    "ru",
  );
}

const dictState: {
  dictionaries: IDictionaries | null;
  loading: boolean;
  error: Error | null;
} = {
  dictionaries: null,
  loading: true,
  error: null,
};
vi.mock("@/shared/dictionaries/index.js", async () => {
  const actual = await vi.importActual<typeof import("@/shared/dictionaries/index.js")>(
    "@/shared/dictionaries/index.js",
  );
  return {
    ...actual,
    useDictionaries: () => dictState,
  };
});
```

The `vi.importActual` call preserves `transformDictionaries` + helpers for the test file and the page code (which needs `getCityCodeByAirportCode`), while overriding only `useDictionaries`.

Update existing tests that assigned `dictState.dictionaries = {...}` with a plain object to use `buildDictionaries(raw)` instead. Any test that set `dictState.dictionaries = { cities: [...] }` must now pass full raw dictionaries:

```tsx
dictState.dictionaries = buildDictionaries({
  regions: [],
  countries: [],
  cities: [
    { code: "A", title: { ru: "A" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } },
    { code: "B", title: { ru: "B" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 40 } },
    { code: "X", title: { ru: "X" }, country_code: "RU", has_afl_flights: true, location: { lat: 58, lon: 38 } },
  ],
  airports: [
    { code: "A", city_code: "A", title: { ru: "A" }, has_afl_flights: true, location: { lat: 55, lon: 37 } },
    { code: "B", city_code: "B", title: { ru: "B" }, has_afl_flights: true, location: { lat: 60, lon: 40 } },
    { code: "X", city_code: "X", title: { ru: "X" }, has_afl_flights: true, location: { lat: 58, lon: 38 } },
  ],
});
```

Apply this mechanical change to every pre-existing test that referenced the old `dictState.dictionaries = { cities: [...] }` shape.

- [ ] **Step 5.3: Append C.4 integration tests**

At the END of `FlightsMapStartPage.test.tsx`, append:

```tsx
describe("FlightsMapStartPage — C.4 integration", () => {
  beforeEach(() => {
    lastMapCanvasProps = null;
    searchCalls.length = 0;
    dictState.dictionaries = buildDictionaries({
      regions: [],
      countries: [
        { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 },
        { code: "US", title: { ru: "США" }, world_region_id: 1 },
      ],
      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: 60, lon: 30 } },
        { code: "NYC", title: { ru: "Нью-Йорк" }, country_code: "US", has_afl_flights: true, location: { lat: 40, lon: -74 } },
      ],
      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: 60, lon: 30 } },
        { code: "JFK", city_code: "NYC", title: { ru: "Джон Кеннеди" }, has_afl_flights: true, location: { lat: 40, lon: -74 } },
      ],
    });
    dictState.loading = false;
    dictState.error = null;
    searchState.routes = [];
    searchState.loading = false;
    searchState.error = null;
  });

  it("drops international routes when filterState.domestic is true", () => {
    // Cannot flip filterState directly from tests without simulating a click
    // path; instead, we check that the pipeline captures the expected polylines
    // under the initial filter state. Assert domestic behaviour at the helper
    // level elsewhere (filterRoutes.test.ts) and verify here that captured
    // polylines include international routes by default (no toggle).
    searchState.routes = [
      { route: ["MOW", "LED"], isDirect: true },
      { route: ["MOW", "NYC"], isDirect: true },
    ];
    render(<FlightsMapStartPage />);
    const polylines = lastMapCanvasProps!["polylines"] as Array<{ cityIds: string[] }>;
    expect(polylines).toHaveLength(2);
  });

  it("renders a departure + arrival popup in route mode with a buy-ticket URL", () => {
    searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }];
    // Inject the filter state via the test's click path: click the MOW marker
    // to set departure, then LED to set arrival. Since we don't simulate
    // marker clicks here, we rely on the onMarkerClick handler. Instead,
    // test popups indirectly by forcing filter state through the marker click
    // handler captured from the MapCanvas mock.

    // Simpler approach: render, then call the captured onMarkerClick twice.
    render(<FlightsMapStartPage />);
    const onClick = lastMapCanvasProps!["onMarkerClick"] as (id: string) => void;
    onClick("MOW");
    onClick("LED");

    // Re-render happens synchronously inside the click handlers.
    // Read popups from the latest captured props.
    const popups = lastMapCanvasProps!["popups"] as Array<{ content: string; lat: number; lng: number }>;
    expect(popups).toHaveLength(2);
    expect(popups[0]!.content).toContain("Москва");
    expect(popups[1]!.content).toContain("Санкт-Петербург");
    expect(popups[1]!.content).toContain("routes=MOW.");
    expect(popups[1]!.content).toContain(".LED");
    expect(popups[1]!.content).toContain("https://www.aeroflot.ru/sb/app/ru-ru");
  });

  it("does not render popups in spider mode (departure only)", () => {
    searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }];
    render(<FlightsMapStartPage />);
    const onClick = lastMapCanvasProps!["onMarkerClick"] as (id: string) => void;
    onClick("MOW");

    const popups = lastMapCanvasProps!["popups"] as unknown[];
    expect(popups).toEqual([]);
  });

  it("auto-fallback: re-issues the search with connections=1 when direct routes come back empty", () => {
    // First mount: no routes. Click MOW + LED to enter route mode.
    searchState.routes = [];
    render(<FlightsMapStartPage />);
    const onClick = lastMapCanvasProps!["onMarkerClick"] as (id: string) => void;
    onClick("MOW");
    onClick("LED");

    // searchCalls should now contain at least one entry with connections: 0
    // (initial) and one with connections: 1 (auto-fallback).
    const withOne = searchCalls.filter((p) => p?.connections === 1);
    expect(withOne.length).toBeGreaterThanOrEqual(1);
  });

  it("mirror: sets filterState.connections=true after auto-fallback", () => {
    searchState.routes = [];
    render(<FlightsMapStartPage />);
    const onClick = lastMapCanvasProps!["onMarkerClick"] as (id: string) => void;
    onClick("MOW");
    onClick("LED");

    // After auto-fallback, MapCanvas receives connections: true (via filterState
    // mirror). We can observe this indirectly: once filterState.connections is
    // true, any subsequent search triggered by filter mutation uses connections=1.
    // Equivalent assertion: searchCalls must contain at least one entry where
    // connections === 1 (the fallback), and none AFTER that point where the
    // effective connections reverts to 0.
    const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
    expect(last?.connections).toBe(1);
  });
});
```

**Important caveat:** the marker click triggers `handleMarkerClick` in the page, which calls `setFilterState` with the new departure/arrival. React batches state updates, so the captured `lastMapCanvasProps` reflects the state AFTER the click. If the tests assert `popups` immediately after `onClick("LED")`, it should reflect the route-mode state. If the test flakes on this, wrap click+assertion in `act(() => { onClick("MOW"); onClick("LED"); })` using `@testing-library/react`'s `act`.

- [ ] **Step 5.4: Run the appended tests**

Run: `pnpm vitest run src/features/flights-map/components/FlightsMapStartPage.test.tsx`
Expected: PASS — prior tests + 5 new C.4 tests.

If any new test fails, read the error carefully. Common issues:

1. **Mock `MapCanvas` doesn't update `lastMapCanvasProps` on rerender.** The existing mock is a function component; it should be called on every render, overwriting `lastMapCanvasProps` each time.
2. **`onClick("MOW")` triggers a state update but the assertion runs before React commits.** Wrap in `act(() => onClick(...))`.
3. **Auto-fallback assertion fails because the effect hasn't fired yet.** Wrap the click chain + assertion in `await act(async () => { ... })` and add a `vi.waitFor(...)` around the `searchCalls.filter(...)` line.

Concrete fix for case 2/3 — wrap every click chain like:

```tsx
import { act } from "@testing-library/react";

act(() => {
  onClick("MOW");
  onClick("LED");
});
```

- [ ] **Step 5.5: Full suite**

Run: `pnpm vitest run`
Expected: all pass.

- [ ] **Step 5.6: Commit**

```bash
git add src/features/flights-map/components/FlightsMapStartPage.test.tsx
git commit -m "Test FlightsMapStartPage filterRoutes + popups + auto-fallback wiring"
```

---

## Task 6: Final verification

- [ ] **Step 6.1: Typecheck**

Run: `pnpm tsc --noEmit`
Expected: no output.

- [ ] **Step 6.2: Full suite**

Run: `pnpm vitest run`
Expected: all pass. Count delta vs. post-C.3 baseline: ~22 new tests (9 filterRoutes + 5 buyTicketUrl + 3 routesToPolylines normalization + 5 page integration).

- [ ] **Step 6.3: Feature-scoped run**

Run: `pnpm vitest run src/features/flights-map/`
Expected: all feature tests pass.

---

## Self-Review Log

Ran against the spec:

- **Spec coverage.**
  - `filterRoutes.ts` module → Task 1.
  - `buildBuyTicketUrl` + `escapeHtml` → Task 2.
  - `routesToPolylines` / `intermediateCityIds` dictionary-arg + airport normalization → Task 3.
  - `effectiveConnections` + auto-fallback + mirror effects → Task 4 Steps 4.2, 4.3, 4.4.
  - `filteredRoutes` memo → Task 4 Step 4.5.
  - `popups` memo in route mode → Task 4 Step 4.6.
  - MapCanvas `popups` prop flow → Task 4 Step 4.7.
  - Dictionaries-null guards → Task 4 Step 4.5/4.6 (fall-through to `[]`).
  - Integration tests → Task 5.
  - Final verify → Task 6.
- **Placeholders.** None.
- **Type consistency.** `filterRoutes` takes `Pick<IFlightsMapFilterState, ...>`. `routesToPolylines` now takes `(routes, filterState, dictionaries)` — every call site updates in lockstep. `IMapPopup` shape (`{ lat, lng, content }`) reused from existing types; no change to the interface. `effectiveConnections` type `0 | 1` used consistently. `getCityCodeByAirportCode` signature matches usage in `filterRoutes.ts`, `routesToPolylines.ts`, and the page's popups memo.
- **Commit grouping.** Tasks 3 + 4 share one commit because `routesToPolylines`' new signature breaks the page until it's updated. Tasks 1, 2, 5 each get their own commit.
- **Known trade-off for integration tests.** The click-based filterState manipulation in `FlightsMapStartPage.test.tsx` relies on the page's `handleMarkerClick`. If timing flakes surface, wrap in `act(...)`. Documented in Step 5.4.
