diff --git a/docs/superpowers/specs/2026-04-17-flights-map-c4-popups-filtering-fallback-design.md b/docs/superpowers/specs/2026-04-17-flights-map-c4-popups-filtering-fallback-design.md new file mode 100644 index 00000000..b672f360 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-flights-map-c4-popups-filtering-fallback-design.md @@ -0,0 +1,408 @@ +# Flights Map C.4: Popups + Filtering + Auto-Fallback — Design + +**Date:** 2026-04-17 +**Author:** brainstorming session +**Scope:** Sub-feature C.4 of Gap C (Flights Map rebuild) +**Status:** Approved +**Depends on:** C.1 (dictionaries), C.2 (markers + zoom), C.3 (polylines) — all landed. + +## Goal + +Close the remaining gaps between the Angular flights-map body component and the React port by adding: client-side route filtering (`filterRoutes`), auto-fallback when direct routes are empty (retry with `connections=1` + mirror into UI toggle), departure/arrival buy-ticket popups (route mode only), and airport→city-code normalization in the polyline pipeline so that API responses containing airport codes render correctly. + +## Non-Goals + +- Date picker UI wiring, exchange button, geolocation-based default departure → **C.5**. +- Any UX/UI polish to the existing popup HTML beyond Angular parity. +- Localization of the buy-ticket link (Russian-locale URL is hardcoded in Angular; matches). + +## Architecture + +Four pieces: + +``` +src/features/flights-map/ +├── filterRoutes.ts NEW (pure) +├── filterRoutes.test.ts NEW +├── buyTicketUrl.ts NEW (pure) +├── buyTicketUrl.test.ts NEW +├── routesToPolylines.ts MODIFY (normalize airport→city codes) +├── routesToPolylines.test.ts MODIFY (pass dictionaries arg; add normalization tests) +└── components/ + ├── FlightsMapStartPage.tsx MODIFY (filterRoutes memo, auto-fallback, popups memo) + └── FlightsMapStartPage.test.tsx MODIFY (append 6 integration tests) +``` + +`MapCanvas.tsx` is **unchanged** — its popup sync effect already handles the `popups` prop correctly; nothing about polylines/markers needs new work. + +The four scope items are orthogonal enough to implement independently; they land together because the auto-fallback and popup construction both depend on `filteredRoutes`. + +## `filterRoutes.ts` + +```ts +import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js"; +import type { IFlightRoute, IFlightsMapFilterState } from "./types.js"; + +/** + * Client-side filter matching Angular `filterRoutes`. + * + * - domestic && !international → keep routes where EVERY city is RU. + * - international && !domestic → keep routes where SOME city is non-RU. + * - connections → keep only non-direct routes. + * - both domestic && international → no domestic/intl filter (show all). + * + * Route entries may contain either city codes or airport codes; this function + * normalizes each to its city code via the dictionaries before set-membership + * checks. + */ +export function filterRoutes( + routes: IFlightRoute[], + filter: Pick, + 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))); +} +``` + +## `buyTicketUrl.ts` + +```ts +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"; + +/** + * Buy-ticket URL for the arrival popup. Mirrors Angular `getLink`. + * + * @param departure 3-letter city code (e.g. "MOW"). + * @param arrival 3-letter city code (e.g. "LED"). + * @param date YYYYMMDD string — typically filter.date or today. + */ +export function buildBuyTicketUrl( + departure: string, + arrival: string, + date: string, +): string { + return `${BASE}?${FIXED_PARAMS}&routes=${departure}.${date}.${arrival}`; +} +``` + +An `escapeHtml(str)` utility is co-located in the same file (prevents HTML injection via city names): + +```ts +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +``` + +## `routesToPolylines.ts` changes + +Both exported functions gain a `dictionaries: IDictionaries` parameter and normalize airport codes to city codes before using them: + +```ts +import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js"; +// ...existing imports + +export function routesToPolylines( + routes: IFlightRoute[], + filterState: Pick, + 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(); + 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(); + 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]; +} +``` + +All existing callers and tests pass a dictionaries fixture; tests are updated accordingly. + +## `FlightsMapStartPage.tsx` wiring + +### Imports + +```tsx +import { filterRoutes } from "../filterRoutes.js"; +import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js"; +import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js"; +import { getCityCodeByAirportCode } from "@/shared/dictionaries/index.js"; +``` + +### `effectiveConnections` state + auto-fallback + +```tsx +const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>( + filterState.connections ? 1 : 0, +); + +// Sync user toggle → effective. +useEffect(() => { + setEffectiveConnections(filterState.connections ? 1 : 0); +}, [filterState.connections]); + +const searchParams = useMemo(() => { + 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]); + +const { routes, loading, error } = useFlightsMapSearch(searchParams); + +// 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, +]); + +// Mirror fallback back into UI toggle once. +useEffect(() => { + if (effectiveConnections === 1 && !filterState.connections) { + setFilterState((prev) => ({ ...prev, connections: true })); + } +}, [effectiveConnections, filterState.connections]); +``` + +### Filtered routes + polylines + intermediates + +```tsx +const filteredRoutes = useMemo( + () => (dictionaries ? filterRoutes(routes, filterState, dictionaries) : []), + [routes, filterState.domestic, filterState.international, filterState.connections, dictionaries], +); + +const polylines = useMemo( + () => (dictionaries ? routesToPolylines(filteredRoutes, filterState, dictionaries) : []), + [filteredRoutes, filterState.departure, filterState.arrival, dictionaries], +); + +const intermediateIds = useMemo( + () => (dictionaries ? intermediateCityIds(filteredRoutes, dictionaries) : []), + [filteredRoutes, dictionaries], +); +``` + +### Popups + +```tsx +const popups = useMemo(() => { + 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 = ` + + `; + + const arrHtml = ` + + + `; + + 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]); +``` + +### MapCanvas props + +```tsx + +``` + +## Error handling / edge cases + +- **Dictionaries null.** All memos fall through to `[]`; no crash. +- **Retry errors.** Auto-fallback effect gates on `!error`; won't loop on network failure. +- **Filter reduces to zero.** Polylines empty; popups empty (gated on `filteredRoutes.length > 0`); existing "No directions found" overlay still shows. +- **Both `domestic` AND `international` true.** No domestic/intl filter applied. Matches Angular. +- **Spider mode + fallback.** Effect gates on `filterState.arrival` → no fallback in spider. Matches Angular. +- **Spider mode + filter.** `filterRoutes` runs unconditionally; predicates evaluate per route and produce correct output for both modes. Confirmed safe. +- **HTML injection via city name.** `escapeHtml` covers `& < > "`. +- **City code not in dictionary.** `toCityCode` returns the original input; `ruCityCodes.has(x)` / `otherCityCodes.has(x)` both false; `isDomestic`/`isInternational` fail that route. Matches Angular silent-failure. +- **`filterState.date` undefined.** Buy-ticket URL uses `todayYyyymmdd()`. Matches Angular. +- **Popups in spider mode.** Gated off via `!filterState.arrival`. Matches Angular. +- **Auto-fallback + user-triggered manual toggle.** User flips connections to true → sync effect sets effective=1 → mirror effect no-ops (already true) → no loop. + +## Testing + +### `filterRoutes.test.ts` + +- Empty routes → `[]`. +- No toggles → routes unchanged. +- `domestic=true, international=false`: drops routes with any non-RU city. +- `international=true, domestic=false`: drops routes where all cities are RU. +- `domestic && international` (both true) → no domestic/intl filter. +- `connections=true` → drops `isDirect=true` routes. +- Airport-code normalization: route `["SVO", "JFK"]` evaluated via resolved `["MOW", "NYC"]`. +- Unknown code in a route: falls through `toCityCode`; membership checks both false; route dropped when domestic or international toggle is active. +- Combo `domestic + connections`: both predicates applied (all-RU AND non-direct). + +### `buyTicketUrl.test.ts` + +- URL prefix `https://www.aeroflot.ru/sb/app/ru-ru#/search?`. +- Contains `routes=MOW.20260501.LED` for `("MOW","LED","20260501")`. +- Contains `adults=1`, `cabin=economy`, `utm_source=aflwebbot` (stability of hardcoded params). +- `escapeHtml` replaces `&`, `<`, `>`, `"`. + +### `routesToPolylines.test.ts` amendments + +- Every existing test call gains a minimal `dictionaries` fixture (empty but valid `IDictionaries`). +- New: spider-mode with airport codes → polylines normalized to city codes. +- New: route-mode with mixed airport/city codes → `cityIds` are all city codes. +- New: `intermediateCityIds` with airport codes → inner entries normalized. + +### `FlightsMapStartPage.test.tsx` (append) + +Extend the existing `useFlightsMapSearch` mock to record call args: + +```ts +const searchCalls: Array = []; +vi.mock("../hooks/useFlightsMapSearch.js", () => ({ + useFlightsMapSearch: (params: FlightsMapSearchParams | null) => { + searchCalls.push(params); + return { ...searchState, refresh: vi.fn() }; + }, +})); +``` + +Tests: + +- **filterRoutes integration.** Dictionaries with RU+non-RU cities; mock routes mix of domestic+international; set `filterState.domestic = true` via simulated click or via a test-controllable initial-state override → captured polylines only include domestic routes. +- **Auto-fallback fires.** Initial mount with departure+arrival set but routes empty, `connections=false` → assert `searchCalls` contains an entry with `connections: 1` (the retry). +- **Mirror effect.** After auto-fallback, the `filterState.connections` value visible to MapCanvas props is `true`. +- **Popups in route mode.** Dictionaries entries for MOW+LED with locations; route `[{route:["MOW","LED"],isDirect:true}]`; filter `{departure:"MOW",arrival:"LED"}` → captured `popups.length === 2`, first.content includes "Москва", second.content includes `href="https://www.aeroflot.ru/sb/app/ru-ru#/search?...routes=MOW..LED"`. +- **No popups in spider mode.** Filter `{departure:"MOW"}`, no arrival → `popups` prop is `[]`. +- **No popups when filteredRoutes is empty.** Filter with toggle that filters everything out → `popups` is `[]`. + +## Success criteria + +- `pnpm tsc --noEmit` clean. +- All new tests pass. Expected delta: ~22 new (9 filterRoutes + 5 buyTicketUrl + 3 routesToPolylines normalization + 5 page integration). +- Full suite green. +- Page renders identically to today when dictionaries are loading or error. +- With dictionaries present + routes returned, the map draws polylines, shows departure+arrival popups with the buy-ticket link, and the filter toggles apply client-side. + +## Deferred to C.5 + +- Date picker UI: once wired, the popup URL memo already depends on `filterState.date`, so popups regenerate automatically. +- Exchange button (swap dep↔arr) and geolocation default departure. +- Calendar `disabled-days` integration (uses `useFlightsMapCalendar` which already exists).