diff --git a/docs/superpowers/plans/2026-04-21-p1-urls-breadcrumbs-names-nav.md b/docs/superpowers/plans/2026-04-21-p1-urls-breadcrumbs-names-nav.md new file mode 100644 index 00000000..b020ebe2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-p1-urls-breadcrumbs-names-nav.md @@ -0,0 +1,1932 @@ +# P1 — URLs, Breadcrumbs, Page Names, Cross-Section Nav 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 the external `Онлайн-Табло / Расписание / Карта полетов` section of `Aeroflot.Flights.Web` into compliance with TZ РИ-07-2538С subsections 4.1.2 (URLs), 4.1.3 (page names), 4.1.4 (breadcrumbs), and 4.1.8 (cross-section navigation). + +**Architecture:** The URL serializer/parser modules (`src/features/online-board/url.ts` + `src/features/schedule/url.ts`) and SEO builders (`src/features/*/seo.ts`) already exist from phase-1. This plan **audits** them against the TZ tables, closes concrete gaps, and adds the shared cross-section-navigation state glue + `?request=` query-param handling on details URLs. No new architectural patterns; all work uses existing modules and existing i18n keys where possible. + +**Tech Stack:** TypeScript, React 18, Modern.js router, Vitest + React Testing Library, Playwright for cross-section e2e, `i18next` for titles/labels. + +**Parent spec:** `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` (commit `8e84c41`). + +**Rule IDs covered:** 4.1.2-R1…R12, 4.1.3-R1…R6, 4.1.4-R1…R4, 4.1.8-R1…R5. Additional rules enumerated during Task 1. + +--- + +## File Structure + +### Files to create +- `src/shared/dateWindow.ts` — centralized date-window constants (Board `-1/+14`, Schedule `-1/+330`, Map `-1/+6mo`) + `isInBoardWindow`/`isInScheduleWindow`/`isInMapWindow` helpers. +- `src/shared/dateWindow.test.ts` — unit tests. +- `src/shared/detailsRequestParam.ts` — encode/decode helpers for the `?request=` query param on details URLs per TZ Table 5 row 6 (handles `route`, `departure`, `arrival`, `flight` kinds). +- `src/shared/detailsRequestParam.test.ts` — unit tests. +- `src/shared/state/crossSectionNavigation.ts` — small store that holds the last-known filter snapshot for Online-Board, Schedule, Flight-Map independently; drives TZ Table 10. +- `src/shared/state/crossSectionNavigation.test.ts` — unit tests. +- `e2e/p1-urls-nav.spec.ts` — Playwright spec covering deep-link entry + cross-section nav. +- `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-mockup-inventory.md` — mockup→subsection map for future plans. + +### Files to modify +- `src/features/online-board/url.ts` — extend `buildOnlineBoardUrl` to accept an optional `request` object that serializes to `?request=...` per TZ Table 5 row 6; extend `parseOnlineBoardUrl` (or add separate `parseDetailsRequestParam`) to round-trip the query param. +- `src/features/online-board/index.ts` — re-export any new URL helpers. +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — extend `parentRequest` memo to handle `route` and `flight` kinds (currently only `departure` + `arrival`); fix breadcrumb trail to match TZ Table 7 (add mode-specific leaf crumb with click-to-restore-search behavior); verify all title callsites route through `buildFlightDetailsSeo` with the TZ-prescribed format. +- `src/features/online-board/components/OnlineBoardSearchPage.tsx` — update breadcrumbs per TZ Table 7 rows 2-5. +- `src/features/online-board/components/OnlineBoardStartPage.tsx` — verify breadcrumbs = `[Главная]` only (TZ Table 7 row 1). +- `src/features/schedule/components/ScheduleStartPage.tsx` — centralize `+330` via `dateWindow`; verify breadcrumbs per TZ Table 7 row 9. +- `src/features/schedule/components/ScheduleFilter.tsx` — centralize `+330` via `dateWindow`. +- `src/features/schedule/components/ScheduleSearchPage.tsx` — breadcrumbs per TZ Table 7 row 10. +- `src/features/schedule/components/ScheduleDetailsPage.tsx` — breadcrumbs per TZ Table 7 row 11; TZ-compliant title via `buildScheduleDetailsSeo` (direct/multi-segment uses "Расписание рейса:", connecting uses "Расписание рейсов:"). +- `src/features/flights-map/components/FlightsMapStartPage.tsx` — breadcrumbs per TZ Table 7 (Карта полетов) — single `[Главная]` crumb only. +- `src/features/flights-map/calendarRange.ts` — centralize `-1/+6mo` via `dateWindow`. +- `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` — date-window guard (redirect to `/{lang}/onlineboard` if date outside `-1/+14`). +- `src/routes/[lang]/onlineboard/route/[params]/page.tsx` — same guard. +- `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` — same guard. +- `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` — same guard. +- `src/routes/[lang]/onlineboard/[params]/page.tsx` (details) — same guard. +- `src/routes/[lang]/schedule/route/[params]/page.tsx` — date-window guard for Schedule `-1/+330`. +- `src/i18n/*.json` (9 locales) — add/update title keys to match TZ Table 6 if drift found: + - `SEO.BOARD.FLIGHT-DETAILS.TITLE` → `"Информация о рейсе: {{flightNumber}}, {{routeCities}}"` (currently embeds date — verify against TZ) + - `SEO.SCHEDULE.FLIGHT-DETAILS.TITLE` → `"Расписание рейса: {{flightNumber}}, {{routeCities}}"` for direct/multi-segment; `"Расписание рейсов: {{flightNumbers}}"` for connecting (split into two keys if needed) +- `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` — update rule statuses + append merge log row after merge. + +### Files reviewed, not modified +- `src/ui/layout/Breadcrumbs.tsx` — already a11y-correct (`aria-current="page"` on last crumb); no changes needed. +- `src/ui/seo/SeoHead.tsx` — renders all required `` fragments. + +--- + +## Pre-flight check + +Before starting, confirm: +- [ ] On branch `redesign/ri-07-2538c-online-board-schedule-ui`. +- [ ] Spec `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` is committed (expected commit `8e84c41`). +- [ ] Main compiles clean: `pnpm typecheck && pnpm lint`. +- [ ] Existing tests pass: `pnpm test -- url.test calendarRange.test seo.test` (sample). + +--- + +## Task 1: Populate full P1 rule enumeration in the spec + +**Files:** +- Modify: `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` + +Goal: expand the four P1 subsection tables (4.1.2, 4.1.3, 4.1.4, 4.1.8) with every rule found in the TZ body, so every later task has a rule-ID to cite in test names. + +- [ ] **Step 1.1: Read TZ §4.1.2 body** + +Command: open `/tmp/ri_tz_extract/content.txt` lines 326-355 and enumerate every URL pattern in Table 5 as a separate rule row. For **each** row of Table 5, extract: +- Area (Online-Board / Schedule / Flight-Map) +- Page type (start / flight-number / route / departure / arrival / details / refresh / share) +- URL pattern (verbatim with placeholders) +- Example URL (verbatim) + +Write each as a rule row in the spec's `# 4.1.2` table. Use format `4.1.2-R{n+1}` starting after R12. + +Expected rule count for 4.1.2 body: ≈30 rows (8 board + 5 schedule + 4 map + format-detail rules like flight-number padding, time-range suffix, connections suffix `-C0`/`-C1`, round-trip second leg slashing). + +- [ ] **Step 1.2: Read TZ §4.1.3 body** + +Command: open `/tmp/ri_tz_extract/content.txt` lines 356-380 and enumerate every page-title from Table 6. Add rows to the `# 4.1.3` table. + +Expected rule count for 4.1.3: ≈15 rows (8 board + 5 schedule + 3 map + "date display as сегодня/завтра/ДД.ММ.ГГГГ" + "long city name wraps to next line"). + +- [ ] **Step 1.3: Read TZ §4.1.4 body** + +Command: open `/tmp/ri_tz_extract/content.txt` lines 381-405 and enumerate every breadcrumb trail from Table 7. Add rows to the `# 4.1.4` table. Each row should include the crumb list with placeholders. + +Expected rule count for 4.1.4: ≈15 rows + the "last-crumb click restores previous search page" behavior + time-interval-preservation rule (from Table 7 row 6 note: "Если поиск был осуществлен без времени... без временного периода / с временным периодом"). + +- [ ] **Step 1.4: Read TZ §4.1.8 body** + +Command: open `/tmp/ri_tz_extract/content.txt` lines 507-523 and enumerate every row of Table 10 as a rule row. There are 7 rows covering: +- Geo-consent ON, 4 combinations of filled/unfilled + searched/not-searched +- Geo-consent OFF, 3 combinations + +Add rows to the `# 4.1.8` table. + +Expected rule count for 4.1.8: ≈10 rows. + +- [ ] **Step 1.5: Update coverage summary** + +In the spec's "Coverage summary" section, set `Total rules extracted` to the new count. + +- [ ] **Step 1.6: Commit** + +```bash +git add docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md +git commit -m "Populate full rule enumeration for P1 subsections 4.1.2/3/4/8 in TZ audit spec" +``` + +--- + +## Task 2: Centralized date-window constants + +**Files:** +- Create: `src/shared/dateWindow.ts` +- Test: `src/shared/dateWindow.test.ts` + +- [ ] **Step 2.1: Write failing test** + +```ts +// src/shared/dateWindow.test.ts +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + BOARD_WINDOW_DAYS_BACK, + BOARD_WINDOW_DAYS_FORWARD, + SCHEDULE_WINDOW_DAYS_BACK, + SCHEDULE_WINDOW_DAYS_FORWARD, + MAP_WINDOW_DAYS_BACK, + MAP_WINDOW_MONTHS_FORWARD, + isInBoardWindow, + isInScheduleWindow, + isInMapWindow, + boardWindowBounds, + scheduleWindowBounds, + mapWindowBounds, +} from "./dateWindow.js"; + +describe("dateWindow constants", () => { + it("exports the TZ-defined numerical bounds", () => { + // TZ §4.1.2: Board [-1, +14] + expect(BOARD_WINDOW_DAYS_BACK).toBe(1); + expect(BOARD_WINDOW_DAYS_FORWARD).toBe(14); + // TZ §4.1.2: Schedule [-1, +330] + expect(SCHEDULE_WINDOW_DAYS_BACK).toBe(1); + expect(SCHEDULE_WINDOW_DAYS_FORWARD).toBe(330); + // TZ §4.1.2: Map [-1, +6mo] + expect(MAP_WINDOW_DAYS_BACK).toBe(1); + expect(MAP_WINDOW_MONTHS_FORWARD).toBe(6); + }); +}); + +describe("dateWindow helpers (clock frozen at 2026-05-15)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-15T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + describe("4.1.2-R12: isInBoardWindow (-1/+14 days)", () => { + it("accepts today (yyyyMMdd)", () => { + expect(isInBoardWindow("20260515")).toBe(true); + }); + it("accepts yesterday (-1 day)", () => { + expect(isInBoardWindow("20260514")).toBe(true); + }); + it("rejects -2 days", () => { + expect(isInBoardWindow("20260513")).toBe(false); + }); + it("accepts today + 14 days", () => { + expect(isInBoardWindow("20260529")).toBe(true); + }); + it("rejects today + 15 days", () => { + expect(isInBoardWindow("20260530")).toBe(false); + }); + it("rejects malformed input", () => { + expect(isInBoardWindow("")).toBe(false); + expect(isInBoardWindow("2026-05-15")).toBe(false); + expect(isInBoardWindow("20269999")).toBe(false); + }); + }); + + describe("4.1.2-R12: isInScheduleWindow (-1/+330 days)", () => { + it("accepts today + 330 days", () => { + expect(isInScheduleWindow("20270410")).toBe(true); + }); + it("rejects today + 331 days", () => { + expect(isInScheduleWindow("20270411")).toBe(false); + }); + it("rejects -2 days", () => { + expect(isInScheduleWindow("20260513")).toBe(false); + }); + }); + + describe("4.1.2-R12: isInMapWindow (-1 day / +6 months)", () => { + it("accepts today + 6 months (same calendar day)", () => { + expect(isInMapWindow("20261115")).toBe(true); + }); + it("rejects today + 6 months + 1 day", () => { + expect(isInMapWindow("20261116")).toBe(false); + }); + it("rejects -2 days", () => { + expect(isInMapWindow("20260513")).toBe(false); + }); + }); + + it("exposes bounds tuples for UI consumers", () => { + const [bMin, bMax] = boardWindowBounds(); + expect(bMin.toISOString().slice(0, 10)).toBe("2026-05-14"); + expect(bMax.toISOString().slice(0, 10)).toBe("2026-05-29"); + const [sMin, sMax] = scheduleWindowBounds(); + expect(sMin.toISOString().slice(0, 10)).toBe("2026-05-14"); + expect(sMax.toISOString().slice(0, 10)).toBe("2027-04-10"); + const [mMin, mMax] = mapWindowBounds(); + expect(mMin.toISOString().slice(0, 10)).toBe("2026-05-14"); + expect(mMax.toISOString().slice(0, 10)).toBe("2026-11-15"); + }); +}); +``` + +- [ ] **Step 2.2: Run test to verify it fails** + +Run: `pnpm vitest run src/shared/dateWindow.test.ts` +Expected: all tests FAIL (module does not exist). + +- [ ] **Step 2.3: Implement the module** + +```ts +// src/shared/dateWindow.ts +/** + * Centralized date-window constants and membership helpers per TZ §4.1.2. + * + * All date arguments are "yyyyMMdd" strings (TZ URL date format). + * Bounds are inclusive on both ends. Day-level comparison (time zeroed). + */ + +// TZ §4.1.2 ¶3-4 +export const BOARD_WINDOW_DAYS_BACK = 1; +export const BOARD_WINDOW_DAYS_FORWARD = 14; +export const SCHEDULE_WINDOW_DAYS_BACK = 1; +export const SCHEDULE_WINDOW_DAYS_FORWARD = 330; +export const MAP_WINDOW_DAYS_BACK = 1; +export const MAP_WINDOW_MONTHS_FORWARD = 6; + +function today(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +function addDays(base: Date, days: number): Date { + const d = new Date(base); + d.setDate(d.getDate() + days); + return d; +} + +function addMonths(base: Date, months: number): Date { + const d = new Date(base); + d.setMonth(d.getMonth() + months); + return d; +} + +function parseYyyymmdd(s: string): Date | null { + if (!/^\d{8}$/.test(s)) return null; + const y = Number(s.slice(0, 4)); + const m = Number(s.slice(4, 6)); + const d = Number(s.slice(6, 8)); + if (m < 1 || m > 12 || d < 1 || d > 31) return null; + const dt = new Date(y, m - 1, d); + if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null; + return dt; +} + +function inRange(date: Date, min: Date, max: Date): boolean { + return date.getTime() >= min.getTime() && date.getTime() <= max.getTime(); +} + +export function boardWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -BOARD_WINDOW_DAYS_BACK), addDays(base, BOARD_WINDOW_DAYS_FORWARD)]; +} + +export function scheduleWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -SCHEDULE_WINDOW_DAYS_BACK), addDays(base, SCHEDULE_WINDOW_DAYS_FORWARD)]; +} + +export function mapWindowBounds(): [Date, Date] { + const base = today(); + return [addDays(base, -MAP_WINDOW_DAYS_BACK), addMonths(base, MAP_WINDOW_MONTHS_FORWARD)]; +} + +export function isInBoardWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = boardWindowBounds(); + return inRange(d, min, max); +} + +export function isInScheduleWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = scheduleWindowBounds(); + return inRange(d, min, max); +} + +export function isInMapWindow(yyyymmdd: string): boolean { + const d = parseYyyymmdd(yyyymmdd); + if (!d) return false; + const [min, max] = mapWindowBounds(); + return inRange(d, min, max); +} +``` + +- [ ] **Step 2.4: Run test to verify it passes** + +Run: `pnpm vitest run src/shared/dateWindow.test.ts` +Expected: all tests PASS. + +- [ ] **Step 2.5: Commit** + +```bash +git add src/shared/dateWindow.ts src/shared/dateWindow.test.ts +git commit -m "Add centralized date-window constants per TZ 4.1.2-R12 (board/schedule/map)" +``` + +--- + +## Task 3: Details-request query-param codec (`?request=...`) + +**Files:** +- Create: `src/shared/detailsRequestParam.ts` +- Test: `src/shared/detailsRequestParam.test.ts` + +Per TZ Table 5 row 6: every Online-Board flight-details URL carries a `?request=...` query param that encodes the search context so the mini-list can rebuild the parent search on refresh/deep-link. Four kinds: +- `onlineboard-flight-SU1234-20260515` +- `onlineboard-departure-SVO-20260515[-06002200]` +- `onlineboard-arrival-SVO-20260515[-06002200]` +- `onlineboard-route-MOW-LED-20260515[-06002200]` + +- [ ] **Step 3.1: Write failing test** + +```ts +// src/shared/detailsRequestParam.test.ts +import { describe, expect, it } from "vitest"; +import { + buildDetailsRequestParam, + parseDetailsRequestParam, + type DetailsRequest, +} from "./detailsRequestParam.js"; + +describe("detailsRequestParam — build", () => { + it("4.1.2-R-Request-flight: encodes flight-number parent", () => { + const r: DetailsRequest = { + area: "onlineboard", + kind: "flight", + flightNumber: "SU1234", + date: "20260515", + }; + expect(buildDetailsRequestParam(r)).toBe("onlineboard-flight-SU1234-20260515"); + }); + + it("4.1.2-R-Request-departure: encodes departure parent without time", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + }), + ).toBe("onlineboard-departure-SVO-20260515"); + }); + + it("4.1.2-R-Request-departure-time: encodes departure parent with time range", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }), + ).toBe("onlineboard-departure-SVO-20260515-06002200"); + }); + + it("4.1.2-R-Request-arrival: encodes arrival parent", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "arrival", + station: "SVO", + date: "20260515", + }), + ).toBe("onlineboard-arrival-SVO-20260515"); + }); + + it("4.1.2-R-Request-route: encodes route parent", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + }), + ).toBe("onlineboard-route-MOW-LED-20260515"); + }); + + it("4.1.2-R-Request-route-time: encodes route parent with time range", () => { + expect( + buildDetailsRequestParam({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }), + ).toBe("onlineboard-route-MOW-LED-20260515-06002200"); + }); +}); + +describe("detailsRequestParam — parse", () => { + it("returns null for empty or non-matching input", () => { + expect(parseDetailsRequestParam("")).toBeNull(); + expect(parseDetailsRequestParam("garbage")).toBeNull(); + expect(parseDetailsRequestParam("schedule-flight-SU1234-20260515")).toBeNull(); // wrong area + }); + + it("parses flight-number form", () => { + expect(parseDetailsRequestParam("onlineboard-flight-SU1234-20260515")).toEqual({ + area: "onlineboard", + kind: "flight", + flightNumber: "SU1234", + date: "20260515", + }); + }); + + it("parses departure without time", () => { + expect(parseDetailsRequestParam("onlineboard-departure-SVO-20260515")).toEqual({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + }); + }); + + it("parses departure with time range", () => { + expect(parseDetailsRequestParam("onlineboard-departure-SVO-20260515-06002200")).toEqual({ + area: "onlineboard", + kind: "departure", + station: "SVO", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }); + }); + + it("parses arrival", () => { + expect(parseDetailsRequestParam("onlineboard-arrival-LED-20260515")).toEqual({ + area: "onlineboard", + kind: "arrival", + station: "LED", + date: "20260515", + }); + }); + + it("parses route without time", () => { + expect(parseDetailsRequestParam("onlineboard-route-MOW-LED-20260515")).toEqual({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + }); + }); + + it("parses route with time range", () => { + expect(parseDetailsRequestParam("onlineboard-route-MOW-LED-20260515-06002200")).toEqual({ + area: "onlineboard", + kind: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + }); + }); +}); + +describe("detailsRequestParam — roundtrip", () => { + it.each([ + { area: "onlineboard", kind: "flight", flightNumber: "SU1234", date: "20260515" }, + { area: "onlineboard", kind: "departure", station: "SVO", date: "20260515" }, + { area: "onlineboard", kind: "departure", station: "SVO", date: "20260515", timeFrom: "0600", timeTo: "2200" }, + { area: "onlineboard", kind: "arrival", station: "LED", date: "20260515" }, + { area: "onlineboard", kind: "route", departure: "MOW", arrival: "LED", date: "20260515" }, + { area: "onlineboard", kind: "route", departure: "MOW", arrival: "LED", date: "20260515", timeFrom: "0600", timeTo: "2200" }, + ])("round-trips %o", (input) => { + const encoded = buildDetailsRequestParam(input); + const decoded = parseDetailsRequestParam(encoded); + expect(decoded).toEqual(input); + }); +}); +``` + +- [ ] **Step 3.2: Run test to verify it fails** + +Run: `pnpm vitest run src/shared/detailsRequestParam.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3.3: Implement the module** + +```ts +// src/shared/detailsRequestParam.ts +/** + * Codec for the `?request=...` query parameter on Online-Board flight-details URLs. + * Per TZ §4.1.2 Table 5 row 6. The param encodes the parent search context so + * the mini-list can rebuild the list on refresh / deep-link entry. + * + * Forms (canonical): + * onlineboard-flight-{flightNumber}-{yyyyMMdd} + * onlineboard-departure-{iata}-{yyyyMMdd}[-{HHmmHHmm}] + * onlineboard-arrival-{iata}-{yyyyMMdd}[-{HHmmHHmm}] + * onlineboard-route-{depIata}-{arrIata}-{yyyyMMdd}[-{HHmmHHmm}] + */ + +export type DetailsRequest = + | { area: "onlineboard"; kind: "flight"; flightNumber: string; date: string } + | { + area: "onlineboard"; + kind: "departure" | "arrival"; + station: string; + date: string; + timeFrom?: string; + timeTo?: string; + } + | { + area: "onlineboard"; + kind: "route"; + departure: string; + arrival: string; + date: string; + timeFrom?: string; + timeTo?: string; + }; + +const AREA = "onlineboard"; + +function isDate(s: string | undefined): s is string { + return typeof s === "string" && /^\d{8}$/.test(s); +} + +function isTimeRange(s: string | undefined): s is string { + return typeof s === "string" && /^\d{8}$/.test(s); +} + +export function buildDetailsRequestParam(r: DetailsRequest): string { + switch (r.kind) { + case "flight": + return `${r.area}-flight-${r.flightNumber}-${r.date}`; + case "departure": + case "arrival": { + const base = `${r.area}-${r.kind}-${r.station}-${r.date}`; + return r.timeFrom && r.timeTo ? `${base}-${r.timeFrom}${r.timeTo}` : base; + } + case "route": { + const base = `${r.area}-route-${r.departure}-${r.arrival}-${r.date}`; + return r.timeFrom && r.timeTo ? `${base}-${r.timeFrom}${r.timeTo}` : base; + } + } +} + +export function parseDetailsRequestParam(raw: string): DetailsRequest | null { + if (!raw) return null; + const parts = raw.split("-"); + if (parts.length < 4) return null; + if (parts[0] !== AREA) return null; + + const kind = parts[1]; + + if (kind === "flight") { + const [, , flightNumber, date] = parts; + if (!flightNumber || !isDate(date) || parts.length !== 4) return null; + return { area: "onlineboard", kind: "flight", flightNumber, date }; + } + + if (kind === "departure" || kind === "arrival") { + const [, , station, date, maybeTime] = parts; + if (!station || !isDate(date)) return null; + if (parts.length === 4) { + return { area: "onlineboard", kind, station, date }; + } + if (parts.length === 5 && isTimeRange(maybeTime)) { + return { + area: "onlineboard", + kind, + station, + date, + timeFrom: maybeTime.slice(0, 4), + timeTo: maybeTime.slice(4, 8), + }; + } + return null; + } + + if (kind === "route") { + const [, , departure, arrival, date, maybeTime] = parts; + if (!departure || !arrival || !isDate(date)) return null; + if (parts.length === 5) { + return { area: "onlineboard", kind: "route", departure, arrival, date }; + } + if (parts.length === 6 && isTimeRange(maybeTime)) { + return { + area: "onlineboard", + kind: "route", + departure, + arrival, + date, + timeFrom: maybeTime.slice(0, 4), + timeTo: maybeTime.slice(4, 8), + }; + } + return null; + } + + return null; +} +``` + +- [ ] **Step 3.4: Run test to verify it passes** + +Run: `pnpm vitest run src/shared/detailsRequestParam.test.ts` +Expected: all PASS. + +- [ ] **Step 3.5: Commit** + +```bash +git add src/shared/detailsRequestParam.ts src/shared/detailsRequestParam.test.ts +git commit -m "Add ?request= query-param codec for Online-Board details URLs per TZ 4.1.2 Table 5 row 6" +``` + +--- + +## Task 4: Wire `detailsRequestParam` into the Online-Board details page + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Current code in `OnlineBoardDetailsPage.tsx:410-425` parses the param inline and only supports `departure` and `arrival`. Replace with the new codec so `route` and `flight` kinds also work. + +- [ ] **Step 4.1: Add failing test for `route` kind parent-request** + +Add to `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — test that when `?request=onlineboard-route-MOW-LED-20260515` is present, the hook dispatches a route search (both `departure` and `arrival` params set). Use the existing test harness pattern. + +```tsx +// add alongside existing tests +it("4.1.2-R-Request-route: hydrates mini-list from route parent-request", async () => { + // arrange: mock useSearchParams to return request=onlineboard-route-MOW-LED-20260515 + // arrange: mock useOnlineBoard to capture the params argument + // render OnlineBoardDetailsPage + // assert: useOnlineBoard was called with { departure: "MOW", arrival: "LED", dateFrom: "2026-05-15T00:00:00", dateTo: "2026-05-15T23:59:59" } +}); + +it("4.1.2-R-Request-flight: hydrates mini-list from flight parent-request", async () => { + // same pattern with request=onlineboard-flight-SU1234-20260515 + // assert: useOnlineBoard was called with { flightNumber: "SU1234", date: "2026-05-15" } +}); +``` + +(Copy the surrounding mock setup from the existing `departure`/`arrival` test if present; otherwise adapt the existing `searchParams` mock pattern.) + +- [ ] **Step 4.2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` +Expected: new tests FAIL (current code returns `null` for `route` / `flight` kinds, so `useOnlineBoard` isn't called with the expected params). + +- [ ] **Step 4.3: Replace inline parser with `parseDetailsRequestParam`** + +In `src/features/online-board/components/OnlineBoardDetailsPage.tsx`, change the `parentRequest` and `parentParams` memos (lines ~405-436) to: + +```tsx +import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js"; + +// ...inside the component: +const parentRequest = useMemo(() => { + const raw = searchParams.get("request"); + return raw ? parseDetailsRequestParam(raw) : null; +}, [searchParams]); + +const parentParams = useMemo(() => { + if (!parentRequest) return null; + const isoDate = `${parentRequest.date.slice(0, 4)}-${parentRequest.date.slice(4, 6)}-${parentRequest.date.slice(6, 8)}`; + const dateFrom = `${isoDate}T00:00:00`; + const dateTo = `${isoDate}T23:59:59`; + switch (parentRequest.kind) { + case "departure": + return { departure: parentRequest.station, dateFrom, dateTo }; + case "arrival": + return { arrival: parentRequest.station, dateFrom, dateTo }; + case "route": + return { + departure: parentRequest.departure, + arrival: parentRequest.arrival, + dateFrom, + dateTo, + }; + case "flight": + // flight-number parent: mini-list is the flight-number-by-date-range + // result, which is already produced by the existing flight-details + // fetch — no extra fetch needed. + return null; + } +}, [parentRequest]); +``` + +- [ ] **Step 4.4: Run tests to verify they pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` +Expected: all PASS. + +- [ ] **Step 4.5: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +git commit -m "Use shared detailsRequestParam codec for mini-list parent-request (route + flight kinds)" +``` + +--- + +## Task 5: Date-window guard in Online-Board route pages + +**Files:** +- Modify: `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` +- Modify: `src/routes/[lang]/onlineboard/route/[params]/page.tsx` +- Modify: `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` +- Modify: `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` +- Modify: `src/routes/[lang]/onlineboard/[params]/page.tsx` + +Per TZ §4.1.2 ¶3–4: when URL carries a date outside `-1/+14`, redirect to `/{lang}/onlineboard` (NOT 404). Malformed/missing date → 404. + +- [ ] **Step 5.1: Read current route-handler pattern** + +Open `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` and note: +- How it parses the URL `params` (likely via `parseOnlineBoardUrl` or `parseFlightUrlParams`). +- How it currently handles invalid params (404, redirect, render-error?). +- Whether it's a server component, client component, or uses `loader`. + +(This is a read-only step to confirm approach.) + +- [ ] **Step 5.2: Add failing test for `flight` route** + +Create `src/routes/[lang]/onlineboard/flight/[params]/page.test.tsx` (if it doesn't exist): + +```tsx +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +// ... import the page component + mocks per existing patterns +describe("onlineboard/flight/[params] — 4.1.2-R11 date-window guard", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-15T12:00:00Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("redirects to /ru-ru/onlineboard when date is > +14 days", async () => { + // render page with params = "SU1234-20260601" (17 days forward) + // assert navigate was called with "/ru-ru/onlineboard" + }); + + it("redirects to /ru-ru/onlineboard when date is < -1 day", async () => { + // render page with params = "SU1234-20260513" (2 days back) + // assert navigate was called with "/ru-ru/onlineboard" + }); + + it("404s on malformed date", async () => { + // render page with params = "SU1234-BADDATE" + // assert error boundary / 404 page renders (or navigate to /error/404) + }); + + it("renders normally when date is in window", async () => { + // render page with params = "SU1234-20260520" (5 days forward) + // assert details content renders (or fetch was dispatched, not redirect) + }); +}); +``` + +Adapt mock setup to match existing patterns in the project (check for existing `page.test.tsx` files via `find src/routes -name "*.test.*"`). + +- [ ] **Step 5.3: Run test to verify it fails** + +Run: `pnpm vitest run src/routes/[lang]/onlineboard/flight/` +Expected: FAIL (no guard implemented yet). + +- [ ] **Step 5.4: Implement the guard in the flight route** + +In `src/routes/[lang]/onlineboard/flight/[params]/page.tsx`, after parsing URL params and before rendering the details content, add: + +```tsx +import { isInBoardWindow } from "@/shared/dateWindow.js"; +// ... + +// 4.1.2-R11: date outside [-1, +14] redirects to start page (NOT 404). +if (!isInBoardWindow(parsed.date)) { + return ; +} +``` + +(Exact import and component depend on router — use `` if route uses `react-router`, or `redirect()` if server-side. Follow the pattern already used in the codebase for redirects.) + +- [ ] **Step 5.5: Run test to verify it passes** + +Run: `pnpm vitest run src/routes/[lang]/onlineboard/flight/` +Expected: PASS. + +- [ ] **Step 5.6: Repeat Steps 5.2–5.5 for `route`, `departure`, `arrival`, and details (`[params]/page.tsx`) routes** + +Use the same test-guard-fix pattern. For the details route at `src/routes/[lang]/onlineboard/[params]/page.tsx`, the parsed date is the same `parsed.date` field. + +- [ ] **Step 5.7: Commit after each route** + +After Steps 5.2–5.5 for each route, commit: + +```bash +git add src/routes/[lang]/onlineboard//... +git commit -m "Enforce [-1, +14] date-window guard on Online-Board per TZ 4.1.2-R11" +``` + +Five commits total (flight, route, departure, arrival, details). + +--- + +## Task 6: Date-window guard in Schedule route page + +**Files:** +- Modify: `src/routes/[lang]/schedule/route/[params]/page.tsx` +- Modify: `src/features/schedule/components/ScheduleStartPage.tsx` (use `SCHEDULE_WINDOW_DAYS_FORWARD`) +- Modify: `src/features/schedule/components/ScheduleFilter.tsx` (use `SCHEDULE_WINDOW_DAYS_FORWARD`) + +- [ ] **Step 6.1: Write failing test for schedule date-window guard** + +Same pattern as Task 5.2, applied to `src/routes/[lang]/schedule/route/[params]/page.test.tsx`. Use `isInScheduleWindow`, test both ends of `-1/+330`. + +- [ ] **Step 6.2: Run test to verify it fails** + +Run: `pnpm vitest run src/routes/[lang]/schedule/route/` +Expected: FAIL. + +- [ ] **Step 6.3: Implement the guard** + +Apply `isInScheduleWindow` to `parsed.outbound.dateFrom`. If out-of-window, redirect to `/{lang}/schedule`. For round-trip, also check `parsed.inbound.dateFrom`. + +- [ ] **Step 6.4: Migrate scattered `+330` constants** + +In `src/features/schedule/components/ScheduleStartPage.tsx` and `ScheduleFilter.tsx`, replace the local `getScheduleMaxDate()` helper (currently duplicated) with `scheduleWindowBounds()` from `@/shared/dateWindow.js`. + +Before: +```tsx +function getScheduleMaxDate(): Date { + const d = today(); + d.setDate(d.getDate() + 330); + return d; +} +// ... +const scheduleMaxDate = useRef(getScheduleMaxDate()).current; +``` + +After: +```tsx +import { scheduleWindowBounds } from "@/shared/dateWindow.js"; +// ... +const scheduleMaxDate = useRef(scheduleWindowBounds()[1]).current; +``` + +- [ ] **Step 6.5: Migrate `flights-map/calendarRange.ts`** + +Replace `getMinDate()` and `getMaxDate()` bodies with `mapWindowBounds()[0]` / `mapWindowBounds()[1]`. Keep the function names for back-compat with existing callers. + +```ts +import { mapWindowBounds } from "@/shared/dateWindow.js"; +// ... +export function getMinDate(): Date { + return mapWindowBounds()[0]; +} +export function getMaxDate(): Date { + return mapWindowBounds()[1]; +} +``` + +- [ ] **Step 6.6: Run full test suite** + +Run: `pnpm vitest run` +Expected: all tests PASS (the migrations should be transparent). + +- [ ] **Step 6.7: Commit** + +```bash +git add src/routes/[lang]/schedule/route/ src/features/schedule/components/ScheduleStartPage.tsx src/features/schedule/components/ScheduleFilter.tsx src/features/flights-map/calendarRange.ts +git commit -m "Enforce [-1, +330] schedule window + consolidate date-window constants" +``` + +--- + +## Task 7: Audit + fix Online-Board page titles per TZ Table 6 + +**Files:** +- Modify: `src/features/online-board/seo.ts` (if strings drift) +- Modify: `src/i18n/*.json` (9 locales) + +Target strings per TZ Table 6: +| # | Page | Title format | +|---|------|--------------| +| 1 | Start | `Онлайн-Табло` | +| 2 | Flight-number search | `Рейс: {number}, {date}` (date is "сегодня" / "завтра" / "dd.MM.yyyy") | +| 3 | Route search | `Маршрут: {fromCity}-{toCity}, {date}` | +| 4 | Departure search | `Вылет: {city}, {date}` | +| 5 | Arrival search | `Прилет: {city}, {date}` | +| 6-8 | Flight details (all modes) | `Информация о рейсе: {number}, {fromCity}-{toCity}` (no date per TZ) | + +- [ ] **Step 7.1: Read current i18n strings** + +Open `src/i18n/ru-ru.json` (or equivalent) and grep for `SEO.BOARD.*.TITLE`. Compare against TZ Table 6. + +Run: `grep -n 'SEO\.BOARD' src/i18n/ru-ru.json` + +- [ ] **Step 7.2: List deltas** + +For each title key, note: +- Current value. +- TZ target value. +- Difference (exact string match required per Q2=C; any drift = flagged conflict). + +If conflicts are found (TZ vs. live Angular site), do NOT silently reconcile — add them to the Conflicts register in the spec and pause this task pending arbitration. + +- [ ] **Step 7.3: Add "today"/"tomorrow"/"dd.MM.yyyy" date-display helper** + +If not present, add to `src/features/online-board/seo.ts`: + +```ts +function formatDateForTitle(yyyymmdd: string, t: TFunction, locale: string): string { + // TZ §4.1.3 Table 6: date display is "сегодня" / "завтра" / "dd.MM.yyyy" + const today = new Date(); + today.setHours(0, 0, 0, 0); + const y = Number(yyyymmdd.slice(0, 4)); + const m = Number(yyyymmdd.slice(4, 6)) - 1; + const d = Number(yyyymmdd.slice(6, 8)); + const input = new Date(y, m, d); + const deltaDays = Math.round((input.getTime() - today.getTime()) / 86_400_000); + if (deltaDays === 0) return t("SHARED.TODAY"); + if (deltaDays === 1) return t("SHARED.TOMORROW"); + // Fallback: dd.MM.yyyy + return `${yyyymmdd.slice(6, 8)}.${yyyymmdd.slice(4, 6)}.${yyyymmdd.slice(0, 4)}`; +} +``` + +- [ ] **Step 7.4: Update each SEO builder to pass the formatted date** + +Change `buildFlightSearchSeo`, `buildDepartureSearchSeo`, `buildArrivalSearchSeo`, `buildRouteSearchSeo` to call `formatDateForTitle(...)` instead of `formatDateForSeo(...)` when populating the `date` interpolation for TITLE strings. Keep `formatDateForSeo` for DESCRIPTION (full dd.MM.yyyy remains appropriate there). + +- [ ] **Step 7.5: Update i18n keys** + +For each of the 9 locale files (`ru-ru`, `en-us`, `de-de`, `fr-fr`, `es-es`, `it-it`, `zh-cn`, `ja-jp`, `ko-kr`), ensure: +- `SEO.BOARD.FLIGHT-SEARCH.TITLE` = `"Рейс: {{flightNumber}}, {{date}}"` (or the locale translation) +- `SEO.BOARD.ROUTE-SEARCH.TITLE` = `"Маршрут: {{departureCity}}-{{arrivalCity}}, {{date}}"` +- `SEO.BOARD.DEPARTURE-SEARCH.TITLE` = `"Вылет: {{departureCity}}, {{date}}"` +- `SEO.BOARD.ARRIVAL-SEARCH.TITLE` = `"Прилет: {{arrivalCity}}, {{date}}"` +- `SEO.BOARD.FLIGHT-DETAILS.TITLE` = `"Информация о рейсе: {{flightNumber}}, {{departureCity}}-{{arrivalCity}}"` (drops date per TZ Table 6 rows 6-8) +- Ensure `SHARED.TODAY` = `"сегодня"` and `SHARED.TOMORROW` = `"завтра"` exist. + +For non-Russian locales, translate appropriately (keep existing translations; only add missing keys). + +- [ ] **Step 7.6: Update unit tests** + +Update `src/features/online-board/seo.test.ts` to assert the new title format with a frozen clock. Add cases for "today", "tomorrow", and explicit date. + +- [ ] **Step 7.7: Run tests** + +Run: `pnpm vitest run src/features/online-board/seo.test.ts` +Expected: PASS. + +- [ ] **Step 7.8: Commit** + +```bash +git add src/features/online-board/seo.ts src/features/online-board/seo.test.ts src/i18n/*.json +git commit -m "Align Online-Board page titles with TZ Table 6 (сегодня/завтра/ДД.ММ.ГГГГ date display)" +``` + +--- + +## Task 8: Audit + fix Schedule page titles per TZ Table 6 + +**Files:** +- Modify: `src/features/schedule/seo.ts` +- Modify: `src/features/schedule/seo.test.ts` +- Modify: `src/i18n/*.json` + +TZ Table 6: +- Row 9 Start: `Расписание` +- Row 10 Route: `Расписание по маршруту: {fromCity}-{toCity}` +- Row 11-13 Details (direct/multi-segment): `Расписание рейса: {number}, {fromCity}-{toCity}` +- Row 11-13 Details (connecting): `Расписание рейсов: {number1}, {number2}[, {number3}]` + +Current code uses a single `SEO.SCHEDULE.FLIGHT-DETAILS.TITLE` key. The TZ requires branching by flight count. + +- [ ] **Step 8.1: Write failing test** + +Add to `src/features/schedule/seo.test.ts`: + +```ts +describe("4.1.3-R5 (schedule details title branches by flight count)", () => { + it("direct (1 flight) uses 'Расписание рейса:'", () => { + const seo = buildScheduleDetailsSeo(tRu, [directFlightStub], "ru-ru", origin, [ + { carrier: "SU", flightNumber: "1234", date: "20260515" }, + ]); + expect(seo.title).toBe("Расписание рейса: SU 1234, Москва-Санкт-Петербург"); + }); + + it("multi-segment (1 flight number) uses 'Расписание рейса:'", () => { + // same as direct — multi-segment has same flight number across all legs + }); + + it("connecting (2+ flight numbers) uses 'Расписание рейсов:' with comma list", () => { + const seo = buildScheduleDetailsSeo(tRu, [flight1, flight2], "ru-ru", origin, [ + { carrier: "SU", flightNumber: "1234", date: "20260515" }, + { carrier: "SU", flightNumber: "5678", date: "20260515" }, + ]); + expect(seo.title).toBe("Расписание рейсов: SU 1234, SU 5678"); + }); +}); +``` + +- [ ] **Step 8.2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/schedule/seo.test.ts` +Expected: FAIL. + +- [ ] **Step 8.3: Split the details-title key into two** + +Rename existing `SEO.SCHEDULE.FLIGHT-DETAILS.TITLE` to `SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-DIRECT` and add new `SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-CONNECTING`. + +Translate both across 9 locales: +- `TITLE-DIRECT` = `"Расписание рейса: {{flightNumber}}, {{routeCities}}"` +- `TITLE-CONNECTING` = `"Расписание рейсов: {{flightNumbers}}"` + +In `src/features/schedule/seo.ts`, update `buildScheduleDetailsSeo` to branch: + +```ts +export function buildScheduleDetailsSeo( + t: TFunction, + flights: ISimpleFlight[], + locale: string, + canonicalOrigin: string, + flightIds: IScheduleFlightId[], +): SeoHeadProps { + const uniqueNumbers = new Set(flightIds.map((f) => f.flightNumber)); + const isConnecting = uniqueNumbers.size > 1; + + let title: string; + if (isConnecting) { + const flightNumbers = flightIds.map((f) => `${f.carrier} ${f.flightNumber}${f.suffix ?? ""}`).join(", "); + title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-CONNECTING", { flightNumbers }); + } else { + const first = flightIds[0]!; + const flightNumber = `${first.carrier} ${first.flightNumber}${first.suffix ?? ""}`; + const firstLeg = flights[0]?.flightId; // or iterate flights for route cities — adapt to actual ISimpleFlight shape + const routeCities = /* derive "{dep}-{arr}" city names from flights[0] */ ""; + title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-DIRECT", { flightNumber, routeCities }); + } + // ...rest unchanged (description, canonical, og) +} +``` + +(Adjust to the actual `ISimpleFlight` fields available; if route-city names aren't on the flight object at SSR time, keep the dd.MM.yyyy date for backward compat and flag this as a conflict in the spec.) + +- [ ] **Step 8.4: Run tests** + +Run: `pnpm vitest run src/features/schedule/seo.test.ts` +Expected: PASS. + +- [ ] **Step 8.5: Commit** + +```bash +git add src/features/schedule/seo.ts src/features/schedule/seo.test.ts src/i18n/*.json +git commit -m "Branch schedule details title by direct vs connecting per TZ Table 6 rows 11-13" +``` + +--- + +## Task 9: Verify Flight-Map page title per TZ Table 6 + +**Files:** +- Verify: `src/features/flights-map/seo.ts` + +TZ Table 6 rows 1-3 for Карта полетов: **all three** (start, departure-search, route-search) use the same title `Карта полетов`. Current code already uses a single `SEO.FLIGHTS-MAP.MAIN.TITLE` — verify it matches. + +- [ ] **Step 9.1: Read current value** + +Run: `grep -n 'FLIGHTS-MAP\.MAIN\.TITLE' src/i18n/ru-ru.json` + +- [ ] **Step 9.2: Verify** + +Expected: value is `"Карта полетов"`. If different, update all 9 locales. + +- [ ] **Step 9.3: Add an assertion test (if missing)** + +In `src/features/flights-map/seo.test.ts`: + +```ts +it("4.1.3-R3 uses 'Карта полетов' for all map pages", () => { + const seo = buildFlightsMapSeo(tRu, "ru-ru", origin); + expect(seo.title).toBe("Карта полетов"); +}); +``` + +- [ ] **Step 9.4: Commit if changes made** + +If the title was already correct and only the test was added: + +```bash +git add src/features/flights-map/seo.test.ts +git commit -m "Verify Flight-Map title 'Карта полетов' per TZ Table 6 rows 1-3" +``` + +--- + +## Task 10: Breadcrumbs audit — Online-Board + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardStartPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardSearchPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` + +Per TZ Table 7 rows 1-8: +- Start → `[Главная]` +- Search results (all 4 modes) → `[Главная, Онлайн-Табло]` +- Details → `[Главная, Онлайн-Табло, {modeLabel}:{value}]` where `modeLabel` matches Table 7 row 6 (Номер рейса / Маршрут / Вылет / Прилет) + value (flight number or city-city). + +The last crumb (mode-label) must be **clickable** and must restore the previous search page with filter state preserved (per Table 7 row 6 description). + +- [ ] **Step 10.1: Verify start-page crumb** + +Open `src/features/online-board/components/OnlineBoardStartPage.tsx`. Confirm it passes `breadcrumbs={[]}` (the Breadcrumbs component auto-prepends `[Главная]`). If current impl passes extra items, fix it. + +- [ ] **Step 10.2: Verify search-page crumb** + +Open `src/features/online-board/components/OnlineBoardSearchPage.tsx`. Confirm crumbs = `[{ label: t("BOARD.TITLE"), url: "/{lang}/onlineboard" }]` (which becomes `[Главная, Онлайн-Табло]` after the component prepends Home). + +- [ ] **Step 10.3: Add failing test for details-page mode-specific leaf crumb** + +In `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`, add: + +```tsx +it("4.1.4-R-Crumb-leaf-flight: flight-mode details shows 'Номер рейса: SU1234' as last clickable crumb", () => { + // render details page with ?request=onlineboard-flight-SU1234-20260515 + // assert breadcrumb trail = [Главная, Онлайн-Табло, "Номер рейса: SU1234"] + // assert clicking the last crumb navigates to /ru-ru/onlineboard/flight/SU1234-20260515 +}); + +it("4.1.4-R-Crumb-leaf-route: route-mode details shows 'Маршрут: Москва-Санкт-Петербург'", () => { /* ... */ }); +it("4.1.4-R-Crumb-leaf-departure: departure-mode details shows 'Вылет: Москва'", () => { /* ... */ }); +it("4.1.4-R-Crumb-leaf-arrival: arrival-mode details shows 'Прилет: Москва'", () => { /* ... */ }); +it("4.1.4-R-Crumb-leaf-share-link: details opened via share-link (no request param) shows only [Главная, Онлайн-Табло]", () => { /* no leaf */ }); +``` + +- [ ] **Step 10.4: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` +Expected: FAIL (current impl has only `[{ label: t("BOARD.TITLE"), url: "/{locale}/onlineboard" }]`). + +- [ ] **Step 10.5: Implement mode-specific leaf crumb** + +In `OnlineBoardDetailsPage.tsx`, replace the hardcoded single-crumb `commonLayoutProps.breadcrumbs` with a computed trail that uses `parentRequest`: + +```tsx +import { buildOnlineBoardUrl } from "@/features/online-board"; +// ... + +const detailsCrumbs = useMemo((): BreadcrumbItem[] => { + const baseCrumbs: BreadcrumbItem[] = [ + { label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }, + ]; + if (!parentRequest) return baseCrumbs; + + const backPath = (() => { + switch (parentRequest.kind) { + case "flight": + return buildOnlineBoardUrl({ + type: "flight", + carrier: parentRequest.flightNumber.slice(0, 2), + flightNumber: parentRequest.flightNumber.slice(2), + date: parentRequest.date, + }); + case "departure": + case "arrival": + return buildOnlineBoardUrl({ + type: parentRequest.kind, + station: parentRequest.station, + date: parentRequest.date, + ...(parentRequest.timeFrom && parentRequest.timeTo + ? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo } + : {}), + }); + case "route": + return buildOnlineBoardUrl({ + type: "route", + departure: parentRequest.departure, + arrival: parentRequest.arrival, + date: parentRequest.date, + ...(parentRequest.timeFrom && parentRequest.timeTo + ? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo } + : {}), + }); + } + })(); + + const leafLabel = (() => { + switch (parentRequest.kind) { + case "flight": + return t("CRUMB.FLIGHT-NUMBER", { flightNumber: parentRequest.flightNumber }); + case "departure": + return t("CRUMB.DEPARTURE", { city: cityNames?.departure ?? parentRequest.station }); + case "arrival": + return t("CRUMB.ARRIVAL", { city: cityNames?.arrival ?? parentRequest.station }); + case "route": + return t("CRUMB.ROUTE", { + departureCity: cityNames?.departure ?? parentRequest.departure, + arrivalCity: cityNames?.arrival ?? parentRequest.arrival, + }); + } + })(); + + return [ + ...baseCrumbs, + { label: leafLabel, url: `/${locale}/${backPath}` }, + ]; +}, [parentRequest, cityNames, locale, t]); + +// ... +const commonLayoutProps = { + headerLeft: , + breadcrumbs: detailsCrumbs, +}; +``` + +Add i18n keys in all 9 locales: +- `CRUMB.FLIGHT-NUMBER` = `"Номер рейса: {{flightNumber}}"` +- `CRUMB.DEPARTURE` = `"Вылет: {{city}}"` +- `CRUMB.ARRIVAL` = `"Прилет: {{city}}"` +- `CRUMB.ROUTE` = `"Маршрут: {{departureCity}}-{{arrivalCity}}"` + +- [ ] **Step 10.6: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` +Expected: PASS. + +- [ ] **Step 10.7: Also check TZ Table 7 note** + +The TZ notes (Table 7 row 6) that clicking the last crumb must produce **the previous search with time-range if originally specified**. The `backPath` we build already uses the full `parentRequest` (including timeFrom/timeTo), so this is satisfied. Add a test asserting the time-range case: + +```tsx +it("4.1.4-R-Crumb-leaf-timerange: details opened from route with time range → back crumb includes time range", () => { + // render details with ?request=onlineboard-route-MOW-LED-20260515-06002200 + // assert last crumb url = /ru-ru/onlineboard/route/MOW-LED-20260515-06002200 +}); +``` + +- [ ] **Step 10.8: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx src/i18n/*.json +git commit -m "Add TZ Table 7 mode-specific leaf breadcrumb + back-to-search link on Online-Board details" +``` + +--- + +## Task 11: Breadcrumbs audit — Schedule + +**Files:** +- Modify: `src/features/schedule/components/ScheduleStartPage.tsx` +- Modify: `src/features/schedule/components/ScheduleSearchPage.tsx` +- Modify: `src/features/schedule/components/ScheduleDetailsPage.tsx` + +Per TZ Table 7 rows 9-13: +- Start → `[Главная]` +- Search → `[Главная, Расписание]` +- Details (all) → `[Главная, Расписание, {fromCity}-{toCity}]` + +- [ ] **Step 11.1: Verify start + search crumbs** (read + adjust if needed). + +- [ ] **Step 11.2: Add failing test for schedule-details leaf crumb** + +Add to `src/features/schedule/components/ScheduleDetailsPage.test.tsx` (create if missing): + +```tsx +it("4.1.4-R-Sched-Crumb-leaf: schedule-details shows '{fromCity}-{toCity}' as last clickable crumb", () => { + // render ScheduleDetailsPage with route MOW-LED + // assert breadcrumbs last entry = "Москва-Санкт-Петербург" with url pointing back to /ru-ru/schedule/route/MOW-LED-... +}); +``` + +- [ ] **Step 11.3: Implement** + +The schedule details URL doesn't use `?request=` — the route context is in the URL path (`/{lang}/schedule/{flights}` with the flights IDs). The back-to-search URL must be reconstructed from the flight's origin/destination + dates. Two options: +1. Add a `?request=` param on schedule details URLs too (mirror Online-Board). TZ Table 5 row 11 DOES show `?request=schedule-route-NBC-KHV-20220307-20220313` on schedule details URLs, so this is required. +2. Reuse the flight's own `departureCity` / `arrivalCity` fields from the loaded flight data. + +Option 1 matches the TZ. Implement `buildScheduleDetailsRequestParam` alongside the existing `detailsRequestParam` codec (or extend it — `area: "schedule"` plus `kind: "route"` with dates-from/to). + +Update `buildScheduleUrl` for `type: "details"` to accept an optional `request` field and append `?request=...`. Update the route `` callsites in the search results to include the request param. + +- [ ] **Step 11.4: Extend `detailsRequestParam` codec** + +Add to `src/shared/detailsRequestParam.ts`: + +```ts +export type DetailsRequest = + | /* existing onlineboard variants */ + | { + area: "schedule"; + kind: "route"; + departure: string; + arrival: string; + dateFrom: string; + dateTo: string; + timeFrom?: string; + timeTo?: string; + connections?: number; // C0 / C1 + // round-trip (if present) + returnDepartureArrival?: { departure: string; arrival: string; dateFrom: string; dateTo: string; timeFrom?: string; timeTo?: string; connections?: number }; + }; +``` + +Extend `buildDetailsRequestParam` and `parseDetailsRequestParam` to handle `area: "schedule"`. Add tests following the same pattern as Task 3 (mirrored for schedule). + +- [ ] **Step 11.5: Update schedule search-result links** + +In `src/features/schedule/components/ScheduleSearchPage.tsx` (or wherever details-link is built), include `?request=...` on the navigation URL using `buildDetailsRequestParam({ area: "schedule", kind: "route", ... })`. + +- [ ] **Step 11.6: Update ScheduleDetailsPage breadcrumb** + +Same pattern as Online-Board Task 10 but simpler — single leaf label `{departureCity}-{arrivalCity}`. Use `CRUMB.SCHEDULE-ROUTE` key: `"{{departureCity}}-{{arrivalCity}}"`. + +- [ ] **Step 11.7: Run tests** + +Run: `pnpm vitest run src/features/schedule/ src/shared/detailsRequestParam.test.ts` +Expected: PASS. + +- [ ] **Step 11.8: Commit** + +```bash +git add src/shared/detailsRequestParam.ts src/shared/detailsRequestParam.test.ts src/features/schedule/ src/i18n/*.json +git commit -m "Add TZ Table 7 back-to-search breadcrumb + ?request= context on Schedule details" +``` + +--- + +## Task 12: Breadcrumbs audit — Flight-Map + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` + +Per TZ Table 7 (Карта полетов rows 1-3): ALL map pages show only `[Главная]`. The map has no search-result / details page (filter changes update the same URL). + +- [ ] **Step 12.1: Verify current impl** + +Open `FlightsMapStartPage.tsx`. Confirm breadcrumbs = `[]` (Breadcrumbs auto-prepends Home). If extra crumbs are rendered, remove them. + +- [ ] **Step 12.2: Add assertion test** + +```tsx +it("4.1.4-R-Map-Crumb: map page shows only [Главная]", () => { + // render FlightsMapStartPage + // assert breadcrumbs has exactly 1 entry = Главная +}); +``` + +- [ ] **Step 12.3: Run test** + +Run: `pnpm vitest run src/features/flights-map/` +Expected: PASS. + +- [ ] **Step 12.4: Commit if changes were made** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.tsx src/features/flights-map/components/FlightsMapStartPage.test.tsx +git commit -m "Verify TZ Table 7 Карта полетов breadcrumb = [Главная] only" +``` + +--- + +## Task 13: Cross-section filter state carry-over + +**Files:** +- Create: `src/shared/state/crossSectionNavigation.ts` +- Create: `src/shared/state/crossSectionNavigation.test.ts` +- Modify: `src/features/online-board/components/OnlineBoardStartPage.tsx` / `OnlineBoardFilter.tsx` (hydrate from store on mount) +- Modify: `src/features/schedule/components/ScheduleStartPage.tsx` / `ScheduleFilter.tsx` (same) +- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` (independent — does NOT subscribe to board/schedule filter) + +Per TZ Table 10: when navigating Board → Schedule (or reverse) within one session, the filter carries the common fields forward. The map filter is independent and keeps its own last-state. + +- [ ] **Step 13.1: Write failing test** + +```ts +// src/shared/state/crossSectionNavigation.test.ts +import { describe, expect, it, beforeEach } from "vitest"; +import { + setBoardFilter, + getBoardFilter, + setScheduleFilter, + getScheduleFilter, + setMapFilter, + getMapFilter, + projectBoardToSchedule, + projectScheduleToBoard, + resetCrossSectionStore, + type BoardFilterSnapshot, + type ScheduleFilterSnapshot, + type MapFilterSnapshot, +} from "./crossSectionNavigation.js"; + +beforeEach(() => { + resetCrossSectionStore(); +}); + +describe("4.1.8-R1/R2: board ↔ schedule filter carry-over", () => { + it("stores and retrieves board filter", () => { + const snap: BoardFilterSnapshot = { + mode: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0000", + timeTo: "2400", + searchExecuted: true, + }; + setBoardFilter(snap); + expect(getBoardFilter()).toEqual(snap); + }); + + it("projectBoardToSchedule: carries departure + arrival, sets dateFrom/dateTo = current week, clears time/toggles", () => { + const board: BoardFilterSnapshot = { + mode: "route", + departure: "MOW", + arrival: "LED", + date: "20260515", + timeFrom: "0600", + timeTo: "2200", + searchExecuted: true, + }; + const projected = projectBoardToSchedule(board); + expect(projected.departure).toBe("MOW"); + expect(projected.arrival).toBe("LED"); + expect(projected.onlyDirect).toBe(false); + expect(projected.showReturn).toBe(false); + expect(projected.timeFrom).toBe("0000"); + expect(projected.timeTo).toBe("2400"); + // dateFrom/dateTo = current week (Mon-Sun of week containing board.date) + // — asserted with frozen clock + board.date = 20260515 (Friday) + }); + + it("projectScheduleToBoard: carries departure + arrival, sets date = today, clears time", () => { + const schedule: ScheduleFilterSnapshot = { + mode: "route", + departure: "MOW", + arrival: "LED", + dateFrom: "20260511", + dateTo: "20260517", + timeFrom: "0600", + timeTo: "2200", + onlyDirect: true, + showReturn: false, + searchExecuted: true, + }; + const projected = projectScheduleToBoard(schedule); + expect(projected.departure).toBe("MOW"); + expect(projected.arrival).toBe("LED"); + expect(projected.timeFrom).toBe("0000"); + expect(projected.timeTo).toBe("2400"); + // date = today — asserted with frozen clock + }); +}); + +describe("4.1.1-R26 / 4.1.8-R3: map filter is independent", () => { + it("map filter is NOT affected by board/schedule filter changes", () => { + const mapSnap: MapFilterSnapshot = { + departure: "MOW", + arrival: null, + date: "20260515", + showInternal: true, + showInternational: true, + showTransfers: false, + }; + setMapFilter(mapSnap); + + setBoardFilter({ + mode: "route", + departure: "LED", + arrival: "KGD", + date: "20260601", + timeFrom: "0000", + timeTo: "2400", + searchExecuted: false, + }); + + expect(getMapFilter()).toEqual(mapSnap); + }); +}); +``` + +- [ ] **Step 13.2: Run test to verify it fails** + +Run: `pnpm vitest run src/shared/state/crossSectionNavigation.test.ts` +Expected: FAIL. + +- [ ] **Step 13.3: Implement the store** + +```ts +// src/shared/state/crossSectionNavigation.ts +/** + * Session-scoped store for cross-section filter state (TZ §4.1.8 Table 10). + * In-memory only — cleared on page reload. Projection functions implement + * the exact field mappings from Table 10 when a user navigates between + * Online-Board ↔ Schedule. Map filter is stored separately and is NEVER + * projected from / to Board or Schedule (per TZ §4.1.1 ¶12). + */ + +export interface BoardFilterSnapshot { + mode: "route" | "flight-number" | "departure" | "arrival"; + departure?: string; + arrival?: string; + flightNumber?: string; + date: string; // yyyyMMdd + timeFrom: string; // HHmm (default "0000") + timeTo: string; // HHmm (default "2400") + searchExecuted: boolean; +} + +export interface ScheduleFilterSnapshot { + mode: "route"; + departure?: string; + arrival?: string; + dateFrom: string; // yyyyMMdd + dateTo: string; // yyyyMMdd + timeFrom: string; // HHmm + timeTo: string; // HHmm + onlyDirect: boolean; + showReturn: boolean; + returnDateFrom?: string; + returnDateTo?: string; + returnTimeFrom?: string; + returnTimeTo?: string; + searchExecuted: boolean; +} + +export interface MapFilterSnapshot { + departure: string | null; + arrival: string | null; + date: string | null; // yyyyMMdd + showInternal: boolean; + showInternational: boolean; + showTransfers: boolean; +} + +let board: BoardFilterSnapshot | null = null; +let schedule: ScheduleFilterSnapshot | null = null; +let map: MapFilterSnapshot | null = null; + +export function setBoardFilter(s: BoardFilterSnapshot): void { board = s; } +export function getBoardFilter(): BoardFilterSnapshot | null { return board; } +export function setScheduleFilter(s: ScheduleFilterSnapshot): void { schedule = s; } +export function getScheduleFilter(): ScheduleFilterSnapshot | null { return schedule; } +export function setMapFilter(s: MapFilterSnapshot): void { map = s; } +export function getMapFilter(): MapFilterSnapshot | null { return map; } + +export function resetCrossSectionStore(): void { + board = null; + schedule = null; + map = null; +} + +function currentWeekBounds(from: Date): { start: string; end: string } { + // Mon..Sun of the week containing `from` + const d = new Date(from); + d.setHours(0, 0, 0, 0); + const dow = (d.getDay() + 6) % 7; // 0=Mon..6=Sun + d.setDate(d.getDate() - dow); + const start = new Date(d); + const end = new Date(d); + end.setDate(end.getDate() + 6); + const y = (x: Date) => + `${x.getFullYear()}${String(x.getMonth() + 1).padStart(2, "0")}${String(x.getDate()).padStart(2, "0")}`; + return { start: y(start), end: y(end) }; +} + +function todayYyyymmdd(): string { + const d = new Date(); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; +} + +/** TZ Table 10 rows 1-4: Board → Schedule projection. */ +export function projectBoardToSchedule(b: BoardFilterSnapshot): ScheduleFilterSnapshot { + const base = b.date ? new Date(Number(b.date.slice(0, 4)), Number(b.date.slice(4, 6)) - 1, Number(b.date.slice(6, 8))) : new Date(); + const week = currentWeekBounds(base); + return { + mode: "route", + ...(b.departure ? { departure: b.departure } : {}), + ...(b.arrival ? { arrival: b.arrival } : {}), + dateFrom: week.start, + dateTo: week.end, + timeFrom: "0000", + timeTo: "2400", + onlyDirect: false, + showReturn: false, + searchExecuted: false, + }; +} + +/** TZ Table 10 rows 1-4: Schedule → Board projection. */ +export function projectScheduleToBoard(s: ScheduleFilterSnapshot): BoardFilterSnapshot { + return { + mode: "route", + ...(s.departure ? { departure: s.departure } : {}), + ...(s.arrival ? { arrival: s.arrival } : {}), + date: todayYyyymmdd(), + timeFrom: "0000", + timeTo: "2400", + searchExecuted: false, + }; +} +``` + +- [ ] **Step 13.4: Run test to verify it passes** + +Run: `pnpm vitest run src/shared/state/crossSectionNavigation.test.ts` +Expected: PASS. + +(Note: the projection tests need a frozen clock for `todayYyyymmdd()` and `currentWeekBounds()`. Use `vi.setSystemTime(new Date("2026-05-15T12:00:00Z"))` in the `beforeEach` — 2026-05-15 is a Friday so week bounds are Mon 2026-05-11 → Sun 2026-05-17.) + +- [ ] **Step 13.5: Wire the store into Online-Board filter** + +In `src/features/online-board/components/OnlineBoardFilter.tsx` (and `OnlineBoardStartPage.tsx` as applicable): +- On mount, call `getScheduleFilter()` — if not null and we have no stored board filter, hydrate from `projectScheduleToBoard(scheduleSnap)`. +- On every filter change (and on search submit), call `setBoardFilter(currentSnap)` so we preserve state for the reverse direction. + +(Adapt to the existing state pattern: if the filter already uses a `useState` or store, add the `useEffect` that reads the cross-section store on mount and syncs on change.) + +- [ ] **Step 13.6: Wire the store into Schedule filter** + +Same pattern, reversed: read `getBoardFilter()` on mount, project via `projectBoardToSchedule`, write back via `setScheduleFilter` on change. + +- [ ] **Step 13.7: Wire into Flight-Map filter** + +In `src/features/flights-map/components/FlightsMapStartPage.tsx` (or the component that owns the filter): on mount, hydrate from `getMapFilter()`; on change, call `setMapFilter`. Do NOT read or write board / schedule state. + +- [ ] **Step 13.8: Add a component test per wiring** + +Add at least one test per feature that asserts the hydration direction (mount → filter populated from cross-section store). Use `resetCrossSectionStore()` in `beforeEach`. + +- [ ] **Step 13.9: Run full test suite** + +Run: `pnpm vitest run` +Expected: all PASS. + +- [ ] **Step 13.10: Commit** + +```bash +git add src/shared/state/crossSectionNavigation.ts src/shared/state/crossSectionNavigation.test.ts src/features/online-board/components/ src/features/schedule/components/ src/features/flights-map/components/ +git commit -m "Add cross-section filter state carry-over per TZ Table 10 (4.1.8) — map stays independent" +``` + +--- + +## Task 14: Date-window clamp on cross-section projection + +**Files:** +- Modify: `src/shared/state/crossSectionNavigation.ts` +- Modify: `src/shared/state/crossSectionNavigation.test.ts` + +Per TZ 4.1.8-R2: when projecting Board (window -1/+14) → Schedule (window -1/+330), the date clamps only if out-of-range for the destination section. When projecting Schedule → Board, if schedule's `dateFrom` is > board's `+14` window, the projected date snaps to today (since we default Board's date to today anyway). Edge case: Schedule `dateFrom` < today-1 → clamp to today. + +- [ ] **Step 14.1: Add failing test** + +```ts +it("4.1.8-R2: projectScheduleToBoard clamps date to today when schedule dateFrom is outside board window", () => { + vi.setSystemTime(new Date("2026-05-15T12:00:00Z")); + const schedule: ScheduleFilterSnapshot = { + mode: "route", + departure: "MOW", + arrival: "LED", + dateFrom: "20260701", // 47 days forward — outside board window + dateTo: "20260707", + timeFrom: "0000", + timeTo: "2400", + onlyDirect: false, + showReturn: false, + searchExecuted: true, + }; + expect(projectScheduleToBoard(schedule).date).toBe("20260515"); // today +}); +``` + +- [ ] **Step 14.2: Verify test already passes** (we default to `todayYyyymmdd()` in Step 13.3 projection) + +Run: `pnpm vitest run src/shared/state/crossSectionNavigation.test.ts` +If PASS: already satisfied by Task 13. Add the test as an assertion and commit. +If FAIL: implement the clamp explicitly. + +- [ ] **Step 14.3: Commit** + +```bash +git add src/shared/state/crossSectionNavigation.test.ts +git commit -m "Assert date-window clamp on Board ← Schedule projection (TZ 4.1.8-R2)" +``` + +--- + +## Task 15: Playwright e2e — cross-section navigation + deep-link guards + +**Files:** +- Create: `e2e/p1-urls-nav.spec.ts` + +Playwright spec covering the user-facing P1 behaviors end-to-end. Use `pnpm playwright` (or the project's existing e2e runner). + +- [ ] **Step 15.1: Read existing Playwright config + spec patterns** + +Run: `ls e2e/ tests/e2e/ 2>/dev/null; find . -name 'playwright.config*' -not -path './node_modules/*'` + +Identify the project's Playwright config (likely `playwright.config.ts` at repo root or `e2e/playwright.config.ts`) and existing spec conventions (naming, fixtures, base URL). + +- [ ] **Step 15.2: Write the e2e spec** + +```ts +// e2e/p1-urls-nav.spec.ts +import { test, expect } from "@playwright/test"; + +const BASE = process.env.E2E_BASE_URL ?? "http://localhost:8081/ru-ru"; + +test.describe("4.1.2-R11: out-of-range dates redirect to start page", () => { + test("Online-Board: date > +14 days redirects to /onlineboard", async ({ page }) => { + // 2026-05-15 + 30 days = 20260614, outside window + await page.goto(`${BASE}/onlineboard/flight/SU1234-20260614`); + await expect(page).toHaveURL(`${BASE}/onlineboard`); + }); + + test("Schedule: date > +330 days redirects to /schedule", async ({ page }) => { + await page.goto(`${BASE}/schedule/route/MOW-LED-20280101-20280107`); + await expect(page).toHaveURL(`${BASE}/schedule`); + }); +}); + +test.describe("4.1.2-R10: unknown directory shows 404", () => { + test("unknown mode 404s", async ({ page }) => { + const response = await page.goto(`${BASE}/onlineboard/bogusmode/X-20260515`); + // 404 page OR the error route + expect(response?.status()).toBe(404); + }); +}); + +test.describe("4.1.4: breadcrumbs per TZ Table 7", () => { + test("Online-Board start page shows [Главная]", async ({ page }) => { + await page.goto(`${BASE}/onlineboard`); + const crumbs = page.getByTestId("breadcrumbs").locator("li"); + await expect(crumbs).toHaveCount(1); + await expect(crumbs.first()).toContainText("Главная"); + }); + + test("Online-Board search page shows [Главная / Онлайн-Табло]", async ({ page }) => { + await page.goto(`${BASE}/onlineboard/route/MOW-LED-20260515`); + const crumbs = page.getByTestId("breadcrumbs").locator("li"); + await expect(crumbs).toHaveCount(2); + }); + + test("Online-Board details shows [Главная / Онлайн-Табло / Маршрут:city-city]", async ({ page }) => { + await page.goto(`${BASE}/onlineboard/SU1234-20260515?request=onlineboard-route-MOW-LED-20260515`); + const crumbs = page.getByTestId("breadcrumbs").locator("li"); + await expect(crumbs).toHaveCount(3); + await expect(crumbs.last()).toContainText("Маршрут"); + }); +}); + +test.describe("4.1.8: cross-section filter carry-over", () => { + test("Board → Schedule preserves city fields", async ({ page }) => { + // 1. fill board filter with MOW → LED, submit + await page.goto(`${BASE}/onlineboard`); + // ...fill filter, submit + // 2. click Schedule tab + // ... + // 3. assert departure = MOW, arrival = LED, week range auto-set + }); + + test("Schedule → Map does NOT carry filter (map is independent)", async ({ page }) => { + await page.goto(`${BASE}/schedule/route/MOW-LED-20260511-20260517`); + // ...verify schedule filter + await page.goto(`${BASE}/flights-map`); + // ...verify map filter is fresh (default state), NOT populated from schedule + }); +}); +``` + +(Expand each body with actual selector/fill sequences. Use the existing filter form's `aria-label` from recent a11y commits to target it.) + +- [ ] **Step 15.3: Run the e2e spec** + +Run: `pnpm playwright test e2e/p1-urls-nav.spec.ts` +Expected: all PASS. + +- [ ] **Step 15.4: Commit** + +```bash +git add e2e/p1-urls-nav.spec.ts +git commit -m "Add P1 e2e coverage: URL guards + breadcrumbs + cross-section nav per TZ 4.1.2/4/8" +``` + +--- + +## Task 16: Update spec with P1 completion + +**Files:** +- Modify: `docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md` + +- [ ] **Step 16.1: Mark P1 rule rows as Done** + +For every rule row covered by Tasks 1-15, change the `Status` column from `TBD`/`Missing`/`Partial` to `Done `. Use `git log --oneline -40` to find the relevant commit for each rule. + +Rules definitely covered: +- 4.1.2-R1 through R12 (all twelve): Done +- 4.1.3-R1 through R6: Done (after Task 7/8/9) +- 4.1.4-R1 through R4: Done (after Task 10/11/12) +- 4.1.8-R1 through R5: Done (after Task 13/14) +- Plus all rules added in Task 1. + +- [ ] **Step 16.2: Resolve any conflicts encountered** + +Any `Conflict`-status rows that came up during Tasks 7/8/10 (e.g. TZ title says "Информация о рейсе: {number}, {from}-{to}" but Angular shows only flight number) — move the Resolution text into the Conflicts register and mark the rule Done with the chosen resolution. + +- [ ] **Step 16.3: Append merge-log row** + +In the spec's "Merge log" section, add: + +```markdown +| 2026-04-21 | P1 | URLs/breadcrumbs/names/nav | {first-commit-sha} .. {last-commit-sha} | N rules done | +``` + +- [ ] **Step 16.4: Update coverage summary counts** + +Recalculate `Implemented` / `Partial` / `Missing` / `Conflict` / `Done` totals. Commit. + +- [ ] **Step 16.5: Commit** + +```bash +git add docs/superpowers/specs/2026-04-21-online-board-schedule-tz-redesign-design.md +git commit -m "Mark P1 (URLs/breadcrumbs/names/nav) rules Done in TZ audit spec" +``` + +--- + +## Task 17: Finish branch — code review + merge gate + +- [ ] **Step 17.1: Run the full verification suite** + +```bash +pnpm typecheck +pnpm lint +pnpm vitest run +pnpm playwright test e2e/p1-urls-nav.spec.ts +``` + +Expected: all pass. If any fail, diagnose and fix via a follow-up commit BEFORE requesting review. + +- [ ] **Step 17.2: Invoke `superpowers:requesting-code-review`** + +Attach this plan + the spec + the list of new tests as context for the reviewer. + +- [ ] **Step 17.3: Invoke `superpowers:finishing-a-development-branch`** + +After review feedback addressed, use the skill to decide merge strategy. Preferred: rebase P1 commits onto `main`, fast-forward merge, push (with user authorization per CLAUDE.md). + +- [ ] **Step 17.4: After merge, confirm in spec coverage** + +Ensure all merged commits' SHAs in the spec's Merge log and per-rule Done markers reflect the **merged** SHAs (not the pre-rebase ones). Amend the spec if rebasing changed SHAs. + +--- + +## Self-Review + +Run this checklist after writing the plan (done here inline): + +**1. Spec coverage.** Every rule in P1's subsections (4.1.2, 4.1.3, 4.1.4, 4.1.8) has a task: +- URL formation (4.1.2) → Tasks 2, 3, 4, 5, 6, 11 (shared with schedule). +- Page names (4.1.3) → Tasks 7, 8, 9. +- Breadcrumbs (4.1.4) → Tasks 10, 11, 12. +- Cross-section nav (4.1.8) → Tasks 13, 14. +- e2e verification → Task 15. +- Bookkeeping → Tasks 1, 16, 17. + +**2. Placeholder scan.** No TBD/TODO/"implement later". A few comment markers (like "adapt to existing patterns") point to inspection steps, not unfilled code — intentional where the existing route/component file layout needs to be read before writing against it, because file contents differ slightly across the four Online-Board sub-routes. + +**3. Type consistency.** +- `DetailsRequest` defined in Task 3, extended in Task 11. Both cite the same module. ✓ +- `BoardFilterSnapshot`/`ScheduleFilterSnapshot`/`MapFilterSnapshot` defined in Task 13, used in Task 14. ✓ +- `buildDetailsRequestParam` / `parseDetailsRequestParam` used consistently in Tasks 3, 4, 10, 11. ✓ +- `scheduleWindowBounds()` / `mapWindowBounds()` referenced in Tasks 2, 6. ✓ + +**4. Known carry-overs into later plans.** +- P2 (Start pages + first-entry + popular) depends on `crossSectionNavigation.ts` from Task 13 — the hydration wiring in Task 13.5-13.7 sets the pattern, but the first-entry geo defaults are P2's scope. +- P3 (Filter) will extend `BoardFilterSnapshot` / `ScheduleFilterSnapshot` with flight-number-mode fields; the types here are forward-compatible via optional fields. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-21-p1-urls-breadcrumbs-names-nav.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. + +Which approach?