diff --git a/docs/superpowers/plans/2026-04-17-city-autocomplete-regional-picker.md b/docs/superpowers/plans/2026-04-17-city-autocomplete-regional-picker.md new file mode 100644 index 00000000..add71c33 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-city-autocomplete-regional-picker.md @@ -0,0 +1,1691 @@ +# City Autocomplete + Regional Picker 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 cross-feature `` with regional picker popup matching Angular's `city-autocomplete` + `city-select`, apply to OnlineBoardFilter Route tab and FlightsMapFilter, and switch both filters' time-selector to Angular's compact layout. + +**Architecture:** New `src/ui/city-autocomplete/` module with three files (helper, popup, composite). PrimeReact `` remains the typeahead engine; everything else is custom. Consumers import the composite and replace inline `` blocks. + +**Tech Stack:** TypeScript, React 18, PrimeReact ``, vitest, jsdom, @testing-library/react. + +**Related spec:** `docs/superpowers/specs/2026-04-17-city-autocomplete-regional-picker-design.md` + +--- + +## File Structure + +**New:** +- `src/ui/city-autocomplete/buildCountryCityRows.ts` +- `src/ui/city-autocomplete/buildCountryCityRows.test.ts` +- `src/ui/city-autocomplete/CityPickerPopup.tsx` +- `src/ui/city-autocomplete/CityPickerPopup.test.tsx` +- `src/ui/city-autocomplete/CityPickerPopup.scss` +- `src/ui/city-autocomplete/CityAutocomplete.tsx` +- `src/ui/city-autocomplete/CityAutocomplete.test.tsx` +- `src/ui/city-autocomplete/CityAutocomplete.scss` +- `src/ui/city-autocomplete/index.ts` + +**Modified:** +- `src/features/online-board/components/OnlineBoardFilter.tsx` (Route tab only) +- `src/features/online-board/components/OnlineBoardFilter.scss` (time-selector compact view) +- `src/features/online-board/components/OnlineBoardFilter.test.tsx` (integration) +- `src/features/flights-map/components/FlightsMapFilter.tsx` +- `src/features/flights-map/components/FlightsMapFilter.test.tsx` +- `src/features/flights-map/components/FlightsMapStartPage.scss` (time-selector compact view, if present; else the filter's SCSS) + +--- + +## Task 1: `buildCountryCityRows` pure helper + +**Files:** +- Create: `src/ui/city-autocomplete/buildCountryCityRows.ts` +- Create: `src/ui/city-autocomplete/buildCountryCityRows.test.ts` + +- [ ] **Step 1.1: Write failing tests** + +Create `src/ui/city-autocomplete/buildCountryCityRows.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildCountryCityRows } from "./buildCountryCityRows.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; + +function makeRaw(overrides: Partial = {}): IRawDictionaries { + return { regions: [], countries: [], cities: [], airports: [], ...overrides }; +} + +function dict(raw: Partial): IDictionaries { + return transformDictionaries(makeRaw(raw), "ru"); +} + +describe("buildCountryCityRows — empty and missing", () => { + it("returns [] for missing region id", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + }); + expect(buildCountryCityRows(d, 99999)).toEqual([]); + }); + + it("returns [] when region has no countries", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + }); + expect(buildCountryCityRows(d, 500374)).toEqual([]); + }); +}); + +describe("buildCountryCityRows — RU pinning", () => { + it("pins RU country first within its region, others alphabetical", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + countries: [ + { code: "BY", title: { ru: "Беларусь" }, world_region_id: 500374 }, + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "AM", title: { ru: "Армения" }, world_region_id: 500374 }, + ], + cities: [ + { code: "BYN", title: { ru: "Минск" }, country_code: "BY", has_afl_flights: true, location: { lat: 53, lon: 27 } }, + { code: "YER", title: { ru: "Ереван" }, country_code: "AM", has_afl_flights: true, location: { lat: 40, lon: 44 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + airports: [ + { code: "MSQ", city_code: "BYN", title: { ru: "Минск аэропорт" }, has_afl_flights: true, location: { lat: 53, lon: 27 } }, + { code: "EVN", city_code: "YER", title: { ru: "Звартноц" }, has_afl_flights: true, location: { lat: 40, lon: 44 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + const countryOrder = rows + .map((r) => r.countryName) + .filter((n): n is string => Boolean(n)); + expect(countryOrder[0]).toBe("Россия"); + // Others in alpha order after Russia. + expect(countryOrder).toEqual(["Россия", "Армения", "Беларусь"]); + }); +}); + +describe("buildCountryCityRows — MOW pinning inside RU", () => { + it("pins MOW first among RU cities", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "KZN", title: { ru: "Казань" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 49 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + airports: [ + { code: "KZN", city_code: "KZN", title: { ru: "Казань аэропорт" }, has_afl_flights: true, location: { lat: 55, lon: 49 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]!.city1!.code).toBe("MOW"); + }); +}); + +describe("buildCountryCityRows — pairing", () => { + it("pairs two single-airport cities on the same row", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(1); + expect(rows[0]!.city1!.code).toBe("AAA"); + expect(rows[0]!.city2!.code).toBe("BBB"); + expect(rows[0]!.city1Airports).toBeUndefined(); + }); + + it("puts a multi-airport city on its own row with city1Airports populated", () => { + const d = dict({ + 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 } }, + { code: "ZZZ", title: { ru: "Город Я" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + 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: "ZZZ", city_code: "ZZZ", title: { ru: "Аэропорт Я" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(2); + // Row 1: MOW alone with airports + expect(rows[0]!.city1!.code).toBe("MOW"); + expect(rows[0]!.city2).toBeNull(); + expect(rows[0]!.city1Airports?.length).toBe(2); + // Row 2: ZZZ alone, no pair + expect(rows[1]!.city1!.code).toBe("ZZZ"); + expect(rows[1]!.city2).toBeNull(); + }); + + it("handles odd-count cities by leaving city2 null on last row", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", title: { ru: "Гор В" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", city_code: "CCC", title: { ru: "Аэропорт В" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + expect(rows).toHaveLength(2); + expect(rows[1]!.city2).toBeNull(); + }); +}); + +describe("buildCountryCityRows — countryName placement", () => { + it("sets countryName only on the first row of each country block", () => { + const d = dict({ + regions: [{ world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "BY", title: { ru: "Беларусь" }, world_region_id: 500374 }, + ], + cities: [ + { code: "AAA", title: { ru: "Гор А" }, country_code: "RU", has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", title: { ru: "Гор Б" }, country_code: "RU", has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", title: { ru: "Гор В" }, country_code: "RU", has_afl_flights: true, location: { lat: 3, lon: 3 } }, + { code: "DDD", title: { ru: "Гор Д" }, country_code: "BY", has_afl_flights: true, location: { lat: 4, lon: 4 } }, + ], + airports: [ + { code: "AAA", city_code: "AAA", title: { ru: "Аэропорт А" }, has_afl_flights: true, location: { lat: 1, lon: 1 } }, + { code: "BBB", city_code: "BBB", title: { ru: "Аэропорт Б" }, has_afl_flights: true, location: { lat: 2, lon: 2 } }, + { code: "CCC", city_code: "CCC", title: { ru: "Аэропорт В" }, has_afl_flights: true, location: { lat: 3, lon: 3 } }, + { code: "DDD", city_code: "DDD", title: { ru: "Аэропорт Д" }, has_afl_flights: true, location: { lat: 4, lon: 4 } }, + ], + }); + + const rows = buildCountryCityRows(d, 500374); + // Row 1 (RU, first): countryName set. + // Row 2 (RU, continuation): countryName undefined. + // Row 3 (BY, first): countryName set. + expect(rows[0]!.countryName).toBe("Россия"); + expect(rows[1]!.countryName).toBeUndefined(); + expect(rows[2]!.countryName).toBe("Беларусь"); + }); +}); +``` + +- [ ] **Step 1.2: Run failing tests** + +Run: `pnpm vitest run src/ui/city-autocomplete/buildCountryCityRows.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 1.3: Implement the helper** + +Create `src/ui/city-autocomplete/buildCountryCityRows.ts`: + +```ts +/** + * Build rows for the regional-picker country/city grid. + * + * Matches Angular `DictionariesService.handleLoading` logic: + * 1. Countries in the region: alphabetical by localized name, RU first. + * 2. Cities within a country: alphabetical, excluding MOW initially; + * MOW re-inserted at front for RU country. + * 3. Row packing: city with >1 airport gets its own row (airports below); + * others paired into city1 + city2 columns. + * 4. `countryName` set only on the first row of each country's block. + */ + +import type { + IAirport, + ICity, + IDictionaries, +} from "@/shared/dictionaries/index.js"; + +export interface ICountryCityRow { + countryName?: string; + city1: ICity | null; + city2: ICity | null; + city1Airports?: IAirport[]; +} + +export function buildCountryCityRows( + dictionaries: IDictionaries, + regionId: number, +): ICountryCityRow[] { + const region = dictionaries.regions.find((r) => r.id === regionId); + if (!region || region.countries.length === 0) return []; + + const countries = [...region.countries].sort((a, b) => + a.name.localeCompare(b.name), + ); + const ruIdx = countries.findIndex((c) => c.code === "RU"); + if (ruIdx > 0) { + const [ru] = countries.splice(ruIdx, 1); + if (ru) countries.unshift(ru); + } + + const rows: ICountryCityRow[] = []; + + for (const country of countries) { + const cities = dictionaries.cities + .filter((c) => c.country_code === country.code && c.code !== "MOW") + .sort((a, b) => a.name.localeCompare(b.name)); + + if (country.code === "RU") { + const mow = dictionaries.cities.find((c) => c.code === "MOW"); + if (mow) cities.unshift(mow); + } + + if (cities.length === 0) continue; + + let firstRow = true; + let i = 0; + while (i < cities.length) { + const city1 = cities[i]!; + const multiAirport = city1.airports.length > 1; + + if (multiAirport) { + rows.push({ + ...(firstRow ? { countryName: country.name } : {}), + city1, + city2: null, + city1Airports: city1.airports, + }); + i += 1; + } else { + const city2 = cities[i + 1] ?? null; + rows.push({ + ...(firstRow ? { countryName: country.name } : {}), + city1, + city2, + }); + i += 2; + } + firstRow = false; + } + } + + return rows; +} +``` + +- [ ] **Step 1.4: Run tests — expect pass** + +Run: `pnpm vitest run src/ui/city-autocomplete/buildCountryCityRows.test.ts` +Expected: PASS — 7 tests (2 empty + 1 RU pinning + 1 MOW pinning + 3 pairing + 1 countryName placement). Actually 8: 2+1+1+3+1 = 8. Count may differ depending on how the subagent counts nested describes — target is "all pass." + +- [ ] **Step 1.5: Commit** + +```bash +git add src/ui/city-autocomplete/buildCountryCityRows.ts src/ui/city-autocomplete/buildCountryCityRows.test.ts +git commit -m "Add buildCountryCityRows helper for regional picker grid" +``` + +--- + +## Task 2: `CityPickerPopup` component + +**Files:** +- Create: `src/ui/city-autocomplete/CityPickerPopup.tsx` +- Create: `src/ui/city-autocomplete/CityPickerPopup.test.tsx` +- Create: `src/ui/city-autocomplete/CityPickerPopup.scss` + +- [ ] **Step 2.1: Write failing tests** + +Create `src/ui/city-autocomplete/CityPickerPopup.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { CityPickerPopup } from "./CityPickerPopup.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IRawDictionaries } from "@/shared/dictionaries/index.js"; + +const raw: IRawDictionaries = { + regions: [ + { world_region_id: 500374, title: { ru: "Россия и страны СНГ" } }, + { world_region_id: 1, title: { ru: "Азия" } }, + ], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "CN", 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: "BJS", title: { ru: "Пекин" }, country_code: "CN", has_afl_flights: true, location: { lat: 39, lon: 116 } }, + ], + 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: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + { code: "PEK", city_code: "BJS", title: { ru: "Пекин аэропорт" }, has_afl_flights: true, location: { lat: 39, lon: 116 } }, + ], +}; +const dictionaries = transformDictionaries(raw, "ru"); + +describe("CityPickerPopup", () => { + it("renders a tab button per region", () => { + render(); + expect(screen.getByText("Россия и страны СНГ")).toBeTruthy(); + expect(screen.getByText("Азия")).toBeTruthy(); + }); + + it("shows the first region's rows by default", () => { + render(); + // RU is first region, should show Moscow. + expect(screen.getByText("Москва")).toBeTruthy(); + }); + + it("switches to another region when tab clicked", () => { + render(); + fireEvent.click(screen.getByText("Азия")); + expect(screen.getByText("Пекин")).toBeTruthy(); + }); + + it("calls onSelect with city code when city cell clicked", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Санкт-Петербург")); + expect(onSelect).toHaveBeenCalledWith("LED"); + }); + + it("calls onSelect with airport code when airport cell clicked (multi-airport city)", () => { + const onSelect = vi.fn(); + render(); + // MOW has SVO + DME. Click on SVO. + fireEvent.click(screen.getByText("Шереметьево")); + expect(onSelect).toHaveBeenCalledWith("SVO"); + }); + + it("highlights the selectedCode cell", () => { + const { container } = render( + , + ); + const activeCells = container.querySelectorAll(".city-active"); + expect(activeCells.length).toBeGreaterThan(0); + }); + + it("hides GPS button when onLocate is not provided", () => { + render(); + expect(screen.queryByTestId("city-picker-locate")).toBeNull(); + }); + + it("shows GPS button when onLocate is provided and invokes it on click", () => { + const onLocate = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("city-picker-locate")); + expect(onLocate).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2.2: Run failing tests** + +Run: `pnpm vitest run src/ui/city-autocomplete/CityPickerPopup.test.tsx` +Expected: FAIL — component not found. + +- [ ] **Step 2.3: Implement the component** + +Create `src/ui/city-autocomplete/CityPickerPopup.tsx`: + +```tsx +import { useState, useMemo, type FC } from "react"; +import { buildCountryCityRows } from "./buildCountryCityRows.js"; +import type { IAirport, IDictionaries } from "@/shared/dictionaries/index.js"; +import "./CityPickerPopup.scss"; + +export interface CityPickerPopupProps { + dictionaries: IDictionaries; + selectedCode?: string; + onSelect: (code: string) => void; + onLocate?: () => void | Promise; +} + +export const CityPickerPopup: FC = ({ + dictionaries, + selectedCode, + onSelect, + onLocate, +}) => { + const regions = dictionaries.regions; + const [activeRegionId, setActiveRegionId] = useState( + regions[0]?.id ?? 0, + ); + + const rows = useMemo( + () => buildCountryCityRows(dictionaries, activeRegionId), + [dictionaries, activeRegionId], + ); + + return ( +
+
+ {regions.map((r) => ( + + ))} +
+ +
+
+ {rows.map((row, idx) => { + const rowKey = `${row.countryName ?? ""}-${row.city1?.code ?? ""}-${idx}`; + const isMulti = Boolean(row.city1Airports); + return ( +
+
+
{row.countryName ?? ""}
+
+ + {!isMulti && row.city1 && ( + <> +
+
onSelect(row.city1!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code); + }} + > + {row.city1.name} +
+
+
+ {row.city2 && ( +
onSelect(row.city2!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city2!.code); + }} + > + {row.city2.name} +
+ )} +
+ + )} + + {isMulti && row.city1 && ( +
+
onSelect(row.city1!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code); + }} + > + {row.city1.name} +
+
+ {row.city1Airports!.map((ap: IAirport) => ( +
onSelect(ap.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(ap.code); + }} + > + {ap.name} +
+ ))} +
+
+ )} +
+ ); + })} +
+
+ + {onLocate && ( +
+
+ +
+
+ )} +
+ ); +}; +``` + +- [ ] **Step 2.4: Create minimal SCSS stub** + +Create `src/ui/city-autocomplete/CityPickerPopup.scss` — ported from Angular's `city-select.component.scss`: + +```scss +.city-autocomplete-popup { + position: absolute; + width: 630px; + background: #ffffff; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10000; + margin-top: 8px; + + .tabs { + display: flex; + align-items: stretch; + border-bottom: 1px solid #e0e0e0; + + .tab-button { + flex-grow: 1; + padding: 10px; + background-color: #f3f9ff; + border: none; + border-right: 1px solid #e0e0e0; + color: #2457ff; + font-weight: 700; + font-size: 14px; + cursor: pointer; + + &:last-child { border-right: none; } + + &.active { + background-color: #ffffff; + color: #333; + } + + &:hover { + background-color: #e8f1ff; + } + } + } + + .content { + padding: 12px 16px; + + .content--scroll-panel { + max-height: 350px; + overflow-y: auto; + } + + .row { + display: flex; + align-items: flex-start; + padding: 4px 0; + + &.country-start-row { + border-top: 1px solid #e0e0e0; + padding-top: 12px; + margin-top: 8px; + + &:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; + } + } + } + + .cell { + &.contry { + width: 30%; + font-weight: 700; + padding: 7.5px; + } + + &.city { + flex: 1; + padding-right: 16px; + + .city--item { + display: inline-block; + padding: 8px; + cursor: pointer; + border-radius: 4px; + transition-duration: 0.1s; + + &:hover { background-color: #f3f9ff; } + } + + &.city-active .city--item { + background-color: #dfe8ff; + } + + .airports-column { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 8px; + + .city--item { + color: #8a8a8a; + } + } + } + } + } + + .city-autocomplete-popup-footer { + background: #f3f9ff; + border-top: 1px solid #e0e0e0; + + .gps-contaner { + padding: 16px; + + .gps-button { + height: 40px; + width: 100%; + background-color: #4a8cff; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + + &:hover { background-color: #3a7aee; } + } + } + } +} +``` + +- [ ] **Step 2.5: Run tests** + +Run: `pnpm vitest run src/ui/city-autocomplete/CityPickerPopup.test.tsx` +Expected: PASS — 8 tests. + +- [ ] **Step 2.6: Commit** + +```bash +git add src/ui/city-autocomplete/CityPickerPopup.tsx src/ui/city-autocomplete/CityPickerPopup.test.tsx src/ui/city-autocomplete/CityPickerPopup.scss +git commit -m "Add CityPickerPopup with regional tabs and country/city grid" +``` + +--- + +## Task 3: `CityAutocomplete` composite + +**Files:** +- Create: `src/ui/city-autocomplete/CityAutocomplete.tsx` +- Create: `src/ui/city-autocomplete/CityAutocomplete.test.tsx` +- Create: `src/ui/city-autocomplete/CityAutocomplete.scss` + +- [ ] **Step 3.1: Write failing tests** + +Create `src/ui/city-autocomplete/CityAutocomplete.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { CityAutocomplete } from "./CityAutocomplete.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IRawDictionaries } from "@/shared/dictionaries/index.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 } }, + { 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 dictionaries = transformDictionaries(raw, "ru"); + +// Stub PrimeReact AutoComplete to expose a controllable input. +vi.mock("primereact/autocomplete", () => ({ + AutoComplete: (props: Record) => ( + { + const onChange = props["onChange"] as ((ev: { value: string }) => void) | undefined; + onChange?.({ value: e.target.value }); + }} + /> + ), +})); + +describe("CityAutocomplete", () => { + it("renders label and placeholder", () => { + render( + , + ); + expect(screen.getByText("City")).toBeTruthy(); + expect(screen.getByPlaceholderText("Pick one")).toBeTruthy(); + }); + + it("shows the selected city code in the right label when value is set", () => { + render( + , + ); + expect(screen.getByTestId("test-code").textContent).toBe("MOW"); + }); + + it("opens the regional picker when the popup-trigger button is clicked", () => { + render( + , + ); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.getByTestId("city-picker-popup")).toBeTruthy(); + }); + + it("calls onChange with the selected code when a popup cell is clicked", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + fireEvent.click(screen.getByText("Санкт-Петербург")); + expect(onChange).toHaveBeenCalledWith("LED"); + }); + + it("clears the value when the clear button is clicked", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("test-clear-button")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("closes the popup when clicking outside the component", () => { + render( +
+ +
outside
+
, + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.getByTestId("city-picker-popup")).toBeTruthy(); + fireEvent.mouseDown(screen.getByTestId("outside")); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + }); + + it("renders error tooltip and has-error class when error prop is set", () => { + const { container } = render( + , + ); + expect(screen.getByTestId("test-error").textContent).toBe("required"); + expect(container.querySelector(".city-autocomplete__input--has-error")).toBeTruthy(); + }); + + it("does not open popup when dictionaries is null", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + }); +}); +``` + +- [ ] **Step 3.2: Run failing tests** + +Run: `pnpm vitest run src/ui/city-autocomplete/CityAutocomplete.test.tsx` +Expected: FAIL — component not found. + +- [ ] **Step 3.3: Implement the composite** + +Create `src/ui/city-autocomplete/CityAutocomplete.tsx`: + +```tsx +import { useState, useRef, useCallback, useEffect, type FC } from "react"; +import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; +import { CityPickerPopup } from "./CityPickerPopup.js"; +import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import "./CityAutocomplete.scss"; + +interface CitySuggestion { + code: string; + name: string; +} + +export interface CityAutocompleteProps { + label: string; + placeholder: string; + value: string; + onChange: (code: string) => void; + dictionaries: IDictionaries | null; + onLocate?: () => void | Promise; + error?: string; + testIdPrefix?: string; +} + +export const CityAutocomplete: FC = ({ + label, + placeholder, + value, + onChange, + dictionaries, + onLocate, + error, + testIdPrefix = "city-autocomplete", +}) => { + const [inputValue, setInputValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); + const [popupOpen, setPopupOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setPopupOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const handleComplete = useCallback( + (event: AutoCompleteCompleteEvent) => { + if (!dictionaries) { + setSuggestions([]); + return; + } + const q = event.query.trim().toUpperCase(); + if (q.length === 0) { + setSuggestions([]); + return; + } + + if (q.length === 3) { + const byCity = dictionaries.cityByCode.get(q); + if (byCity) { + setSuggestions([{ code: byCity.code, name: byCity.name }]); + return; + } + } + + const lc = event.query.toLowerCase(); + const byName = dictionaries.cities + .filter((c) => c.name.toLowerCase().includes(lc)) + .slice(0, 15) + .map((c) => ({ code: c.code, name: c.name })); + if (byName.length) { + setSuggestions(byName); + return; + } + + const byAirport = dictionaries.airportByCode.get(q); + if (byAirport) { + const city = dictionaries.cityByCode.get(byAirport.city_code.toUpperCase()); + if (city) { + setSuggestions([{ code: city.code, name: city.name }]); + return; + } + } + setSuggestions([]); + }, + [dictionaries], + ); + + const handleSelect = useCallback( + (code: string) => { + onChange(code); + setPopupOpen(false); + }, + [onChange], + ); + + const handleClear = useCallback(() => { + onChange(""); + setInputValue(""); + }, [onChange]); + + const selectedCity = dictionaries?.cityByCode.get(value.toUpperCase()) ?? null; + const hasValue = Boolean(selectedCity); + + return ( +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ setInputValue(e.value as CitySuggestion | string)} + onSelect={(e) => { + const sel = e.value as CitySuggestion; + if (sel?.code) handleSelect(sel.code); + }} + data-testid={`${testIdPrefix}-input`} + inputClassName="input--filter" + className="input--filter" + /> +
+ + {popupOpen && dictionaries && ( +
+ +
+ )} +
+ ); +}; +``` + +- [ ] **Step 3.4: Create SCSS** + +Create `src/ui/city-autocomplete/CityAutocomplete.scss`: + +```scss +.city-autocomplete { + display: flex; + flex-direction: column; + position: relative; + + &__labels-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + } + + &__label { + color: #8a8a8a; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__input { + display: flex; + flex-direction: row; + position: relative; + align-items: center; + width: 100%; + box-shadow: 0 0 0 1px #e0e0e0; + border-radius: 4px; + + .p-autocomplete { + flex: 1; + } + + .button-clear { + display: none; + width: 32px; + height: 38px; + border: none; + background: transparent; + cursor: pointer; + font-size: 18px; + color: #8a8a8a; + + &::before { + content: "×"; + } + } + } + + &__input--has-value { + .button-clear { + display: block; + } + + .city-autocomplete__search-button { + border-left: 1px solid #e0e0e0 !important; + } + } + + &__input--has-error { + box-shadow: 0 0 0 1px #e55353; + } + + &__search-button { + width: 38px !important; + min-width: 38px; + height: 38px; + border-radius: 0 4px 4px 0 !important; + border: none !important; + border-left: 1px solid white !important; + background-color: transparent !important; + background-image: url("/assets/img/sprite.svg#search"); + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + + &::before { + content: "▾"; + display: inline-block; + color: #2457ff; + } + + &:hover { + background-color: #fff !important; + border-left-color: #e0e0e0 !important; + } + + &--opened { + background-color: #eef3ff !important; + } + } +} + +.city-autocomplete-popup-wrapper { + position: relative; +} + +.tooltip { + color: #e55353; + font-size: 12px; + margin-bottom: 4px; +} +``` + +- [ ] **Step 3.5: Run tests** + +Run: `pnpm vitest run src/ui/city-autocomplete/CityAutocomplete.test.tsx` +Expected: PASS — 8 tests. + +- [ ] **Step 3.6: Create barrel** + +Create `src/ui/city-autocomplete/index.ts`: + +```ts +export { CityAutocomplete } from "./CityAutocomplete.js"; +export type { CityAutocompleteProps } from "./CityAutocomplete.js"; +export { CityPickerPopup } from "./CityPickerPopup.js"; +export type { CityPickerPopupProps } from "./CityPickerPopup.js"; +export { buildCountryCityRows } from "./buildCountryCityRows.js"; +export type { ICountryCityRow } from "./buildCountryCityRows.js"; +``` + +- [ ] **Step 3.7: Typecheck + full feature suite** + +Run: `pnpm tsc --noEmit` — expect no output. +Run: `pnpm vitest run src/ui/` — expect all `src/ui/city-autocomplete` tests pass. + +- [ ] **Step 3.8: Commit** + +```bash +git add src/ui/city-autocomplete/CityAutocomplete.tsx src/ui/city-autocomplete/CityAutocomplete.test.tsx src/ui/city-autocomplete/CityAutocomplete.scss src/ui/city-autocomplete/index.ts +git commit -m "Add CityAutocomplete composite with clear and regional-picker trigger" +``` + +--- + +## Task 4: Wire into `OnlineBoardFilter` Route tab + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardFilter.tsx` + +- [ ] **Step 4.1: Add dictionaries hook and imports** + +Open `src/features/online-board/components/OnlineBoardFilter.tsx`. + +Add near other `@/` imports: + +```tsx +import { CityAutocomplete } from "@/ui/city-autocomplete/index.js"; +import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { useParams } from "@modern-js/runtime/router"; +``` + +(If `useParams` is already imported, don't duplicate.) + +Inside the component body, near the top before other state: + +```tsx +const { lang } = useParams<{ lang: string }>(); +const { dictionaries } = useDictionaries(lang ?? "ru"); +``` + +- [ ] **Step 4.2: Change Route tab state to plain strings** + +Find: + +```tsx +const [routeDeparture, setRouteDeparture] = useState(initialDeparture ?? ""); +const [routeArrival, setRouteArrival] = useState(initialArrival ?? ""); +``` + +Replace with: + +```tsx +const [routeDepartureCode, setRouteDepartureCode] = useState(initialDeparture ?? ""); +const [routeArrivalCode, setRouteArrivalCode] = useState(initialArrival ?? ""); +``` + +Update `handleExchange` to swap codes: + +```tsx +const handleExchange = useCallback(() => { + const prevDep = routeDepartureCode; + setRouteDepartureCode(routeArrivalCode); + setRouteArrivalCode(prevDep); +}, [routeDepartureCode, routeArrivalCode]); +``` + +Update `handleRouteSubmit` to use the string codes directly: + +```tsx +const handleRouteSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + const depCode = routeDepartureCode.trim().toUpperCase(); + const arrCode = routeArrivalCode.trim().toUpperCase(); + // ... existing validation + navigation logic, using depCode/arrCode as before + }, + [routeDepartureCode, routeArrivalCode, routeDate, navigate, lang], +); +``` + +If the existing code already narrows `routeDeparture`/`routeArrival` via `typeof === "string"`, remove those branches — the new variables are already strings. + +- [ ] **Step 4.3: Replace the two `` blocks in the Route tab** + +Find the Route tab's two `` blocks (dep + arr). Replace the entire dep AutoComplete + label + arr AutoComplete + label section with: + +```tsx + + +
+ +
+ + +``` + +The existing `data-testid="route-departure-input"` on the input is now created by `CityAutocomplete` automatically from `testIdPrefix="route-departure"` → `route-departure-input`. Same for arrival. + +- [ ] **Step 4.4: Remove now-unused `useCitySearch` Route tab usage** + +Find and remove: + +```tsx +const { suggestions: routeDepSuggestions, search: searchRouteDep } = useCitySearch(); +const { suggestions: routeArrSuggestions, search: searchRouteArr } = useCitySearch(); +const handleRouteDepSearch = useCallback(...); +const handleRouteArrSearch = useCallback(...); +``` + +Keep Flight-Number tab's usage (if any) untouched. + +- [ ] **Step 4.5: Typecheck + test suite** + +Run: `pnpm tsc --noEmit` — expect no output. +Run: `pnpm vitest run src/features/online-board/` — expect all tests pass (existing test ids preserved via testIdPrefix). + +If tests fail due to assertions on the old `routeDeparture` being `CitySuggestion | string`, update the assertions to expect the string shape. + +- [ ] **Step 4.6: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardFilter.tsx +git commit -m "Use CityAutocomplete for OnlineBoardFilter Route tab departure + arrival" +``` + +--- + +## Task 5: Wire into `FlightsMapFilter` + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapFilter.tsx` + +- [ ] **Step 5.1: Add imports and wire up** + +Open `src/features/flights-map/components/FlightsMapFilter.tsx`. + +Add imports near existing: + +```tsx +import { CityAutocomplete } from "@/ui/city-autocomplete/index.js"; +import { + useDictionaries, + findCityByCoord, +} from "@/shared/dictionaries/index.js"; +import { useParams } from "@modern-js/runtime/router"; +``` + +- [ ] **Step 5.2: Access dictionaries and build onLocate** + +Inside the component body, near the top: + +```tsx +const { lang } = useParams<{ lang: string }>(); +const { dictionaries } = useDictionaries(lang ?? "ru"); + +const handleLocate = useCallback(async () => { + if (!dictionaries || typeof navigator === "undefined" || !navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + const city = findCityByCoord( + dictionaries, + pos.coords.latitude, + pos.coords.longitude, + ); + if (city) { + onChange({ ...value, departure: city.code }); + setDeparture(city.code); + } + }, + () => { + // silent + }, + { enableHighAccuracy: false, timeout: 5000 }, + ); +}, [dictionaries, onChange, value]); +``` + +- [ ] **Step 5.3: Replace the two `` blocks** + +Find the existing two `` blocks and their surrounding `