# P2 — Start Pages + First-Entry Geo + Popular Sections 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:** Bring §4.1.1 (first-entry behavior + geo detection), §4.1.5 (popular sections), §4.1.6 (Online-Board start page composition), and §4.1.7 (Schedule start page composition) of TZ РИ-07-2538С into compliance.

**Architecture:** Start pages (`OnlineBoardStartPage`, `ScheduleStartPage`, `FlightsMapStartPage`) + filter components already exist from phase-1 + P1. Flight-Map already has a `useGeolocationDefault` hook; generalize it into a shared module so Board + Schedule can use the same geo-to-city logic. Popular-requests panel is already wired into Board + Schedule start pages; audit prefill behavior against TZ Table 10-like rules in §4.1.5. Tooltips on section tabs are present; verify copy matches TZ exactly. Mobile-specific `Время рейса` default `-1/+3ч` on Online-Board is new.

**Tech Stack:** TypeScript, React 18, Modern.js router, Vitest + React Testing Library, `navigator.geolocation`, `i18next` for copy.

**Parent spec:** `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` (at `8f573c1` on main after P1 merge).

**Rule IDs covered:** 4.1.1-R1 through R27 (27 rules already populated) + 4.1.5 and 4.1.6 and 4.1.7 (populated in Task 1).

---

## File Structure

### Files to create
- `src/shared/hooks/useGeoCityDefault.ts` — shared geolocation → city-code hook. Generalizes the Flight-Map-only `useGeolocationDefault`. Takes: dictionaries, a `shouldApply()` predicate (e.g. "filter is empty"), and an `onCity(cityCode)` callback.
- `src/shared/hooks/useGeoCityDefault.test.ts` — unit tests.
- `src/shared/hooks/useIsMobileViewport.ts` — tiny hook returning `true` when `window.matchMedia('(max-width: 640px)')` matches (or similar TZ-prescribed mobile breakpoint). Used by Online-Board to compute `-1/+3ч` time default on mobile.
- `src/shared/hooks/useIsMobileViewport.test.ts` — unit tests.
- `src/features/online-board/timeDefaults.ts` — `getOnlineBoardDefaultTimeRange(isMobile, now)` returns `{ timeFrom, timeTo }` as `'HHmm'` strings. Pure function.
- `src/features/online-board/timeDefaults.test.ts` — unit tests.

### Files to modify
- `src/features/online-board/components/OnlineBoardStartPage.tsx` — on first session entry (no stored filter), call `useGeoCityDefault` to set `Город вылета`. Compute `Время рейса` default via `getOnlineBoardDefaultTimeRange(isMobile, now)`.
- `src/features/online-board/components/OnlineBoardFilter.tsx` — apply the same mobile time-range default when the user switches to `Маршрут` mode with no stored value.
- `src/features/schedule/components/ScheduleStartPage.tsx` — on first session entry, call `useGeoCityDefault` for `Город вылета`. Default `Показать расписание на` = current week, `Время вылета` = `00:00-24:00` (desktop-like on all viewports per §4.1.1-R10).
- `src/features/flights-map/components/FlightsMapStartPage.tsx` — refactor to use the shared `useGeoCityDefault` (replace the Map-only hook usage, or delete the Map-only hook once the shared one is in place).
- `src/features/flights-map/hooks/useGeolocationDefault.ts` — delete if no remaining callers (verify via grep first).
- `src/ui/layout/PageTabs.tsx` — verify `title` attributes use the TZ-prescribed tooltips (no code change likely, just verification + test).
- `src/i18n/locales/ru/common.json` — update `SHARED.TAB-BOARD-TOOLTIP` from `"Информация о фактическом выполнении рейсов на ближайшие дни"` to TZ-exact `"Информация о фактическом выполнении рейсов в ближайшие дни"` (preposition drift — `на` → `в`). Flag as a TZ-vs-Angular conflict before changing; see Task 6.
- `src/i18n/locales/en/common.json` — add/verify English tooltip equivalents.
- `src/features/popular-requests/*` — audit Top-4 click-prefill behavior against §4.1.5's 7 modes. Fix gaps.
- `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` — populate §4.1.5, §4.1.6, §4.1.7 rule rows (Task 1); mark Done after merge (Task 11).

### Files reviewed, not modified
- `src/shared/state/crossSectionNavigation.ts` — already handles session-level filter persistence.
- `src/shared/dictionaries/index.ts` — exports `findCityByCoord` used by the geolocation hook.

---

## Pre-flight

- [ ] Branch `redesign/p2-start-pages-first-entry-popular` exists and is checked out.
- [ ] Base: `main` after P1 merge (commit `ef33b55`).
- [ ] `pnpm typecheck && pnpm lint` clean on main before starting.

---

## Task 1: Populate rule enumeration for §4.1.5, §4.1.6, §4.1.7

**Files:**
- Modify: `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md`

§4.1.1 is already populated (R1-R27 from the initial spec write). §4.1.5, §4.1.6, §4.1.7 are skeleton-only. Read the TZ body and enumerate rule rows.

- [ ] **Step 1.1: Read TZ §4.1.5 body**

TZ location: `/tmp/ri_tz_extract/content.txt` lines 406-461 (approximately — locate by `[STYLE=30] Популярные разделы`). Enumerate:
- Data collection: save all Board + Schedule searches with timestamps. Archive/delete after 1 month under load.
- Three aggregation windows: 1-min, 30-min, 1-day. 30-min aggregates from 1-min; 1-day aggregates from 30-min.
- Aggregation cadence: runs only if time since last aggregation > window size.
- Round-trip flag: outbound A-B + return flag → aggregate outbound A-B with return=true, do NOT aggregate reverse direction.
- Top-4 selection from 30-min aggregation over past 24h.
- Grouping: by search-type, carrier, departure, arrival.
- Ordering: by count desc; ties broken per DB.
- Client cache: 10-min TTL on Top-4 fetch.
- Refresh cadence: every 30 min.
- Six display formats: `Номер рейса: {num}`, `Вылет: {city}`, `Прилет: {city}`, `Маршрут: {from} - {to}`, `Расписание туда: {from} - {to}`, `Расписание туда/обратно: {from} - {to}`.
- Seven click-prefill behaviors (one per display format):
  - `Прилет`: arrival mode, city-to = clicked, city-from = "все направления", date=today, time=00:00-24:00 desktop/tablet / -1h+3h mobile. No auto-search.
  - `Вылет`: same pattern, departure mode.
  - `Маршрут`: route mode with both cities.
  - `Номер рейса`: flight-number mode, number + date=today.
  - `Расписание туда`: schedule one-way, cities + current week + 00:00-24:00 + toggles off.
  - `Расписание туда/обратно`: schedule round-trip with return week = next week + showReturn=true.
- Flight-Map data NOT collected for popular-requests aggregation.

Target rule count for §4.1.5: ~30 rows.

- [ ] **Step 1.2: Read TZ §4.1.6 body**

Lines 462-483. Table 8 composition:
- Desktop/tablet layout: left = section tabs + filter (`Маршрут` expanded, `Номер рейса` collapsed); right = breadcrumbs + page name + info area ("Что такое Онлайн-табло и что я могу в нем увидеть?" with four blocks: Актуальная информация / Информация об услугах / Купить билет / Расписание) + "Популярные разделы" section.
- Mobile layout: single-column stack — breadcrumbs, page name, tabs, filter, info area, popular section.
- Note: texts are example-only per TZ, changeable on request; no admin UI for editing them.

Target rule count for §4.1.6: ~10 rows.

- [ ] **Step 1.3: Read TZ §4.1.7 body**

Lines 484-506. Table 9 composition — schedule equivalent:
- Desktop/tablet: left tabs + filter; right = two info blocks:
  1. "Как пользоваться расписанием?" with four items (Маршрут, Дата вылета, Время вылета, Обратные рейсы)
  2. "Возможности расписания" with two items (Купить билет, Расписание).
- Mobile: same stacked layout.
- No popular-requests panel listed in Table 9 — but §4.1.5 says popular requests appear on BOTH Board and Schedule start pages. Spec reconciliation: §4.1.5 governs, Table 9 is incomplete; popular section IS present on Schedule start.
- Same "example texts" disclaimer.

Target rule count for §4.1.7: ~10 rows.

- [ ] **Step 1.4: Append rule rows to spec**

Use column structure: `| # | Rule | TZ cite | Viewport | Current impl | Status | Action | Plan |`. IDs continue from existing: §4.1.5 = R1, R2, ... / §4.1.6 = R1, ... / §4.1.7 = R1, ... (these subsections were skeleton-only).

Set Status = `TBD`, Plan = `P2`, Viewport = `all` (or `mobile` / `desktop, tablet` where TZ differentiates).

- [ ] **Step 1.5: Update Coverage summary**

Bump `Total rules extracted` by the added count. Also update per-subsection counts in the coverage summary table.

- [ ] **Step 1.6: Commit**

```bash
git add docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md
git commit -m "Populate rule rows for P2 subsections 4.1.5/6/7 in TZ audit spec"
```

---

## Task 2: Shared `useGeoCityDefault` hook

**Files:**
- Create: `src/shared/hooks/useGeoCityDefault.ts`
- Create: `src/shared/hooks/useGeoCityDefault.test.ts`

Extracts the Flight-Map-only `useGeolocationDefault` into a generic hook Online-Board + Schedule can reuse. Same one-shot semantics (fires once on mount, silent on denial).

- [ ] **Step 2.1: Write failing test**

```ts
// src/shared/hooks/useGeoCityDefault.test.ts
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGeoCityDefault } from "./useGeoCityDefault.js";
import type { IDictionaries } from "@/shared/dictionaries/index.js";

// Mock dictionaries that returns city "MOW" for any coord.
const mockDictionaries: IDictionaries = {
  cities: [
    { code: "MOW", name: "Москва", lat: 55.7558, lon: 37.6173 },
    { code: "LED", name: "Санкт-Петербург", lat: 59.9311, lon: 30.3609 },
  ],
  airports: [],
  // ...other fields required by IDictionaries — use type assertion if shape is large
} as unknown as IDictionaries;

describe("useGeoCityDefault", () => {
  let getCurrentPositionMock: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    getCurrentPositionMock = vi.fn();
    Object.defineProperty(navigator, "geolocation", {
      value: { getCurrentPosition: getCurrentPositionMock },
      configurable: true,
    });
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("calls onCity with nearest city code when geolocation succeeds and shouldApply returns true", () => {
    getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
      onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
    });
    const onCity = vi.fn();
    renderHook(() =>
      useGeoCityDefault({
        dictionaries: mockDictionaries,
        shouldApply: () => true,
        onCity,
      }),
    );
    expect(onCity).toHaveBeenCalledWith("MOW");
  });

  it("does not call onCity when shouldApply returns false", () => {
    getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
      onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
    });
    const onCity = vi.fn();
    renderHook(() =>
      useGeoCityDefault({
        dictionaries: mockDictionaries,
        shouldApply: () => false,
        onCity,
      }),
    );
    expect(onCity).not.toHaveBeenCalled();
  });

  it("is silent when geolocation permission is denied", () => {
    getCurrentPositionMock.mockImplementation((_s: unknown, onErr: (err: GeolocationPositionError) => void) => {
      onErr({ code: 1, message: "denied" } as GeolocationPositionError);
    });
    const onCity = vi.fn();
    renderHook(() =>
      useGeoCityDefault({
        dictionaries: mockDictionaries,
        shouldApply: () => true,
        onCity,
      }),
    );
    expect(onCity).not.toHaveBeenCalled();
  });

  it("is silent when navigator.geolocation is undefined", () => {
    Object.defineProperty(navigator, "geolocation", { value: undefined, configurable: true });
    const onCity = vi.fn();
    renderHook(() =>
      useGeoCityDefault({
        dictionaries: mockDictionaries,
        shouldApply: () => true,
        onCity,
      }),
    );
    expect(onCity).not.toHaveBeenCalled();
  });

  it("fires only once per mount even if dictionaries change", () => {
    getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
      onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
    });
    const onCity = vi.fn();
    const { rerender } = renderHook(
      ({ dict }) =>
        useGeoCityDefault({
          dictionaries: dict,
          shouldApply: () => true,
          onCity,
        }),
      { initialProps: { dict: mockDictionaries } },
    );
    rerender({ dict: { ...mockDictionaries } as IDictionaries });
    expect(onCity).toHaveBeenCalledTimes(1);
  });

  it("waits for dictionaries to be non-null before computing city", () => {
    getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => {
      onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
    });
    const onCity = vi.fn();
    renderHook(() =>
      useGeoCityDefault({
        dictionaries: null,
        shouldApply: () => true,
        onCity,
      }),
    );
    // Position callback fires but dictionaries is null → onCity NOT called.
    expect(onCity).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2.2: Run test — FAIL**

Run: `pnpm vitest run src/shared/hooks/useGeoCityDefault.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 2.3: Implement**

```ts
// src/shared/hooks/useGeoCityDefault.ts
/**
 * One-shot geolocation → city-code hook. Matches Angular's
 * `UserLocationService.location` semantics used by Online-Board,
 * Schedule, and Flight-Map. Silent on permission denial / missing API.
 *
 * Fires at most once per mount. The `shouldApply` predicate is
 * re-evaluated at callback time (after the geo permission resolves),
 * letting the caller opt out if the user has already entered a value
 * by then.
 */
import { useEffect, useRef } from "react";
import {
  findCityByCoord,
  type IDictionaries,
} from "@/shared/dictionaries/index.js";

export interface UseGeoCityDefaultOptions {
  dictionaries: IDictionaries | null;
  /** Called at callback time; return false to skip applying the city. */
  shouldApply: () => boolean;
  /** Invoked with the IATA city code when geo + dictionaries produce a match. */
  onCity: (cityCode: string) => void;
}

export function useGeoCityDefault(opts: UseGeoCityDefaultOptions): void {
  const appliedRef = useRef(false);
  const optsRef = useRef(opts);
  optsRef.current = opts;

  useEffect(() => {
    if (appliedRef.current) return;
    if (typeof navigator === "undefined" || !navigator.geolocation) {
      appliedRef.current = true;
      return;
    }
    appliedRef.current = true;

    navigator.geolocation.getCurrentPosition(
      (pos) => {
        const { dictionaries, shouldApply, onCity } = optsRef.current;
        if (!dictionaries) return;
        if (!shouldApply()) return;
        const city = findCityByCoord(
          dictionaries,
          pos.coords.latitude,
          pos.coords.longitude,
        );
        if (!city) return;
        onCity(city.code);
      },
      () => {
        // Silent — matches Angular UserLocationService behavior.
      },
      { enableHighAccuracy: false, timeout: 5000 },
    );
  }, []);
}
```

- [ ] **Step 2.4: Run test — PASS**

Run: `pnpm vitest run src/shared/hooks/useGeoCityDefault.test.ts`
Expected: all pass.

- [ ] **Step 2.5: Commit**

```bash
git add src/shared/hooks/useGeoCityDefault.ts src/shared/hooks/useGeoCityDefault.test.ts
git commit -m "Add shared useGeoCityDefault hook (generalized from flights-map)"
```

---

## Task 3: Migrate Flight-Map to the shared hook

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx`
- Delete: `src/features/flights-map/hooks/useGeolocationDefault.ts` + `.test.tsx` (only if no other callers remain).

- [ ] **Step 3.1: Grep for other callers**

Run: `grep -rn "useGeolocationDefault" src/ --include='*.ts' --include='*.tsx'`

If only `FlightsMapStartPage.tsx` and the hook's own files appear, proceed to delete. If any other caller exists, keep the old hook as a thin delegator around `useGeoCityDefault` and migrate that caller later.

- [ ] **Step 3.2: Migrate FlightsMapStartPage**

In `src/features/flights-map/components/FlightsMapStartPage.tsx`:

Replace:
```tsx
import { useGeolocationDefault } from "../hooks/useGeolocationDefault.js";
// ...
useGeolocationDefault(dictionaries, filterState, setFilterState);
```

With:
```tsx
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
// ...
useGeoCityDefault({
  dictionaries,
  shouldApply: () => !filterState.departure && !filterState.arrival,
  onCity: (cityCode) => setFilterState((prev) =>
    prev.departure || prev.arrival ? prev : { ...prev, departure: cityCode },
  ),
});
```

- [ ] **Step 3.3: Run Flight-Map tests**

Run: `pnpm vitest run src/features/flights-map/`
Expected: all pass (existing Flight-Map geolocation tests should continue to hold because the behavior is identical).

If the existing Flight-Map test file `useGeolocationDefault.test.tsx` fails because the hook is gone, delete the test file too.

- [ ] **Step 3.4: Delete old hook if safe**

```bash
rm src/features/flights-map/hooks/useGeolocationDefault.ts
rm src/features/flights-map/hooks/useGeolocationDefault.test.tsx
```

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

Run: `pnpm typecheck && pnpm vitest run`
Expected: clean + all pass.

- [ ] **Step 3.6: Commit**

```bash
git add src/features/flights-map/components/FlightsMapStartPage.tsx
# if the old hook files were deleted, they'll also be in the stage
git commit -m "Migrate flights-map to shared useGeoCityDefault hook"
```

---

## Task 4: `useIsMobileViewport` hook + `getOnlineBoardDefaultTimeRange`

**Files:**
- Create: `src/shared/hooks/useIsMobileViewport.ts`
- Create: `src/shared/hooks/useIsMobileViewport.test.ts`
- Create: `src/features/online-board/timeDefaults.ts`
- Create: `src/features/online-board/timeDefaults.test.ts`

Mobile breakpoint per existing styling: check `src/styles/` for the project's breakpoint tokens first. If `$breakpoint-mobile: 640px` or similar exists, use it. Otherwise fall back to `640px`.

- [ ] **Step 4.1: Check breakpoint token**

Run: `grep -rn "breakpoint-mobile\|@media.*max-width" src/styles/ 2>/dev/null | head -5`

Note the breakpoint used elsewhere. If multiple breakpoints are in use, pick the "mobile phone" one (typically the smallest).

- [ ] **Step 4.2: Write failing test for `useIsMobileViewport`**

```ts
// src/shared/hooks/useIsMobileViewport.test.ts
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useIsMobileViewport } from "./useIsMobileViewport.js";

describe("useIsMobileViewport", () => {
  let matchMediaMock: ReturnType<typeof vi.fn>;
  let listeners: ((e: { matches: boolean }) => void)[];
  let currentMatches = false;

  beforeEach(() => {
    listeners = [];
    currentMatches = false;
    matchMediaMock = vi.fn().mockImplementation((query: string) => ({
      matches: currentMatches,
      media: query,
      addEventListener: (_type: string, listener: (e: { matches: boolean }) => void) => {
        listeners.push(listener);
      },
      removeEventListener: () => {},
      dispatchEvent: () => false,
    }));
    Object.defineProperty(window, "matchMedia", { value: matchMediaMock, configurable: true });
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("returns false when viewport is wider than mobile breakpoint", () => {
    currentMatches = false;
    const { result } = renderHook(() => useIsMobileViewport());
    expect(result.current).toBe(false);
  });

  it("returns true when viewport is narrower than mobile breakpoint", () => {
    currentMatches = true;
    const { result } = renderHook(() => useIsMobileViewport());
    expect(result.current).toBe(true);
  });

  it("updates when media-query match changes", () => {
    currentMatches = false;
    const { result } = renderHook(() => useIsMobileViewport());
    expect(result.current).toBe(false);
    act(() => {
      for (const l of listeners) l({ matches: true });
    });
    expect(result.current).toBe(true);
  });
});
```

- [ ] **Step 4.3: Run test — FAIL**

Run: `pnpm vitest run src/shared/hooks/useIsMobileViewport.test.ts`
Expected: FAIL.

- [ ] **Step 4.4: Implement**

```ts
// src/shared/hooks/useIsMobileViewport.ts
import { useEffect, useState } from "react";

/** Mobile breakpoint — matches the project's `$breakpoint-mobile` token. */
const MOBILE_BREAKPOINT_PX = 640;

/**
 * Returns `true` when the viewport is ≤ the mobile breakpoint. Used to
 * branch TZ-prescribed mobile-only behaviors (e.g. Online-Board's
 * `Время рейса` default = -1h/+3h per §4.1.1-R5).
 *
 * SSR: returns `false` initially, then updates on first client render.
 */
export function useIsMobileViewport(): boolean {
  const [isMobile, setIsMobile] = useState<boolean>(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`).matches;
  });

  useEffect(() => {
    if (typeof window === "undefined") return;
    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
    const listener = (e: MediaQueryListEvent | { matches: boolean }) => setIsMobile(e.matches);
    setIsMobile(mql.matches);
    mql.addEventListener("change", listener as (e: MediaQueryListEvent) => void);
    return () => mql.removeEventListener("change", listener as (e: MediaQueryListEvent) => void);
  }, []);

  return isMobile;
}
```

- [ ] **Step 4.5: Run test — PASS**

Run: `pnpm vitest run src/shared/hooks/useIsMobileViewport.test.ts`
Expected: PASS.

- [ ] **Step 4.6: Write failing test for `getOnlineBoardDefaultTimeRange`**

```ts
// src/features/online-board/timeDefaults.test.ts
import { describe, expect, it } from "vitest";
import { getOnlineBoardDefaultTimeRange } from "./timeDefaults.js";

describe("4.1.1-R4 + R5: Online-Board default time range", () => {
  it("desktop/tablet default = 00:00-24:00 regardless of time", () => {
    expect(getOnlineBoardDefaultTimeRange(false, new Date(2026, 4, 15, 9, 0))).toEqual({
      timeFrom: "0000",
      timeTo: "2400",
    });
    expect(getOnlineBoardDefaultTimeRange(false, new Date(2026, 4, 15, 22, 30))).toEqual({
      timeFrom: "0000",
      timeTo: "2400",
    });
  });

  it("mobile default = -1h/+3h from user's current hour", () => {
    // 9:00 → -1h = 8:00, +3h = 12:00
    expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 9, 0))).toEqual({
      timeFrom: "0800",
      timeTo: "1200",
    });
  });

  it("mobile default clamps to 00:00 when current hour < 1", () => {
    // 00:30 → -1h clamps to 00:00, +3h = 03:30 (but we drop minutes and use 0330)
    expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 0, 30))).toEqual({
      timeFrom: "0000",
      timeTo: "0330",
    });
  });

  it("mobile default clamps to 24:00 when current hour > 21", () => {
    // 22:15 → -1h = 21:15, +3h = 25:15 → clamp to 24:00
    expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 22, 15))).toEqual({
      timeFrom: "2115",
      timeTo: "2400",
    });
  });

  it("mobile default preserves minutes in HHmm format", () => {
    expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 14, 37))).toEqual({
      timeFrom: "1337",
      timeTo: "1737",
    });
  });
});
```

- [ ] **Step 4.7: Run test — FAIL**

Run: `pnpm vitest run src/features/online-board/timeDefaults.test.ts`
Expected: FAIL.

- [ ] **Step 4.8: Implement**

```ts
// src/features/online-board/timeDefaults.ts
/**
 * Online-Board `Время рейса` default time range per TZ §4.1.1-R4 and R5.
 *
 * Desktop/tablet: always `00:00-24:00`.
 * Mobile: [-1h, +3h] window centered on the user's current local time,
 * clamped to [00:00, 24:00].
 *
 * Output uses `HHmm` strings (e.g. "0800" = 08:00). `2400` represents
 * end-of-day inclusive, matching existing URL encoding.
 */

export interface TimeRange {
  timeFrom: string;
  timeTo: string;
}

export function getOnlineBoardDefaultTimeRange(
  isMobile: boolean,
  now: Date = new Date(),
): TimeRange {
  if (!isMobile) {
    return { timeFrom: "0000", timeTo: "2400" };
  }

  const nowMinutes = now.getHours() * 60 + now.getMinutes();
  const fromMinutes = Math.max(0, nowMinutes - 60);
  const toMinutes = Math.min(24 * 60, nowMinutes + 3 * 60);

  return {
    timeFrom: minutesToHHmm(fromMinutes),
    timeTo: minutesToHHmm(toMinutes),
  };
}

function minutesToHHmm(total: number): string {
  // 24:00 is the magic end-of-day value; TZ URL format accepts it.
  if (total === 24 * 60) return "2400";
  const h = Math.floor(total / 60);
  const m = total % 60;
  return `${String(h).padStart(2, "0")}${String(m).padStart(2, "0")}`;
}
```

- [ ] **Step 4.9: Run test — PASS**

Run: `pnpm vitest run src/features/online-board/timeDefaults.test.ts`
Expected: PASS.

- [ ] **Step 4.10: Commit**

```bash
git add src/shared/hooks/useIsMobileViewport.ts src/shared/hooks/useIsMobileViewport.test.ts src/features/online-board/timeDefaults.ts src/features/online-board/timeDefaults.test.ts
git commit -m "Add useIsMobileViewport hook + Online-Board mobile time defaults per TZ 4.1.1-R4/R5"
```

---

## Task 5: Wire geolocation + mobile time default into Online-Board start page

**Files:**
- Modify: `src/features/online-board/components/OnlineBoardStartPage.tsx`
- Modify: `src/features/online-board/components/OnlineBoardFilter.tsx` (if mobile time default needs to be applied on `Маршрут` mode switch too).
- Test: `src/features/online-board/components/OnlineBoardStartPage.test.tsx`

- [ ] **Step 5.1: Add failing test**

Test that on mount, when no Board filter is stored AND geolocation resolves, the filter's `departure` is populated with the detected city code.

```tsx
it("4.1.1-R1: first-entry populates departure from geolocation", async () => {
  resetCrossSectionStore();
  const getCurrentPositionMock = vi.fn().mockImplementation(
    (onSuccess: (pos: GeolocationPosition) => void) => {
      onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition);
    },
  );
  Object.defineProperty(navigator, "geolocation", {
    value: { getCurrentPosition: getCurrentPositionMock },
    configurable: true,
  });

  render(<OnlineBoardStartPage />);
  await waitFor(() => {
    const departure = screen.getByTestId("online-board-filter-departure");
    expect(departure).toHaveValue("Москва");
  });
});

it("4.1.1-R5: mobile viewport uses -1h/+3h time default on Маршрут mode", async () => {
  // Mock useIsMobileViewport to return true
  // Mock clock to a known time
  // Render, switch to Маршрут mode, assert time slider values
});
```

(Adapt selectors to the actual OnlineBoardFilter test patterns. If the filter component doesn't expose the "Маршрут mode" toggle by testid, use one of the existing text-based selectors.)

- [ ] **Step 5.2: Run tests — FAIL**

Run: `pnpm vitest run src/features/online-board/components/OnlineBoardStartPage.test.tsx`
Expected: new tests FAIL.

- [ ] **Step 5.3: Wire geolocation into OnlineBoardStartPage**

Add to the component:

```tsx
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
import { useIsMobileViewport } from "@/shared/hooks/useIsMobileViewport.js";
import { getOnlineBoardDefaultTimeRange } from "../timeDefaults.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";

// inside the component:
const { dictionaries } = useDictionaries();
const isMobile = useIsMobileViewport();

useGeoCityDefault({
  dictionaries,
  shouldApply: () => {
    // Only apply if we have no stored board filter and no current departure.
    return !getBoardFilter() && !filterState.departure;
  },
  onCity: (cityCode) => {
    setFilterState((prev) =>
      prev.departure ? prev : { ...prev, departure: cityCode },
    );
  },
});
```

And for mobile time defaults, adjust the initial filter-state computation:

```tsx
// Where initial filter state is computed (useState initializer), use:
const initialTimeRange = useRef(getOnlineBoardDefaultTimeRange(isMobile)).current;
// Then set timeFrom/timeTo from that.
```

The exact wiring depends on how `OnlineBoardStartPage` currently structures state — adapt to the existing pattern. Don't rewrite; layer the defaults on top.

- [ ] **Step 5.4: Run tests — PASS**

Run: `pnpm vitest run src/features/online-board/`
Expected: all pass, including new tests and existing.

- [ ] **Step 5.5: Commit**

```bash
git add src/features/online-board/
git commit -m "Wire first-entry geolocation + mobile time default into Online-Board start page (TZ 4.1.1)"
```

---

## Task 6: Wire geolocation into Schedule start page

**Files:**
- Modify: `src/features/schedule/components/ScheduleStartPage.tsx`
- Test: `src/features/schedule/components/ScheduleStartPage.test.tsx`

Per §4.1.1-R8 — Schedule first-entry fills `Город вылета` from geo. §4.1.1-R10 — `Время вылета` default `00:00-24:00` on ALL viewports (mobile like desktop, unlike Board).

- [ ] **Step 6.1: Add failing test**

```tsx
it("4.1.1-R8: Schedule first-entry populates departure from geolocation", async () => {
  resetCrossSectionStore();
  // same geolocation mock as Task 5.1
  render(<ScheduleStartPage />);
  await waitFor(() => {
    const departure = screen.getByTestId("schedule-filter-departure");
    expect(departure).toHaveValue("Москва");
  });
});

it("4.1.1-R10: Schedule uses 00:00-24:00 on all viewports (mobile same as desktop)", async () => {
  // Mock useIsMobileViewport to return true
  render(<ScheduleStartPage />);
  // Assert time slider values are 00:00 and 24:00, NOT a -1h/+3h range.
});
```

- [ ] **Step 6.2: Run tests — FAIL**

Run: `pnpm vitest run src/features/schedule/components/ScheduleStartPage.test.tsx`
Expected: FAIL.

- [ ] **Step 6.3: Wire geolocation**

```tsx
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";

useGeoCityDefault({
  dictionaries,
  shouldApply: () => !getScheduleFilter() && !filterState.departure,
  onCity: (cityCode) => {
    setFilterState((prev) =>
      prev.departure ? prev : { ...prev, departure: cityCode },
    );
  },
});
```

Do NOT apply a mobile-specific time override — Schedule time default stays `00:00-24:00` per §4.1.1-R10.

- [ ] **Step 6.4: Run tests — PASS**

Run: `pnpm vitest run src/features/schedule/`
Expected: all PASS.

- [ ] **Step 6.5: Commit**

```bash
git add src/features/schedule/
git commit -m "Wire first-entry geolocation into Schedule start page (TZ 4.1.1-R8)"
```

---

## Task 7: Verify + fix section-tab tooltips per TZ

**Files:**
- Verify: `src/ui/layout/PageTabs.tsx`
- Modify: `src/i18n/locales/ru/common.json`
- Verify: `src/i18n/locales/en/common.json`

TZ-prescribed tooltips (from `4.1` opening ¶):
- Online-Board: `"Информация о фактическом выполнении рейсов в ближайшие дни"`
- Schedule: `"Информация о планируемом выполнении рейсов на ближайший год"`
- Flight-Map: `"Картографический сервис представления информации об маршрутной сети «Аэрофлот»"`

Current `ru` values (grep'd during plan writing):
- Board: `"Информация о фактическом выполнении рейсов **на** ближайшие дни"` ← note: `на`, TZ says `в`.
- Schedule: `"Информация о планируемом выполнении рейсов на ближайший год"` ← matches.
- Flight-Map: `"Картографический сервис представления информации об маршрутной сети «Аэрофлот»"` ← matches.

- [ ] **Step 7.1: Flag as TZ-vs-Angular conflict if Angular shows `на`**

This is minor copy drift. Per Q2=C arbitration policy: add to Conflicts register in the spec with resolution = "adopt TZ-exact Russian preposition `в`". Document in commit message.

- [ ] **Step 7.2: Update Russian locale**

Change `SHARED.TAB-BOARD-TOOLTIP` value in `src/i18n/locales/ru/common.json`:
```diff
-"TAB-BOARD-TOOLTIP": "Информация о фактическом выполнении рейсов на ближайшие дни",
+"TAB-BOARD-TOOLTIP": "Информация о фактическом выполнении рейсов в ближайшие дни",
```

- [ ] **Step 7.3: Verify English locale has tooltips**

Check `src/i18n/locales/en/common.json`. If `SHARED.TAB-BOARD-TOOLTIP` / `TAB-SCHEDULE-TOOLTIP` / `TAB-FLIGHTS-MAP-TOOLTIP` are missing or empty, add sensible English equivalents.

- [ ] **Step 7.4: Add an assertion test**

In a PageTabs test (create `src/ui/layout/PageTabs.test.tsx` if missing):

```tsx
it("4.1-tooltips: Board tab title matches TZ-prescribed Russian tooltip", () => {
  render(<PageTabs viewType="onlineboard" showFlightsMap />);
  const boardTab = screen.getByTestId("onlineboard-tab");
  expect(boardTab).toHaveAttribute("title", "Информация о фактическом выполнении рейсов в ближайшие дни");
});
// Similar for Schedule + Flight-Map.
```

(If i18next stubs translate to the key itself in tests, adapt accordingly — the goal is to lock in the TZ wording when the real Russian locale is loaded.)

- [ ] **Step 7.5: Run tests**

Run: `pnpm vitest run src/ui/layout/PageTabs.test.tsx`
Expected: PASS.

- [ ] **Step 7.6: Commit**

```bash
git add src/i18n/locales/ru/common.json src/i18n/locales/en/common.json src/ui/layout/PageTabs.test.tsx
git commit -m "Align Board tab tooltip preposition to TZ-exact 'в ближайшие дни' (minor copy drift)"
```

---

## Task 8: Audit Popular-requests Top-4 click-prefill per TZ §4.1.5

**Files:**
- Inspect: `src/features/popular-requests/components/PopularRequestItem.tsx`
- Modify as needed: `src/features/popular-requests/*`
- Test: `src/features/popular-requests/components/*.test.tsx`

Per TZ §4.1.5, clicking each of the 7 Top-4 display formats should hydrate the target filter with specific defaults:

| Format | Target section | Filter prefill |
|--------|---------------|----------------|
| `Прилет: {city}` | Board arrival mode | city-to = clicked, city-from = "все направления", date = today, time = 00:00-24:00 (desktop) / -1h+3h (mobile) |
| `Вылет: {city}` | Board departure mode | city-from = clicked, city-to = "все направления", same date/time |
| `Маршрут: {from}-{to}` | Board route mode | both cities, same date/time |
| `Номер рейса: {num}` | Board flight-number mode | number, date = today |
| `Расписание туда: {from}-{to}` | Schedule route | cities, показать расписание на = текущая неделя, toggles off, time = 00:00-24:00 |
| `Расписание туда/обратно: {from}-{to}` | Schedule route+round-trip | cities, current week, showReturn=true, return date = next week, all times 00:00-24:00 |

For all 7: search is NOT auto-executed.

- [ ] **Step 8.1: Inspect current click behavior**

Read `PopularRequestItem.tsx` and the prefill helper (likely in `src/features/online-board/components/OnlineBoardStartPage.tsx` exports — `buildOnlineBoardPrefillState`). Trace how each popular-request kind maps to a filter prefill.

- [ ] **Step 8.2: Enumerate the 7 kinds and test coverage**

For each of the 7 kinds above, verify a component test exists asserting the post-click filter state. If any kind is missing a test or behaves differently from TZ, note it.

- [ ] **Step 8.3: Write failing tests for gaps**

For any uncovered / incorrect kind, add a test following the pattern:

```tsx
it("4.1.5-R<n>: 'Расписание туда/обратно' click prefills round-trip with return date = next week", () => {
  // Click a 'Расписание туда/обратно' popular tile for MOW-LED.
  // Assert: filter state has departure=MOW, arrival=LED, dateFrom=<Mon of this week>, dateTo=<Sun of this week>,
  //         showReturn=true, returnDateFrom=<Mon of next week>, returnDateTo=<Sun of next week>,
  //         onlyDirect=false, time=00:00-24:00.
});
```

- [ ] **Step 8.4: Fix behavior gaps**

If a kind doesn't behave per TZ, fix it in the prefill builder. Add inline comments citing the TZ rule ID.

- [ ] **Step 8.5: Verify auto-search is NOT triggered**

For each kind, assert that after the click + prefill, no network request / navigation to results page happens — the user stays on the start page with the filter populated.

- [ ] **Step 8.6: Run tests**

Run: `pnpm vitest run src/features/popular-requests/ src/features/online-board/components/OnlineBoardStartPage.test.tsx src/features/schedule/components/ScheduleStartPage.test.tsx`
Expected: all PASS.

- [ ] **Step 8.7: Commit**

```bash
git add src/features/popular-requests/ src/features/online-board/ src/features/schedule/
git commit -m "Audit Popular-requests Top-4 click-prefill against TZ 4.1.5 (7 kinds)"
```

---

## Task 9: Flight-Map first-entry toggle defaults per §4.1.1 ¶4

**Files:**
- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` (or the filter component).
- Test: `src/features/flights-map/components/FlightsMapFilter.test.tsx`

Per TZ §4.1.1-R13 through R16 — Flight-Map first-entry with geo consent:
- `Город вылета` = nearest airport (Task 3's shared hook already handles this).
- `Город прилета` placeholder = `"Куда"`.
- Toggles: `Внутренние рейсы` = ON, `Международные регулярные рейсы` = ON, `Показать рейсы с пересадкой` = OFF.
- `Дата рейса` = today.
- Map shows "паутинка" (spider) from departure: direct lines if direct+connecting exist, connecting lines if only connecting, full-network dots otherwise.

Without geo consent (§4.1.1-R21): placeholders `Откуда`/`Куда`, all 3 toggles OFF, date placeholder, map shows dots.

- [ ] **Step 9.1: Inspect current Flight-Map defaults**

Open `FlightsMapStartPage.tsx` + `FlightsMapFilter.tsx` + the filter-state types. Note the current initial values for all 4 toggles (internal / international / transfers / connecting?) and the default `Дата рейса`.

- [ ] **Step 9.2: Write failing tests**

```tsx
it("4.1.1-R14: Flight-Map with geo consent — internal + international ON, transfers OFF", async () => {
  // Mock geolocation to succeed.
  render(<FlightsMapStartPage />);
  await waitFor(() => {
    expect(screen.getByTestId("flights-map-toggle-internal")).toBeChecked();
    expect(screen.getByTestId("flights-map-toggle-international")).toBeChecked();
    expect(screen.getByTestId("flights-map-toggle-transfers")).not.toBeChecked();
  });
});

it("4.1.1-R21: Flight-Map without geo consent — all 3 toggles OFF", async () => {
  // Mock geolocation to fail/deny.
  render(<FlightsMapStartPage />);
  await waitFor(() => {
    expect(screen.getByTestId("flights-map-toggle-internal")).not.toBeChecked();
    expect(screen.getByTestId("flights-map-toggle-international")).not.toBeChecked();
    expect(screen.getByTestId("flights-map-toggle-transfers")).not.toBeChecked();
  });
});
```

(Testids and component names are illustrative — adapt to what exists.)

- [ ] **Step 9.3: Run tests — FAIL or PASS**

If the current defaults already match TZ, tests pass and no code change is needed. If not, fix the defaults.

- [ ] **Step 9.4: Fix as needed**

Update initial `filterState` in `FlightsMapStartPage.tsx`:
- With geo success: `showInternal: true, showInternational: true, showTransfers: false`.
- Without: all three `false`.

The branch is in the `onCity` callback (geo resolved) vs the `useState` initializer default (no geo / pending).

- [ ] **Step 9.5: Commit**

```bash
git add src/features/flights-map/
git commit -m "Align Flight-Map first-entry toggle defaults with TZ 4.1.1-R14/R21"
```

---

## Task 10: Start-page content blocks per TZ Table 8 + Table 9

**Files:**
- Verify: `src/features/online-board/components/OnlineBoardStartPage.tsx`
- Verify: `src/features/schedule/components/ScheduleStartPage.tsx`
- Modify as needed: the same files + i18n

TZ Table 8 — Online-Board start page right-side content:
- Heading: `"Что такое Онлайн-табло и что я могу в нем увидеть?"`
- 4 info blocks: Актуальная информация / Информация об услугах / Купить билет / Расписание (each with an icon + title + description).
- Below: heading `"Популярные разделы Онлайн-Табло и Расписания"` + the popular panel.

TZ Table 9 — Schedule start page:
- Heading 1: `"Как пользоваться расписанием?"` — 4 blocks: Маршрут / Дата вылета / Время вылета / Обратные рейсы.
- Heading 2: `"Возможности расписания"` — 2 blocks: Купить билет / Расписание.

- [ ] **Step 10.1: Inspect current start-page content**

Does `OnlineBoardStartPage.tsx` render the "Что такое Онлайн-табло..." heading + 4 info blocks? Does `ScheduleStartPage.tsx` render both schedule headings + 6 blocks total?

- [ ] **Step 10.2: Assert content presence**

Add tests:

```tsx
it("4.1.6-R-info: Online-Board start page shows the TZ Table 8 info section", () => {
  render(<OnlineBoardStartPage />);
  expect(screen.getByText(/Что такое Онлайн-табло/)).toBeInTheDocument();
  expect(screen.getByText(/Актуальная информация/)).toBeInTheDocument();
  expect(screen.getByText(/Купить билет/)).toBeInTheDocument();
});

it("4.1.7-R-info: Schedule start page shows both TZ Table 9 info sections", () => {
  render(<ScheduleStartPage />);
  expect(screen.getByText(/Как пользоваться расписанием/)).toBeInTheDocument();
  expect(screen.getByText(/Возможности расписания/)).toBeInTheDocument();
});
```

- [ ] **Step 10.3: Fix gaps**

If any heading or block is missing, add it. Use i18n keys — don't hardcode Russian strings in components. Keys likely exist from phase-1 (`START-PAGE.*` or `ONBOARD.*`).

- [ ] **Step 10.4: Run tests**

Run: `pnpm vitest run src/features/online-board/components/OnlineBoardStartPage.test.tsx src/features/schedule/components/ScheduleStartPage.test.tsx`
Expected: all PASS.

- [ ] **Step 10.5: Commit**

```bash
git add src/features/online-board/ src/features/schedule/ src/i18n/
git commit -m "Verify start-page info-section content per TZ Table 8 + Table 9"
```

---

## Task 11: Update spec with P2 completion

**Files:**
- Modify: `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md`

- [ ] **Step 11.1: Mark P2 rules as Done**

For §4.1.1 (R1-R27), §4.1.5, §4.1.6, §4.1.7 — change Status from TBD to `Done <7-char-SHA>` citing the relevant P2 commit.

- [ ] **Step 11.2: Add Conflicts register entries**

New conflicts found in P2:
- **Board tab tooltip preposition** — TZ says `в ближайшие дни`, Angular/pre-existing impl had `на ближайшие дни`. Resolution: adopted TZ-exact form. Commit: whichever of Task 7's commits.
- Any other Angular-vs-TZ drifts encountered during the audit.

- [ ] **Step 11.3: Update Coverage summary + Merge log**

Add merge-log row:
```markdown
| 2026-04-22 | P2 | Start pages + first-entry geo + popular sections | <first-sha>..<last-sha> | N rules done |
```

- [ ] **Step 11.4: Commit**

```bash
git add docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md
git commit -m "Mark P2 (start pages + first-entry + popular) rules Done in TZ audit spec"
```

---

## Task 12: Verify + merge gate

- [ ] **Step 12.1: Run full verification**

```bash
pnpm typecheck
pnpm lint
pnpm vitest run
pnpm test:e2e tests/e2e/p1-urls-nav.spec.ts
```

Expected: all pass. (Note: no new e2e spec for P2 — geolocation flows are unit-tested with mocked `navigator.geolocation`; e2e would require real browser geo permissions which is flaky in CI.)

- [ ] **Step 12.2: Invoke `superpowers:requesting-code-review`**

- [ ] **Step 12.3: Invoke `superpowers:finishing-a-development-branch`**

After review passes, merge `redesign/p2-start-pages-first-entry-popular` to main via fast-forward.

- [ ] **Step 12.4: Update spec Done SHAs after merge**

If rebase changed SHAs, amend spec's Done markers + Merge log row to reflect final SHAs.

---

## Self-Review

**1. Spec coverage.** Rules covered:
- §4.1.1 (R1-R27) → geo-detection hook (Task 2), Board wiring (Task 5), Schedule wiring (Task 6), Flight-Map wiring (Task 3 + Task 9), mobile time-default (Task 4).
- §4.1.5 → populated in Task 1, Top-4 click-prefill audited in Task 8.
- §4.1.6 → start-page content in Task 10, tooltip in Task 7.
- §4.1.7 → start-page content in Task 10, tooltip in Task 7.

**2. Placeholder scan.** No TBD/TODO/"implement later" in code steps. Some "adapt to existing patterns" language in Task 5 and Task 8 — intentional because the current filter component structure differs across Online-Board and Schedule and the wiring points need to be identified in situ.

**3. Type consistency.**
- `UseGeoCityDefaultOptions` + `useGeoCityDefault` used consistently in Tasks 2, 3, 5, 6.
- `useIsMobileViewport` returns `boolean`, used in Task 4's helpers and Task 5 wiring.
- `getOnlineBoardDefaultTimeRange` returns `TimeRange = { timeFrom: string; timeTo: string }`, consumed in Task 5.

**Known follow-ups** not in P2 scope:
- Popular-requests backend aggregation (§4.1.5 server-side rules — 1-min/30-min/1-day aggregation, storage policy). Backend scope, not this repo.
- Localized labels for popular-request prefixes in 7 non-priority locales (pre-existing empty-strings pattern).

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-04-21-p2-start-pages-first-entry-popular.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks.

**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints.

Which approach?
