diff --git a/docs/superpowers/plans/2026-04-21-p2-start-pages-first-entry-popular.md b/docs/superpowers/plans/2026-04-21-p2-start-pages-first-entry-popular.md new file mode 100644 index 00000000..fe9c6a24 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-p2-start-pages-first-entry-popular.md @@ -0,0 +1,1137 @@ +# 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; + + 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; + 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(() => { + 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(); + 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(); + 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(); + // 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(); + 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: 'Расписание туда/обратно' 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=, dateTo=, + // showReturn=true, returnDateFrom=, returnDateTo=, + // 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(); + 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(); + 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(); + 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(); + 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 | .. | 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?