# 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 `<head>` 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<DetailsRequest>([
    { 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 <Navigate to={`/${locale}/onlineboard`} replace />;
}
```

(Exact import and component depend on router — use `<Navigate>` 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/<route>/...
git commit -m "Enforce [-1, +14] date-window guard on Online-Board <route> 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: <DetailsBackButton locale={locale} />,
  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 `<Link>` 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 <first-7-chars-of-commit-SHA>`. 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?
