Add design spec for Flights Map C.4 (Popups + Filtering + Fallback)

Covers the final four Angular-parity gaps: filterRoutes pure helper,
buildBuyTicketUrl + escapeHtml, routesToPolylines/intermediateCityIds
airport→city normalization, and FlightsMapStartPage wiring for the
auto-fallback effect plus departure/arrival buy-ticket popups.
This commit is contained in:
2026-04-17 10:43:24 +03:00
parent 76e9270f5e
commit 299b0285b0
@@ -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<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)));
}
```
## `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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
```
## `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<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];
}
```
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<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]);
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<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],
);
```
### Popups
```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]);
```
### MapCanvas props
```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}
/>
```
## 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<FlightsMapSearchParams | null> = [];
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.<date>.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).