diff --git a/docs/superpowers/plans/2026-04-17-transfer-multi-leg-timeline.md b/docs/superpowers/plans/2026-04-17-transfer-multi-leg-timeline.md new file mode 100644 index 00000000..70b3b3b2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-transfer-multi-leg-timeline.md @@ -0,0 +1,1675 @@ +# Transfer Bar + Multi-Leg Full-Route Timeline (B.5) 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:** Add Angular-parity multi-leg visualization: a carousel-style `FullRouteTimeline` at the top of multi-leg flight details and a `TransferBar` between every pair of consecutive legs. + +**Architecture:** Pure helpers first (`detectStationChange`, `computeTransferMinutes`, `formatMinutesAsDuration`). Then leaf components (`Station`, `StationChange`, `TransferTime`). Then `TransferBar` (the bar between legs) and `Timeline` + `FullRouteTimeline` (the top-of-page carousel). Wire into `OnlineBoardDetailsPage` last. + +**Tech Stack:** React 18, TypeScript, date-fns (`parseISO`, `differenceInMinutes`), Vitest + React Testing Library, existing `IFlightLeg` types (extended with optional `IDuration`). + +--- + +## File Structure + +### New files +- `src/features/online-board/components/FullRouteTimeline/detectStationChange.ts` +- `src/features/online-board/components/FullRouteTimeline/detectStationChange.test.ts` +- `src/features/online-board/components/FullRouteTimeline/Station.tsx` +- `src/features/online-board/components/FullRouteTimeline/Station.test.tsx` +- `src/features/online-board/components/FullRouteTimeline/StationChange.tsx` +- `src/features/online-board/components/FullRouteTimeline/StationChange.test.tsx` +- `src/features/online-board/components/FullRouteTimeline/Timeline.tsx` +- `src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx` +- `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.tsx` +- `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.scss` +- `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.test.tsx` +- `src/features/online-board/components/FullRouteTimeline/index.ts` +- `src/features/online-board/components/TransferBar/computeTransferTime.ts` +- `src/features/online-board/components/TransferBar/computeTransferTime.test.ts` +- `src/features/online-board/components/TransferBar/TransferTime.tsx` +- `src/features/online-board/components/TransferBar/TransferTime.test.tsx` +- `src/features/online-board/components/TransferBar/TransferBar.tsx` +- `src/features/online-board/components/TransferBar/TransferBar.scss` +- `src/features/online-board/components/TransferBar/TransferBar.test.tsx` +- `src/features/online-board/components/TransferBar/index.ts` + +### Modified files +- `src/features/online-board/types.ts` — add `IDuration`, extend `IFlightLeg` +- `src/features/online-board/types.test.ts` — test cases for new types +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — integrate `FullRouteTimeline` and `TransferBar` +- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — add integration tests + +--- + +### Task 1: Extend types with `IDuration` + +**Files:** +- Modify: `src/features/online-board/types.ts` +- Modify: `src/features/online-board/types.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to the existing `describe("online-board types extension", ...)` block in `src/features/online-board/types.test.ts`: + +```typescript + it("IDuration has days/hours/minutes and optional isNegative", () => { + const d: IDuration = { days: 0, hours: 1, minutes: 30, isNegative: true }; + expect(d.days).toBe(0); + expect(d.hours).toBe(1); + expect(d.minutes).toBe(30); + expect(d.isNegative).toBe(true); + }); + + it("IFlightLeg accepts optional estimatedDuration and scheduledDuration", () => { + const leg: Partial = { + estimatedDuration: { days: 0, hours: 1, minutes: 30 }, + scheduledDuration: { days: 0, hours: 1, minutes: 45 }, + }; + expect(leg.estimatedDuration?.hours).toBe(1); + expect(leg.scheduledDuration?.minutes).toBe(45); + }); +``` + +Add `IDuration,` to the import block at the top. + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: FAIL — `IDuration` not exported. + +- [ ] **Step 3: Extend `types.ts`** + +Find the `IDaysOfWeek` interface section; immediately after it, add: + +```typescript +// --------------------------------------------------------------------------- +// Duration +// --------------------------------------------------------------------------- + +/** + * Structured duration for flight timings. `isNegative` flags an uncertain or + * "specifying" state (estimated arrival is earlier than departure). + */ +export interface IDuration { + days: number; + hours: number; + minutes: number; + isNegative?: boolean; +} +``` + +Then extend `IFlightLeg`: after `daysOfWeek?: IDaysOfWeek;` add two more optional fields: + +```typescript + daysOfWeek?: IDaysOfWeek; + estimatedDuration?: IDuration; + scheduledDuration?: IDuration; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/types.ts src/features/online-board/types.test.ts +git commit -m "Extend IFlightLeg with optional estimatedDuration/scheduledDuration" +``` + +--- + +### Task 2: `detectStationChange` helper + +**Files:** +- Create: `src/features/online-board/components/FullRouteTimeline/detectStationChange.ts` +- Create: `src/features/online-board/components/FullRouteTimeline/detectStationChange.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// detectStationChange.test.ts +import { describe, it, expect } from "vitest"; +import { detectStationChange } from "./detectStationChange.js"; +import type { IFlightLegStation } from "../../types.js"; + +function makeStation(overrides: { + cityCode?: string; + airportCode?: string; + terminal?: string; +}): IFlightLegStation { + return { + scheduled: { + airport: "", + airportCode: overrides.airportCode ?? "SVO", + city: "", + cityCode: overrides.cityCode ?? "MOW", + countryCode: "RU", + }, + latest: { + airport: "", + airportCode: overrides.airportCode ?? "SVO", + city: "", + cityCode: overrides.cityCode ?? "MOW", + countryCode: "RU", + }, + dispatch: "", + gate: "", + ...(overrides.terminal !== undefined ? { terminal: overrides.terminal } : { terminal: "" }), + } as IFlightLegStation; +} + +describe("detectStationChange", () => { + it("returns 'noChange' when city/airport/terminal all match", () => { + const a = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "D" }); + const b = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "D" }); + expect(detectStationChange(a, b)).toBe("noChange"); + }); + + it("returns 'city' when cityCode differs", () => { + const a = makeStation({ cityCode: "MOW", airportCode: "SVO" }); + const b = makeStation({ cityCode: "LED", airportCode: "LED" }); + expect(detectStationChange(a, b)).toBe("city"); + }); + + it("returns 'airport' when cities match but airport differs", () => { + const a = makeStation({ cityCode: "MOW", airportCode: "SVO" }); + const b = makeStation({ cityCode: "MOW", airportCode: "DME" }); + expect(detectStationChange(a, b)).toBe("airport"); + }); + + it("returns 'terminal' when airport matches but terminal differs", () => { + const a = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "D" }); + const b = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "F" }); + expect(detectStationChange(a, b)).toBe("terminal"); + }); + + it("returns 'noChange' when terminals are both empty strings", () => { + const a = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "" }); + const b = makeStation({ cityCode: "MOW", airportCode: "SVO", terminal: "" }); + expect(detectStationChange(a, b)).toBe("noChange"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/detectStationChange.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `detectStationChange.ts`** + +```typescript +import type { IFlightLegStation } from "../../types.js"; + +export type StationChange = "city" | "airport" | "terminal" | "noChange"; + +/** + * Detect the kind of transition between two stations by priority: + * city change > airport change > terminal change > no change. + * + * Uses the `scheduled` snapshot for comparisons (stable across updates). + */ +export function detectStationChange( + from: IFlightLegStation, + to: IFlightLegStation, +): StationChange { + if (from.scheduled.cityCode !== to.scheduled.cityCode) return "city"; + if (from.scheduled.airportCode !== to.scheduled.airportCode) return "airport"; + if (from.terminal && to.terminal && from.terminal !== to.terminal) return "terminal"; + return "noChange"; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/detectStationChange.test.ts` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FullRouteTimeline/detectStationChange.ts src/features/online-board/components/FullRouteTimeline/detectStationChange.test.ts +git commit -m "Add detectStationChange helper for multi-leg timeline" +``` + +--- + +### Task 3: `computeTransferTime` helpers + +**Files:** +- Create: `src/features/online-board/components/TransferBar/computeTransferTime.ts` +- Create: `src/features/online-board/components/TransferBar/computeTransferTime.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { computeTransferMinutes, formatMinutesAsDuration } from "./computeTransferTime.js"; + +describe("computeTransferMinutes", () => { + it("returns positive diff in minutes", () => { + const arrival = "2026-04-17T10:00:00Z"; + const departure = "2026-04-17T11:30:00Z"; + expect(computeTransferMinutes(arrival, departure)).toBe(90); + }); + + it("returns 0 when times are identical", () => { + const t = "2026-04-17T10:00:00Z"; + expect(computeTransferMinutes(t, t)).toBe(0); + }); + + it("returns null when departure is before arrival (negative)", () => { + const arrival = "2026-04-17T11:00:00Z"; + const departure = "2026-04-17T10:00:00Z"; + expect(computeTransferMinutes(arrival, departure)).toBeNull(); + }); + + it("returns null when arrival is undefined", () => { + expect(computeTransferMinutes(undefined, "2026-04-17T10:00:00Z")).toBeNull(); + }); + + it("returns null when departure is undefined", () => { + expect(computeTransferMinutes("2026-04-17T10:00:00Z", undefined)).toBeNull(); + }); + + it("returns null for invalid ISO strings", () => { + expect(computeTransferMinutes("not-a-date", "2026-04-17T10:00:00Z")).toBeNull(); + }); +}); + +describe("formatMinutesAsDuration", () => { + it("formats 90 minutes as '1h 30m'", () => { + expect(formatMinutesAsDuration(90)).toBe("1h 30m"); + }); + + it("formats 45 minutes as '45m'", () => { + expect(formatMinutesAsDuration(45)).toBe("45m"); + }); + + it("formats 60 minutes as '1h'", () => { + expect(formatMinutesAsDuration(60)).toBe("1h"); + }); + + it("formats 0 as '0m'", () => { + expect(formatMinutesAsDuration(0)).toBe("0m"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/computeTransferTime.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Create `computeTransferTime.ts`** + +```typescript +import { parseISO, differenceInMinutes, isValid } from "date-fns"; + +/** + * Diff (in minutes) between departure and arrival ISO UTC strings. + * Returns null when either input is missing/invalid, or when result is negative. + */ +export function computeTransferMinutes( + arrivalUtc: string | undefined, + departureUtc: string | undefined, +): number | null { + if (!arrivalUtc || !departureUtc) return null; + const arr = parseISO(arrivalUtc); + const dep = parseISO(departureUtc); + if (!isValid(arr) || !isValid(dep)) return null; + const diff = differenceInMinutes(dep, arr); + if (diff < 0) return null; + return diff; +} + +/** + * Format a minute count as "Nh Mm" (or "Mm" when <1 hour, or "Nh" when no remainder). + */ +export function formatMinutesAsDuration(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/computeTransferTime.test.ts` + +Expected: 10 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/TransferBar/computeTransferTime.ts src/features/online-board/components/TransferBar/computeTransferTime.test.ts +git commit -m "Add computeTransferMinutes and formatMinutesAsDuration helpers" +``` + +--- + +### Task 4: `Station` component + +**Files:** +- Create: `src/features/online-board/components/FullRouteTimeline/Station.tsx` +- Create: `src/features/online-board/components/FullRouteTimeline/Station.test.tsx` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Station } from "./Station.js"; +import type { IFlightLegStation } from "../../types.js"; + +function makeStation(): IFlightLegStation { + return { + scheduled: { + airport: "Sheremetyevo", + airportCode: "SVO", + city: "Moscow", + cityCode: "MOW", + countryCode: "RU", + }, + latest: { + airport: "Sheremetyevo", + airportCode: "SVO", + city: "Moscow", + cityCode: "MOW", + countryCode: "RU", + }, + dispatch: "", + gate: "", + terminal: "D", + } as IFlightLegStation; +} + +describe("Station", () => { + it("renders city, code, and terminal", () => { + render(); + expect(screen.getByText("Moscow")).toBeTruthy(); + expect(screen.getByText("SVO")).toBeTruthy(); + expect(screen.getByText("TD")).toBeTruthy(); + }); + + it("hides terminal line when empty", () => { + const s = { ...makeStation(), terminal: "" } as IFlightLegStation; + render(); + expect(screen.queryByText(/^T/)).toBeNull(); + }); + + it("applies align modifier", () => { + const { container } = render(); + expect(container.firstChild?.nodeType === 1 && (container.firstChild as HTMLElement).className).toContain("station--right"); + }); + + it("applies size modifier", () => { + const { container } = render(); + expect(container.firstChild?.nodeType === 1 && (container.firstChild as HTMLElement).className).toContain("station--large"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/Station.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `Station.tsx`** + +```tsx +import type { FC } from "react"; +import type { IFlightLegStation } from "../../types.js"; + +export type StationAlign = "left" | "right" | "center"; +export type StationSize = "small" | "medium" | "large"; + +export interface StationProps { + station: IFlightLegStation; + align?: StationAlign; + size?: StationSize; +} + +export const Station: FC = ({ station, align = "left", size = "medium" }) => { + const classes = [ + "station", + `station--${align}`, + `station--${size}`, + ].join(" "); + + return ( +
+ {station.scheduled.city} + {station.scheduled.airportCode} + {station.terminal && T{station.terminal}} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/Station.test.tsx` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FullRouteTimeline/Station.tsx src/features/online-board/components/FullRouteTimeline/Station.test.tsx +git commit -m "Add Station component for multi-leg timeline" +``` + +--- + +### Task 5: `StationChange` component + +**Files:** +- Create: `src/features/online-board/components/FullRouteTimeline/StationChange.tsx` +- Create: `src/features/online-board/components/FullRouteTimeline/StationChange.test.tsx` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { StationChange } from "./StationChange.js"; +import type { IFlightLegStation } from "../../types.js"; + +function makeStation(overrides: { + city: string; + cityCode: string; + airportCode: string; + terminal?: string; +}): IFlightLegStation { + return { + scheduled: { + airport: "", + airportCode: overrides.airportCode, + city: overrides.city, + cityCode: overrides.cityCode, + countryCode: "", + }, + latest: { + airport: "", + airportCode: overrides.airportCode, + city: overrides.city, + cityCode: overrides.cityCode, + countryCode: "", + }, + dispatch: "", + gate: "", + terminal: overrides.terminal ?? "", + } as IFlightLegStation; +} + +describe("StationChange", () => { + it("renders single station when noChange", () => { + const s = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "SVO" }); + render(); + expect(screen.getAllByText("Moscow")).toHaveLength(1); + }); + + it("renders both cities with arrow when city changes", () => { + const from = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "SVO" }); + const to = makeStation({ city: "St Petersburg", cityCode: "LED", airportCode: "LED" }); + render(); + expect(screen.getByText("Moscow")).toBeTruthy(); + expect(screen.getByText("St Petersburg")).toBeTruthy(); + expect(screen.getByText("→")).toBeTruthy(); + }); + + it("renders airport codes with arrow when airport changes (same city)", () => { + const from = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "SVO" }); + const to = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "DME" }); + render(); + expect(screen.getByText("SVO")).toBeTruthy(); + expect(screen.getByText("DME")).toBeTruthy(); + expect(screen.getByText("→")).toBeTruthy(); + }); + + it("renders terminal difference when only terminal changes", () => { + const from = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "SVO", terminal: "D" }); + const to = makeStation({ city: "Moscow", cityCode: "MOW", airportCode: "SVO", terminal: "F" }); + render(); + expect(screen.getByText(/SVO\/D/)).toBeTruthy(); + expect(screen.getByText(/SVO\/F/)).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/StationChange.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `StationChange.tsx`** + +```tsx +import type { FC } from "react"; +import type { IFlightLegStation } from "../../types.js"; +import { Station } from "./Station.js"; +import { detectStationChange } from "./detectStationChange.js"; + +export interface StationChangeProps { + from: IFlightLegStation; + to: IFlightLegStation; +} + +function formatAirportWithTerminal(station: IFlightLegStation): string { + const code = station.scheduled.airportCode; + return station.terminal ? `${code}/${station.terminal}` : code; +} + +export const StationChange: FC = ({ from, to }) => { + const changeType = detectStationChange(from, to); + + if (changeType === "noChange") { + return ; + } + + if (changeType === "city") { + return ( +
+ + + +
+ ); + } + + // airport or terminal — same city + return ( +
+ {from.scheduled.city} + {formatAirportWithTerminal(from)} + + {formatAirportWithTerminal(to)} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/StationChange.test.tsx` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FullRouteTimeline/StationChange.tsx src/features/online-board/components/FullRouteTimeline/StationChange.test.tsx +git commit -m "Add StationChange component for multi-leg timeline" +``` + +--- + +### Task 6: `TransferTime` component + +**Files:** +- Create: `src/features/online-board/components/TransferBar/TransferTime.tsx` +- Create: `src/features/online-board/components/TransferBar/TransferTime.test.tsx` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TransferTime } from "./TransferTime.js"; + +describe("TransferTime", () => { + it("renders formatted duration for valid inputs", () => { + render( + , + ); + expect(screen.getByTestId("transfer-time")).toBeTruthy(); + expect(screen.getByText("1h 30m")).toBeTruthy(); + }); + + it("returns null when arrivalUtc is missing", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("returns null when departure is before arrival", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/TransferTime.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `TransferTime.tsx`** + +```tsx +import type { FC } from "react"; +import { computeTransferMinutes, formatMinutesAsDuration } from "./computeTransferTime.js"; + +export interface TransferTimeProps { + arrivalUtc: string; + departureUtc: string; +} + +export const TransferTime: FC = ({ arrivalUtc, departureUtc }) => { + const minutes = computeTransferMinutes(arrivalUtc, departureUtc); + if (minutes === null) return null; + + return ( + + {formatMinutesAsDuration(minutes)} + + ); +}; +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/TransferTime.test.tsx` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/TransferBar/TransferTime.tsx src/features/online-board/components/TransferBar/TransferTime.test.tsx +git commit -m "Add TransferTime component for layover duration display" +``` + +--- + +### Task 7: `TransferBar` component + SCSS + index + +**Files:** +- Create: `src/features/online-board/components/TransferBar/TransferBar.tsx` +- Create: `src/features/online-board/components/TransferBar/TransferBar.scss` +- Create: `src/features/online-board/components/TransferBar/TransferBar.test.tsx` +- Create: `src/features/online-board/components/TransferBar/index.ts` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TransferBar } from "./TransferBar.js"; +import type { IFlightLeg } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +// Mock StationChange (imported from FullRouteTimeline/) +vi.mock("../FullRouteTimeline/StationChange.js", () => ({ + StationChange: ({ from, to }: { from: { scheduled: { cityCode: string } }; to: { scheduled: { cityCode: string } } }) => ( +
{from.scheduled.cityCode}-{to.scheduled.cityCode}
+ ), +})); + +function makeLeg(overrides: { arrCity?: string; depCity?: string; arrUtc?: string; depUtc?: string; arrLocal?: string; depLocal?: string }): IFlightLeg { + return { + id: "L", routeType: "Direct" as never, + arrival: { + scheduled: { airport: "", airportCode: "LED", city: "SPB", cityCode: overrides.arrCity ?? "LED", countryCode: "RU" }, + latest: { airport: "", airportCode: "LED", city: "SPB", cityCode: overrides.arrCity ?? "LED", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: overrides.arrLocal ?? "12:30", localTime: "", tzOffset: 0, utc: overrides.arrUtc ?? "2026-04-17T10:00:00Z" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: "SVO", city: "Moscow", cityCode: overrides.depCity ?? "MOW", countryCode: "RU" }, + latest: { airport: "", airportCode: "SVO", city: "Moscow", cityCode: overrides.depCity ?? "MOW", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: overrides.depLocal ?? "14:00", localTime: "", tzOffset: 0, utc: overrides.depUtc ?? "2026-04-17T11:30:00Z" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", index: 0, operatingBy: {}, status: "Scheduled", updated: "", + } as IFlightLeg; +} + +describe("TransferBar", () => { + it("renders with data-testid=transfer-bar", () => { + const leg = makeLeg({}); + const next = makeLeg({ depCity: "LED" }); + render(); + expect(screen.getByTestId("transfer-bar")).toBeTruthy(); + }); + + it("renders label and times", () => { + const leg = makeLeg({ arrLocal: "10:00" }); + const next = makeLeg({ depLocal: "12:30", depCity: "MOW" }); + render(); + expect(screen.getByText("SHARED.INTERMEDIATE-LANDING")).toBeTruthy(); + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.getByText("12:30")).toBeTruthy(); + }); + + it("renders TransferTime", () => { + const leg = makeLeg({}); + const next = makeLeg({}); + render(); + expect(screen.getByTestId("transfer-time")).toBeTruthy(); + }); + + it("renders StationChange when station changes", () => { + const leg = makeLeg({ arrCity: "LED" }); + const next = makeLeg({ depCity: "MOW" }); + render(); + expect(screen.getByTestId("station-change")).toBeTruthy(); + }); + + it("omits station change when arrival/departure match (noChange)", () => { + const leg = makeLeg({ arrCity: "MOW" }); + const next = makeLeg({ depCity: "MOW" }); + // Same city -> noChange -> station-change block still renders noChange variant but the outer `--separated` modifier should NOT apply + render(); + const bar = screen.getByTestId("transfer-bar"); + expect(bar.className).not.toMatch(/--separated/); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/TransferBar.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `TransferBar.scss`** + +```scss +.transfer-bar { + display: flex; + align-items: center; + gap: 16px; + background: #e3f0ff; + border: 1px solid #b8d4f0; + padding: 12px 16px; + min-height: 50px; + font-size: 13px; + + &--separated { + margin: 12px 0; + border-radius: 3px; + } + + &__icon svg { + fill: #ff9000; + width: 20px; + height: 20px; + } + + &__content { + flex: 1; + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + + &__type { font-weight: 500; } + + &__times { color: #1a3a5c; } + + @media (max-width: 768px) { + &__content { + flex-direction: column; + align-items: flex-start; + } + } +} + +.transfer-time { + font-weight: 500; + color: #ff9000; +} +``` + +- [ ] **Step 4: Create `TransferBar.tsx`** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IFlightLeg } from "../../types.js"; +import { StationChange } from "../FullRouteTimeline/StationChange.js"; +import { detectStationChange } from "../FullRouteTimeline/detectStationChange.js"; +import { TransferTime } from "./TransferTime.js"; +import "./TransferBar.scss"; + +export interface TransferBarProps { + leg: IFlightLeg; + nextLeg: IFlightLeg; + viewType: "Onlineboard" | "Schedule"; +} + +function arrivalLocal(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string { + const t = leg.arrival.times; + if (viewType === "Schedule") return t.scheduledArrival.local; + return t.actualBlockOn?.local ?? t.scheduledArrival.local; +} + +function departureLocal(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string { + const t = leg.departure.times; + if (viewType === "Schedule") return t.scheduledDeparture.local; + return t.actualBlockOff?.local ?? t.scheduledDeparture.local; +} + +export const TransferBar: FC = ({ leg, nextLeg, viewType }) => { + const { t } = useTranslation(); + const stationChange = detectStationChange(leg.arrival, nextLeg.departure); + const separated = stationChange !== "noChange"; + + const className = `transfer-bar${separated ? " transfer-bar--separated" : ""}`; + + return ( +
+ +
+ {t("SHARED.INTERMEDIATE-LANDING")} +
+ +
+
+ {arrivalLocal(leg, viewType)} + + {departureLocal(nextLeg, viewType)} +
+ {separated && ( +
+ +
+ )} +
+
+ ); +}; +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { TransferBar } from "./TransferBar.js"; +export type { TransferBarProps } from "./TransferBar.js"; +``` + +- [ ] **Step 6: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/TransferBar/TransferBar.test.tsx` + +Expected: 5 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/TransferBar/ +git commit -m "Add TransferBar component for multi-leg transfer info" +``` + +--- + +### Task 8: `Timeline` component + +**Files:** +- Create: `src/features/online-board/components/FullRouteTimeline/Timeline.tsx` +- Create: `src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Timeline } from "./Timeline.js"; +import type { IFlightLeg } from "../../types.js"; + +function makeLeg(i: number, overrides: { isNegative?: boolean } = {}): IFlightLeg { + return { + id: `L${i}`, routeType: "Direct" as never, + arrival: { + scheduled: { airport: "", airportCode: `A${i}`, city: `CityA${i}`, cityCode: `CA${i}`, countryCode: "" }, + latest: { airport: "", airportCode: `A${i}`, city: `CityA${i}`, cityCode: `CA${i}`, countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: `a${i}`, localTime: "", tzOffset: 0, utc: `2026-04-17T${10 + i}:00:00Z` } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: `D${i}`, city: `CityD${i}`, cityCode: `CD${i}`, countryCode: "" }, + latest: { airport: "", airportCode: `D${i}`, city: `CityD${i}`, cityCode: `CD${i}`, countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: `d${i}`, localTime: "", tzOffset: 0, utc: `2026-04-17T${8 + i}:00:00Z` } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: `${i}h`, index: i, operatingBy: {}, status: "Scheduled", updated: "", + ...(overrides.isNegative ? { estimatedDuration: { days: 0, hours: -1, minutes: 0, isNegative: true } } : {}), + } as IFlightLeg; +} + +describe("Timeline", () => { + it("renders times for 2 legs at index 0", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + expect(screen.getByText("d0")).toBeTruthy(); + expect(screen.getByText("a0")).toBeTruthy(); + expect(screen.getByText("d1")).toBeTruthy(); + expect(screen.getByText("a1")).toBeTruthy(); + }); + + it("prev arrow disabled on first page", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + // prev button not present when index=0 + expect(screen.queryByTestId("timeline-prev")).toBeNull(); + }); + + it("next arrow disabled on last pair", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + expect(screen.queryByTestId("timeline-next")).toBeNull(); + }); + + it("shows next arrow and advances index with 3 legs", () => { + const legs = [makeLeg(0), makeLeg(1), makeLeg(2)]; + render(); + expect(screen.getByTestId("timeline-next")).toBeTruthy(); + // initial: legs 0 and 1 visible + expect(screen.getByText("d0")).toBeTruthy(); + fireEvent.click(screen.getByTestId("timeline-next")); + // After advance: legs 1 and 2 visible + expect(screen.getByText("d1")).toBeTruthy(); + expect(screen.getByText("d2")).toBeTruthy(); + expect(screen.queryByText("d0")).toBeNull(); + }); + + it("applies specifying class when canChange and estimatedDuration.isNegative", () => { + const legs = [makeLeg(0, { isNegative: true }), makeLeg(1)]; + const { container } = render(); + expect(container.querySelector(".timeline-section__duration--specifying")).toBeTruthy(); + }); + + it("prefers latest times when canChange=true", () => { + const legs = [makeLeg(0), makeLeg(1)]; + // no actual times set -> falls back to scheduled (a0/d0) + render(); + expect(screen.getByText("d0")).toBeTruthy(); + expect(screen.getByText("a0")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `Timeline.tsx`** + +```tsx +import { type FC, useState } from "react"; +import type { IFlightLeg } from "../../types.js"; +import { Station } from "./Station.js"; +import { StationChange } from "./StationChange.js"; + +export interface TimelineProps { + legs: IFlightLeg[]; + canChange: boolean; +} + +function depTime(leg: IFlightLeg, canChange: boolean): string { + const t = leg.departure.times; + if (canChange) return t.actualBlockOff?.local ?? t.scheduledDeparture.local; + return t.scheduledDeparture.local; +} + +function arrTime(leg: IFlightLeg, canChange: boolean): string { + const t = leg.arrival.times; + if (canChange) return t.actualBlockOn?.local ?? t.scheduledArrival.local; + return t.scheduledArrival.local; +} + +function isSpecifying(leg: IFlightLeg, canChange: boolean): boolean { + return canChange && leg.estimatedDuration?.isNegative === true; +} + +interface SectionProps { + legNumber: number; + duration: string; + specifying: boolean; +} + +const Section: FC = ({ legNumber, duration, specifying }) => ( +
+
+ {legNumber} + + {duration} + +
+
+); + +export const Timeline: FC = ({ legs, canChange }) => { + const [index, setIndex] = useState(0); + const lastPairStart = Math.max(0, legs.length - 2); + const currentLeg = legs[index]; + const nextLeg = legs[index + 1]; + + if (!currentLeg || !nextLeg) return null; + + return ( +
+ {index > 0 && ( + + )} + +
+
+ {depTime(currentLeg, canChange)} +
+ {arrTime(currentLeg, canChange)} +
+ {depTime(nextLeg, canChange)} + {arrTime(nextLeg, canChange)} +
+ +
+ + + +
+
+ + {index < lastPairStart && ( + + )} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx` + +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FullRouteTimeline/Timeline.tsx src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx +git commit -m "Add Timeline component with 2-leg carousel for multi-leg flights" +``` + +--- + +### Task 9: `FullRouteTimeline` wrapper + SCSS + index + +**Files:** +- Create: `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.tsx` +- Create: `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.scss` +- Create: `src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.test.tsx` +- Create: `src/features/online-board/components/FullRouteTimeline/index.ts` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FullRouteTimeline } from "./FullRouteTimeline.js"; +import type { IFlightLeg } from "../../types.js"; + +function makeLeg(i: number): IFlightLeg { + return { + id: `L${i}`, routeType: "Direct" as never, + arrival: { + scheduled: { airport: "", airportCode: `A${i}`, city: `CA${i}`, cityCode: `CA${i}`, countryCode: "" }, + latest: { airport: "", airportCode: `A${i}`, city: `CA${i}`, cityCode: `CA${i}`, countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: `a${i}`, localTime: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: `D${i}`, city: `CD${i}`, cityCode: `CD${i}`, countryCode: "" }, + latest: { airport: "", airportCode: `D${i}`, city: `CD${i}`, cityCode: `CD${i}`, countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: `d${i}`, localTime: "", tzOffset: 0, utc: "" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", index: i, operatingBy: {}, status: "Scheduled", updated: "", + } as IFlightLeg; +} + +describe("FullRouteTimeline", () => { + it("returns null when fewer than 2 legs", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders container with data-testid=full-route-timeline when 2+ legs", () => { + render(); + expect(screen.getByTestId("full-route-timeline")).toBeTruthy(); + }); + + it("renders the inner Timeline", () => { + render(); + // Timeline's content — first leg's departure time + expect(screen.getByText("d0")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `FullRouteTimeline.scss`** + +```scss +.full-route-timeline { + background: #fff; + border-radius: 8px; + padding: 16px 24px; + margin-bottom: 16px; + + @media (max-width: 768px) { + display: none; + } +} + +.timeline { + display: flex; + align-items: center; + gap: 16px; + + &__arrow { + background: transparent; + border: 1px solid #d0dae5; + border-radius: 50%; + width: 32px; + height: 32px; + cursor: pointer; + color: #2060c0; + font-size: 16px; + + &:disabled { opacity: 0.3; cursor: not-allowed; } + } + + &__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + + &--times { font-size: 18px; font-weight: 500; color: #1a3a5c; } + &--stations { font-size: 14px; color: #666; } + } +} + +.timeline-section { + position: relative; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + + &__separator { + flex: 1; + height: 1px; + background: #d0dae5; + } + + &__number { + position: absolute; + top: -20px; + background: #e3f0ff; + color: #2060c0; + border-radius: 10px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + } + + &__duration { + font-size: 12px; + color: #666; + padding: 0 8px; + background: #fff; + + &--specifying { color: #ff9000; } + } +} + +.station { + display: flex; + flex-direction: column; + gap: 2px; + + &--right { align-items: flex-end; text-align: right; } + &--center { align-items: center; text-align: center; } + + &__city { font-size: 14px; font-weight: 500; color: #1a3a5c; } + &__code { font-size: 12px; color: #666; } + &__terminal { font-size: 11px; color: #999; } + + &--large { .station__city { font-size: 22px; } } + &--small { .station__city { font-size: 12px; } } +} + +.station-change { + display: flex; + align-items: center; + gap: 8px; + + &__arrow { color: #2060c0; } + &__city { font-size: 12px; color: #666; } + &__code { font-size: 14px; font-weight: 500; color: #1a3a5c; } +} +``` + +- [ ] **Step 4: Create `FullRouteTimeline.tsx`** + +```tsx +import type { FC } from "react"; +import type { IFlightLeg } from "../../types.js"; +import { Timeline } from "./Timeline.js"; +import "./FullRouteTimeline.scss"; + +export interface FullRouteTimelineProps { + legs: IFlightLeg[]; + viewType: "Onlineboard" | "Schedule"; +} + +export const FullRouteTimeline: FC = ({ legs, viewType }) => { + if (legs.length < 2) return null; + return ( +
+ +
+ ); +}; +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { FullRouteTimeline } from "./FullRouteTimeline.js"; +export type { FullRouteTimelineProps } from "./FullRouteTimeline.js"; +``` + +- [ ] **Step 6: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.test.tsx` + +Expected: 3 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.tsx src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.scss src/features/online-board/components/FullRouteTimeline/FullRouteTimeline.test.tsx src/features/online-board/components/FullRouteTimeline/index.ts +git commit -m "Add FullRouteTimeline wrapper component" +``` + +--- + +### Task 10: Wire into `OnlineBoardDetailsPage` + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +- [ ] **Step 1: Add integration tests** + +Append to the outer describe block in `OnlineBoardDetailsPage.test.tsx`: + +```tsx +describe("multi-leg timeline integration", () => { + it("does not render FullRouteTimeline for Direct flights", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.queryByTestId("full-route-timeline")).toBeNull(); + }); + + it("renders FullRouteTimeline for MultiLeg flights", () => { + const legA = mockFlight.leg; + const legB = { ...mockFlight.leg, index: 1 }; + const multiLeg = { ...mockFlight, routeType: "MultiLeg" as const, legs: [legA, legB], leg: undefined }; + delete (multiLeg as { leg?: unknown }).leg; + mockState = { flight: multiLeg as never, allFlights: [multiLeg as never], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.getByTestId("full-route-timeline")).toBeTruthy(); + }); + + it("renders TransferBar between legs for MultiLeg flights", () => { + const legA = mockFlight.leg; + const legB = { ...mockFlight.leg, index: 1 }; + const multiLeg = { ...mockFlight, routeType: "MultiLeg" as const, legs: [legA, legB], leg: undefined }; + delete (multiLeg as { leg?: unknown }).leg; + mockState = { flight: multiLeg as never, allFlights: [multiLeg as never], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + // Exactly one transfer bar (between 2 legs) + expect(screen.getAllByTestId("transfer-bar")).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect failure** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: the 3 new tests fail. + +- [ ] **Step 3: Update imports in `OnlineBoardDetailsPage.tsx`** + +Add near other component imports: + +```tsx +import { FullRouteTimeline } from "./FullRouteTimeline/index.js"; +import { TransferBar } from "./TransferBar/index.js"; +``` + +- [ ] **Step 4: Render `FullRouteTimeline` for MultiLeg flights** + +In the main happy-path return, find the beginning of the `.flight-details` div: + +```tsx +
+ {/* Connection status */} +
+``` + +Inject `` just before the connection-status div: + +```tsx +
+ {displayFlight.routeType === "MultiLeg" && ( + + )} + + {/* Connection status */} +
+``` + +- [ ] **Step 5: Update `FlightLegs` to interleave `TransferBar`** + +Find the `FlightLegs` function definition (around line 45). Its signature takes `{ legs }` but we need `viewType` too. Rewrite as: + +```tsx +function FlightLegs({ legs, viewType }: { legs: IFlightLeg[]; viewType: "Onlineboard" | "Schedule" }): JSX.Element { + return ( +
+ {legs.map((leg, i) => ( + +
+
+ Leg {leg.index + 1} + {leg.status} +
+ +
+
+ + {leg.departure.scheduled.airportCode} + + + {leg.departure.scheduled.airport} + + + {leg.departure.scheduled.city} + + {leg.departure.terminal && ( + + Terminal {leg.departure.terminal} + + )} + {leg.departure.gate && ( + Gate {leg.departure.gate} + )} + + {leg.departure.times.scheduledDeparture.local} + + {leg.departure.times.actualBlockOff && ( + + Actual: {leg.departure.times.actualBlockOff.local} + + )} +
+ +
+ {leg.flyingTime} +
+ +
+ + {leg.arrival.scheduled.airportCode} + + + {leg.arrival.scheduled.airport} + + + {leg.arrival.scheduled.city} + + {leg.arrival.terminal && ( + + Terminal {leg.arrival.terminal} + + )} + {leg.arrival.bagBelt && ( + + Baggage belt {leg.arrival.bagBelt} + + )} + + {leg.arrival.times.scheduledArrival.local} + + {leg.arrival.times.actualBlockOn && ( + + Actual: {leg.arrival.times.actualBlockOn.local} + + )} +
+
+ + {leg.equipment.name && ( +
+ Aircraft: {leg.equipment.name} + {leg.equipment.code ? ` (${leg.equipment.code})` : ""} +
+ )} + +
+ {i < legs.length - 1 && ( + + )} +
+ ))} +
+ ); +} +``` + +Also add `import { Fragment } from "react";` to the top if not already present (or use a namespace import; check the file's existing `import { useCallback, type FC } from "react";` line and add `Fragment` to it). + +Also update `FlightLegs` call site in the main return: + +Find `` and change to: +```tsx + +``` + +- [ ] **Step 6: Verify tests pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: all tests pass. + +- [ ] **Step 7: Full suite + typecheck** + +Run: `pnpm test` +Run: `pnpm typecheck` + +Expected: no new failures or type errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +git commit -m "Wire FullRouteTimeline and TransferBar into OnlineBoardDetailsPage" +``` + +--- + +### Task 11: Manual Browser Verification + +**Files:** None — observational. + +- [ ] **Step 1: Ensure dev:full is running** + +Run `pnpm dev:full`. Verify `/api/appSettings` returns 200. + +- [ ] **Step 2: Playwright verification script** + +```bash +cat << 'SCRIPT' | npx tsx --input-type=module - +import { chromium } from "@playwright/test"; +import { mockAngularAPIs } from "./tests/e2e-angular/support/angular-api-mock.js"; + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); +await mockAngularAPIs(page); + +await page.route("**/onlineboard/details*", (route) => { + const mkLeg = (i, dep, arr, depUtc, arrUtc, depLocal, arrLocal) => ({ + index: i, flyingTime: "2h", status: "Scheduled", updated: "", dayChange: 0, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + departure: { + scheduled: { airport: dep, airportCode: dep, city: dep, cityCode: dep, countryCode: "RU" }, + latest: { airport: dep, airportCode: dep, city: dep, cityCode: dep, countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: depLocal, localTime: depLocal, tzOffset: 3, utc: depUtc } }, + }, + arrival: { + scheduled: { airport: arr, airportCode: arr, city: arr, cityCode: arr, countryCode: "RU" }, + latest: { airport: arr, airportCode: arr, city: arr, cityCode: arr, countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: arrLocal, localTime: arrLocal, tzOffset: 3, utc: arrUtc } }, + }, + equipment: {}, + }); + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + data: { + partners: [], + routes: [{ + id: "SU0022-X", routeType: "MultiLeg", flyingTime: "5h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417", dateLT: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + legs: [ + mkLeg(0, "SVO", "LED", "2026-04-17T07:00:00Z", "2026-04-17T09:00:00Z", "10:00", "12:00"), + mkLeg(1, "LED", "OVB", "2026-04-17T10:00:00Z", "2026-04-17T12:00:00Z", "13:00", "15:00"), + ], + }], + daysOfFlight: ["20260417"], + }, + }), + }); +}); + +await page.goto("http://localhost:8080/ru/onlineboard/SU0022-20260417", { waitUntil: "networkidle" }); +await page.waitForTimeout(3000); + +const timeline = await page.locator('[data-testid="full-route-timeline"]').count(); +const transfers = await page.locator('[data-testid="transfer-bar"]').count(); +const stationChange = await page.locator('[data-testid="station-change"]').count(); +const transferTime = await page.locator('[data-testid="transfer-time"]').count(); +console.log("timeline:", timeline, "transfers:", transfers, "stationChange:", stationChange, "transferTime:", transferTime); + +await page.screenshot({ path: "/tmp/b5-verify.png", fullPage: true }); + +await browser.close(); +SCRIPT +``` + +Expected: `timeline: 1, transfers: 1, stationChange: 1+ (one in timeline + one in transfer-bar if separated), transferTime: 1`. + +- [ ] **Step 3: No commit — observational**