# 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 `<CityAutocomplete>` 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 `<AutoComplete>` remains the typeahead engine; everything else is custom. Consumers import the composite and replace inline `<AutoComplete>` blocks.

**Tech Stack:** TypeScript, React 18, PrimeReact `<AutoComplete>`, 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> = {}): IRawDictionaries {
  return { regions: [], countries: [], cities: [], airports: [], ...overrides };
}

function dict(raw: Partial<IRawDictionaries>): 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(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
    expect(screen.getByText("Россия и страны СНГ")).toBeTruthy();
    expect(screen.getByText("Азия")).toBeTruthy();
  });

  it("shows the first region's rows by default", () => {
    render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
    // RU is first region, should show Moscow.
    expect(screen.getByText("Москва")).toBeTruthy();
  });

  it("switches to another region when tab clicked", () => {
    render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
    fireEvent.click(screen.getByText("Азия"));
    expect(screen.getByText("Пекин")).toBeTruthy();
  });

  it("calls onSelect with city code when city cell clicked", () => {
    const onSelect = vi.fn();
    render(<CityPickerPopup dictionaries={dictionaries} onSelect={onSelect} />);
    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(<CityPickerPopup dictionaries={dictionaries} onSelect={onSelect} />);
    // MOW has SVO + DME. Click on SVO.
    fireEvent.click(screen.getByText("Шереметьево"));
    expect(onSelect).toHaveBeenCalledWith("SVO");
  });

  it("highlights the selectedCode cell", () => {
    const { container } = render(
      <CityPickerPopup dictionaries={dictionaries} selectedCode="LED" onSelect={vi.fn()} />,
    );
    const activeCells = container.querySelectorAll(".city-active");
    expect(activeCells.length).toBeGreaterThan(0);
  });

  it("hides GPS button when onLocate is not provided", () => {
    render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
    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(
      <CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} onLocate={onLocate} />,
    );
    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<void>;
}

export const CityPickerPopup: FC<CityPickerPopupProps> = ({
  dictionaries,
  selectedCode,
  onSelect,
  onLocate,
}) => {
  const regions = dictionaries.regions;
  const [activeRegionId, setActiveRegionId] = useState<number>(
    regions[0]?.id ?? 0,
  );

  const rows = useMemo(
    () => buildCountryCityRows(dictionaries, activeRegionId),
    [dictionaries, activeRegionId],
  );

  return (
    <div className="city-autocomplete-popup" data-testid="city-picker-popup">
      <div className="tabs">
        {regions.map((r) => (
          <button
            key={r.id}
            type="button"
            role="tab"
            aria-selected={r.id === activeRegionId}
            className={`tab-button${r.id === activeRegionId ? " active" : ""}`}
            onClick={() => setActiveRegionId(r.id)}
            data-testid={`city-picker-tab-${r.id}`}
          >
            {r.name}
          </button>
        ))}
      </div>

      <div className="content">
        <div className="content--scroll-panel">
          {rows.map((row, idx) => {
            const rowKey = `${row.countryName ?? ""}-${row.city1?.code ?? ""}-${idx}`;
            const isMulti = Boolean(row.city1Airports);
            return (
              <div
                key={rowKey}
                className={`row${row.countryName ? " country-start-row" : ""}`}
              >
                <div className="cell contry">
                  <div>{row.countryName ?? ""}</div>
                </div>

                {!isMulti && row.city1 && (
                  <>
                    <div
                      className={`cell city${row.city1.code === selectedCode ? " city-active" : ""}`}
                      data-testid={`city-name-cell-${row.city1.name}`}
                    >
                      <div
                        className="city--item"
                        role="button"
                        tabIndex={0}
                        onClick={() => onSelect(row.city1!.code)}
                        onKeyDown={(e) => {
                          if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code);
                        }}
                      >
                        {row.city1.name}
                      </div>
                    </div>
                    <div className={`cell city${row.city2?.code === selectedCode ? " city-active" : ""}`}>
                      {row.city2 && (
                        <div
                          className="city--item"
                          role="button"
                          tabIndex={0}
                          data-testid={`city-name-cell-${row.city2.name}`}
                          onClick={() => onSelect(row.city2!.code)}
                          onKeyDown={(e) => {
                            if (e.key === "Enter" || e.key === " ") onSelect(row.city2!.code);
                          }}
                        >
                          {row.city2.name}
                        </div>
                      )}
                    </div>
                  </>
                )}

                {isMulti && row.city1 && (
                  <div className={`cell city${row.city1.code === selectedCode ? " city-active" : ""}`}>
                    <div
                      className="city--item"
                      role="button"
                      tabIndex={0}
                      data-testid={`city-name-cell-${row.city1.name}`}
                      onClick={() => onSelect(row.city1!.code)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code);
                      }}
                    >
                      {row.city1.name}
                    </div>
                    <div className="airports-column">
                      {row.city1Airports!.map((ap: IAirport) => (
                        <div
                          key={ap.code}
                          title={ap.name}
                          className="city--item"
                          role="button"
                          tabIndex={0}
                          data-testid={`airport-name-cell-${ap.name}`}
                          onClick={() => onSelect(ap.code)}
                          onKeyDown={(e) => {
                            if (e.key === "Enter" || e.key === " ") onSelect(ap.code);
                          }}
                        >
                          {ap.name}
                        </div>
                      ))}
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      </div>

      {onLocate && (
        <div className="city-autocomplete-popup-footer">
          <div className="gps-contaner">
            <button
              type="button"
              className="gps-button color blue-light"
              onClick={() => void onLocate()}
              data-testid="city-picker-locate"
            >
              GPS
            </button>
          </div>
        </div>
      )}
    </div>
  );
};
```

- [ ] **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<string, unknown>) => (
    <input
      data-testid={props["data-testid"] as string}
      placeholder={props["placeholder"] as string}
      value={typeof props["value"] === "string" ? props["value"] : ""}
      onChange={(e) => {
        const onChange = props["onChange"] as ((ev: { value: string }) => void) | undefined;
        onChange?.({ value: e.target.value });
      }}
    />
  ),
}));

describe("CityAutocomplete", () => {
  it("renders label and placeholder", () => {
    render(
      <CityAutocomplete
        label="City"
        placeholder="Pick one"
        value=""
        onChange={vi.fn()}
        dictionaries={dictionaries}
      />,
    );
    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(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value="MOW"
        onChange={vi.fn()}
        dictionaries={dictionaries}
        testIdPrefix="test"
      />,
    );
    expect(screen.getByTestId("test-code").textContent).toBe("MOW");
  });

  it("opens the regional picker when the popup-trigger button is clicked", () => {
    render(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value=""
        onChange={vi.fn()}
        dictionaries={dictionaries}
        testIdPrefix="test"
      />,
    );
    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(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value=""
        onChange={onChange}
        dictionaries={dictionaries}
        testIdPrefix="test"
      />,
    );
    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(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value="MOW"
        onChange={onChange}
        dictionaries={dictionaries}
        testIdPrefix="test"
      />,
    );
    fireEvent.click(screen.getByTestId("test-clear-button"));
    expect(onChange).toHaveBeenCalledWith("");
  });

  it("closes the popup when clicking outside the component", () => {
    render(
      <div>
        <CityAutocomplete
          label="City"
          placeholder="Pick"
          value=""
          onChange={vi.fn()}
          dictionaries={dictionaries}
          testIdPrefix="test"
        />
        <div data-testid="outside">outside</div>
      </div>,
    );
    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(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value=""
        onChange={vi.fn()}
        dictionaries={dictionaries}
        error="required"
        testIdPrefix="test"
      />,
    );
    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(
      <CityAutocomplete
        label="City"
        placeholder="Pick"
        value=""
        onChange={vi.fn()}
        dictionaries={null}
        testIdPrefix="test"
      />,
    );
    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<void>;
  error?: string;
  testIdPrefix?: string;
}

export const CityAutocomplete: FC<CityAutocompleteProps> = ({
  label,
  placeholder,
  value,
  onChange,
  dictionaries,
  onLocate,
  error,
  testIdPrefix = "city-autocomplete",
}) => {
  const [inputValue, setInputValue] = useState<CitySuggestion | string>(value);
  const [suggestions, setSuggestions] = useState<CitySuggestion[]>([]);
  const [popupOpen, setPopupOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(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 (
    <div ref={rootRef} className="city-autocomplete">
      <div className="city-autocomplete__labels-container">
        <label className="city-autocomplete__label">{label}</label>
        <label className="city-autocomplete__label" data-testid={`${testIdPrefix}-code`}>
          {selectedCity?.code ?? ""}
        </label>
      </div>

      {error && (
        <div className="tooltip" data-testid={`${testIdPrefix}-error`}>
          {error}
        </div>
      )}

      <div
        className={`city-autocomplete__input${
          hasValue ? " city-autocomplete__input--has-value" : ""
        }${error ? " city-autocomplete__input--has-error" : ""}`}
      >
        <AutoComplete
          value={inputValue}
          suggestions={suggestions}
          completeMethod={handleComplete}
          field="name"
          minLength={1}
          placeholder={placeholder}
          onChange={(e) => 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"
        />
        <button
          type="button"
          className="button-clear"
          onClick={handleClear}
          data-testid={`${testIdPrefix}-clear-button`}
          aria-label="clear"
        />
        <button
          type="button"
          className={`city-autocomplete__search-button${
            popupOpen ? " city-autocomplete__search-button--opened" : ""
          }`}
          onClick={() => {
            if (!dictionaries) return;
            setPopupOpen((v) => !v);
          }}
          data-testid={`${testIdPrefix}-popup-button`}
          aria-label="open regional picker"
        />
      </div>

      {popupOpen && dictionaries && (
        <div className="city-autocomplete-popup-wrapper">
          <CityPickerPopup
            dictionaries={dictionaries}
            selectedCode={value}
            onSelect={handleSelect}
            {...(onLocate ? { onLocate } : {})}
          />
        </div>
      )}
    </div>
  );
};
```

- [ ] **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<CitySuggestion | string>(initialDeparture ?? "");
const [routeArrival, setRouteArrival] = useState<CitySuggestion | string>(initialArrival ?? "");
```

Replace with:

```tsx
const [routeDepartureCode, setRouteDepartureCode] = useState<string>(initialDeparture ?? "");
const [routeArrivalCode, setRouteArrivalCode] = useState<string>(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<HTMLFormElement>) => {
    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 `<AutoComplete>` blocks in the Route tab**

Find the Route tab's two `<AutoComplete>` blocks (dep + arr). Replace the entire dep AutoComplete + label + arr AutoComplete + label section with:

```tsx
<CityAutocomplete
  label={t("SHARED.DEPARTURE_CITY")}
  placeholder={t("SHARED.CITY_PLACEHOLDER")}
  value={routeDepartureCode}
  onChange={setRouteDepartureCode}
  dictionaries={dictionaries}
  testIdPrefix="route-departure"
/>

<div className="change-container">
  <button
    className="button-change"
    type="button"
    onClick={handleExchange}
    data-testid="swap-cities-button"
  >
    <svg className="svg--change-city">
      <use xlinkHref="/assets/img/sprite.svg#changeCity" />
    </svg>
  </button>
</div>

<CityAutocomplete
  label={t("SHARED.ARRIVAL_CITY")}
  placeholder={t("SHARED.CITY_PLACEHOLDER")}
  value={routeArrivalCode}
  onChange={setRouteArrivalCode}
  dictionaries={dictionaries}
  testIdPrefix="route-arrival"
/>
```

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 `<AutoComplete>` blocks**

Find the existing two `<AutoComplete>` blocks and their surrounding `<label>` elements. Replace with:

```tsx
<CityAutocomplete
  label={t("SHARED.DEPARTURE_CITY")}
  placeholder={t("FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER")}
  value={typeof departure === "string" ? departure : (departure as { code?: string })?.code ?? ""}
  onChange={(code) => {
    setDeparture(code);
    onChange({ ...value, departure: code || undefined, arrival: undefined });
    setArrival("");
  }}
  dictionaries={dictionaries}
  onLocate={handleLocate}
  testIdPrefix="fm-departure"
/>

<button
  type="button"
  className="flights-map-filter__exchange"
  onClick={handleExchange}
  aria-label={t("SHARED.CITY_CHANGE")}
  data-testid="fm-exchange-btn"
>
  &#8646;
</button>

<CityAutocomplete
  label={t("SHARED.ARRIVAL_CITY")}
  placeholder={t("FLIGHTS-MAP.FILTER_ARRIVAL_PLACEHOLDER")}
  value={typeof arrival === "string" ? arrival : (arrival as { code?: string })?.code ?? ""}
  onChange={(code) => {
    setArrival(code);
    onChange({ ...value, arrival: code || undefined });
  }}
  dictionaries={dictionaries}
  testIdPrefix="fm-arrival"
/>
```

- [ ] **Step 5.4: Remove unused `useCitySearch` + AutoComplete imports**

Remove:

```tsx
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch.js";
```

...along with any per-field `handleDepartureSearch`/`handleArrivalSearch` callbacks that are no longer referenced.

Update `departure`/`arrival` state types from `CitySuggestion | string` to `string`:

```tsx
const [departure, setDeparture] = useState<string>(value.departure ?? "");
const [arrival, setArrival] = useState<string>(value.arrival ?? "");
```

Update handlers that previously handled `CitySuggestion | string` to just pass strings.

- [ ] **Step 5.5: Typecheck + tests**

Run: `pnpm tsc --noEmit` — expect no output.
Run: `pnpm vitest run src/features/flights-map/` — expect all tests pass. If `FlightsMapFilter.test.tsx` breaks (it mocks PrimeReact AutoComplete but the component now uses CityAutocomplete), update the test to mock `@/ui/city-autocomplete/index.js` instead:

```tsx
vi.mock("@/ui/city-autocomplete/index.js", () => ({
  CityAutocomplete: (props: Record<string, unknown>) => (
    <input
      data-testid={`${props["testIdPrefix"] ?? "cac"}-input`}
      placeholder={props["placeholder"] as string}
    />
  ),
}));
```

- [ ] **Step 5.6: Commit**

```bash
git add src/features/flights-map/components/FlightsMapFilter.tsx src/features/flights-map/components/FlightsMapFilter.test.tsx
git commit -m "Use CityAutocomplete for FlightsMapFilter with geolocate on departure"
```

---

## Task 6: Time-selector compact view

**Files:**
- Modify: `src/features/online-board/components/OnlineBoardFilter.tsx`
- Modify: `src/features/online-board/components/OnlineBoardFilter.scss` (if exists; else check where `wrapper--time-selector` styles live)
- Modify: `src/features/flights-map/components/FlightsMapFilter.tsx` (if it has a time selector — verify first)

- [ ] **Step 6.1: Verify where time-selector SCSS lives**

Run: `grep -rn "wrapper--time-selector" src/ --include="*.scss" --include="*.tsx" | head -10`

Note the matching files. The `.scss` file containing `wrapper--time-selector` rules is the one to modify.

- [ ] **Step 6.2: Change class to `compact-view` and restructure JSX in OnlineBoardFilter Route tab**

Find the `<div className="wrapper--time-selector full-view" ...>` block in `OnlineBoardFilter.tsx`. Replace with:

```tsx
<div className="wrapper--time-selector compact-view" data-testid="time-selector">
  <div className="time-selector__label-value">
    <div className="time-selector__label">{t("SHARED.FLIGHT_TIME")}</div>
    <div className="time-selector__value">
      {minutesToTime(timeRange[0])} — {minutesToTime(timeRange[1])}
    </div>
  </div>
  <div className="time-selector">
    <Slider
      value={timeRange}
      onChange={(e: SliderChangeEvent) => setTimeRange(e.value as [number, number])}
      range
      min={0}
      max={1440}
      step={60}
    />
  </div>
</div>
```

- [ ] **Step 6.3: Add `compact-view` CSS rules**

In the `.scss` file identified in 6.1, add (or update) alongside existing `.full-view` rules:

```scss
.wrapper--time-selector {
  &.full-view {
    // existing rules
  }

  &.compact-view {
    display: flex;
    flex-direction: column;
    gap: 6px;

    .time-selector__label-value {
      display: flex;
      justify-content: space-between;
      align-items: baseline;
    }

    .time-selector__label {
      color: #333;
      font-size: 12px;
      font-weight: 700;
    }

    .time-selector__value {
      color: #8a8a8a;
      font-size: 12px;
    }
  }
}
```

If the existing `.scss` already nests under `.wrapper--time-selector`, preserve that structure; just add the `&.compact-view` block inside.

- [ ] **Step 6.4: Repeat for FlightsMapFilter if it has a time-selector**

Check `FlightsMapFilter.tsx` for `wrapper--time-selector`. If present, apply the same change. If absent (Flights Map filter doesn't have a time selector), skip.

- [ ] **Step 6.5: Typecheck + full suite**

Run: `pnpm tsc --noEmit` — expect no output.
Run: `pnpm vitest run` — expect all pass.

- [ ] **Step 6.6: Commit**

```bash
git add src/features/online-board/components/OnlineBoardFilter.tsx src/features/online-board/components/OnlineBoardFilter.scss
git commit -m "Switch filter time-selector to Angular compact layout"
```

(Include additional files if step 6.4 modified them.)

---

## Task 7: Final verification

- [ ] **Step 7.1: Typecheck**

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

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

Run: `pnpm vitest run`
Expected: all pass. Expected new-test count: ~25 (8 buildCountryCityRows + 8 CityPickerPopup + 8 CityAutocomplete + 1-2 integration updates).

- [ ] **Step 7.3: Manual browser check**

In a browser open `http://localhost:8080/ru/onlineboard`:
- Route tab should show both city inputs with a visible popup-trigger button next to each.
- Clicking the trigger opens a popup with regional tabs (Россия и страны СНГ / Азия / Америка / Африка / Ближний восток / Европа).
- Clicking a city name in the grid populates the corresponding input and closes the popup.
- Clicking outside the component closes the popup.
- Time selector shows as compact (label + value on one row, slider below).

Also test `http://localhost:8080/ru/flights-map`:
- Both city inputs show the popup-trigger.
- Departure input's popup has a GPS button at the bottom.
- Clicking GPS → geolocation prompt; on success, departure fills with nearest city.

If servers are not running: `make dev-full`. Pipeline is optional — plan succeeds on test + typecheck pass.

---

## Self-Review Log

Ran against the spec:

- **Spec coverage.**
  - `buildCountryCityRows` (RU/MOW pinning, pairing rules, countryName placement) → Task 1.
  - `CityPickerPopup` (regional tabs, grid, selectedCode highlighting, GPS footer) → Task 2.
  - `CityAutocomplete` (label, typeahead by code/name/airport, clear, popup-trigger, outside-click, error, external-value sync, dictionaries-null guard) → Task 3.
  - Barrel + index → Task 3.6.
  - OnlineBoardFilter Route tab integration → Task 4.
  - FlightsMapFilter integration with geolocate → Task 5.
  - Time-selector compact view → Task 6.
  - Final verification → Task 7.
- **Placeholders.** None.
- **Type consistency.** `ICountryCityRow` shape matches across helper + popup + tests. `CityAutocompleteProps` matches across tests + component + consumers. `testIdPrefix` default value `"city-autocomplete"` consistent.
- **Known trade-offs.**
  - `CityAutocomplete.test.tsx` stubs PrimeReact's `<AutoComplete>` with a plain `<input>`, so typeahead-order tests (3-char code, name substring, airport resolution) would require a more elaborate stub. The popup+clear+outside-click+error tests are the coverage we ship; typeahead-order is covered via the pure `handleComplete` logic. If deeper coverage is needed later, add a dedicated unit test around an extracted `searchCities(dictionaries, query)` function.
  - `FlightsMapFilter.test.tsx` mock rewrite (Task 5.5) may require updating several existing assertions about captured props. Allow one iteration of test fixes.
