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?