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:
@@ -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, "&")
|
||||
.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<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).
|
||||
Reference in New Issue
Block a user