# 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<IFlightLeg> = {
      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(<Station station={makeStation()} />);
    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(<Station station={s} />);
    expect(screen.queryByText(/^T/)).toBeNull();
  });

  it("applies align modifier", () => {
    const { container } = render(<Station station={makeStation()} align="right" />);
    expect(container.firstChild?.nodeType === 1 && (container.firstChild as HTMLElement).className).toContain("station--right");
  });

  it("applies size modifier", () => {
    const { container } = render(<Station station={makeStation()} size="large" />);
    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<StationProps> = ({ station, align = "left", size = "medium" }) => {
  const classes = [
    "station",
    `station--${align}`,
    `station--${size}`,
  ].join(" ");

  return (
    <div className={classes}>
      <span className="station__city">{station.scheduled.city}</span>
      <span className="station__code">{station.scheduled.airportCode}</span>
      {station.terminal && <span className="station__terminal">T{station.terminal}</span>}
    </div>
  );
};
```

- [ ] **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(<StationChange from={s} to={s} />);
    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(<StationChange from={from} to={to} />);
    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(<StationChange from={from} to={to} />);
    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(<StationChange from={from} to={to} />);
    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<StationChangeProps> = ({ from, to }) => {
  const changeType = detectStationChange(from, to);

  if (changeType === "noChange") {
    return <Station station={from} />;
  }

  if (changeType === "city") {
    return (
      <div className="station-change station-change--city">
        <Station station={from} />
        <span className="station-change__arrow" aria-hidden="true">→</span>
        <Station station={to} />
      </div>
    );
  }

  // airport or terminal — same city
  return (
    <div className={`station-change station-change--${changeType}`}>
      <span className="station-change__city">{from.scheduled.city}</span>
      <span className="station-change__code">{formatAirportWithTerminal(from)}</span>
      <span className="station-change__arrow" aria-hidden="true">→</span>
      <span className="station-change__code">{formatAirportWithTerminal(to)}</span>
    </div>
  );
};
```

- [ ] **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(
      <TransferTime
        arrivalUtc="2026-04-17T10:00:00Z"
        departureUtc="2026-04-17T11:30:00Z"
      />,
    );
    expect(screen.getByTestId("transfer-time")).toBeTruthy();
    expect(screen.getByText("1h 30m")).toBeTruthy();
  });

  it("returns null when arrivalUtc is missing", () => {
    const { container } = render(
      <TransferTime arrivalUtc="" departureUtc="2026-04-17T11:30:00Z" />,
    );
    expect(container.firstChild).toBeNull();
  });

  it("returns null when departure is before arrival", () => {
    const { container } = render(
      <TransferTime
        arrivalUtc="2026-04-17T11:30:00Z"
        departureUtc="2026-04-17T10:00:00Z"
      />,
    );
    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<TransferTimeProps> = ({ arrivalUtc, departureUtc }) => {
  const minutes = computeTransferMinutes(arrivalUtc, departureUtc);
  if (minutes === null) return null;

  return (
    <span className="transfer-time" data-testid="transfer-time">
      {formatMinutesAsDuration(minutes)}
    </span>
  );
};
```

- [ ] **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 } } }) => (
    <div data-testid="station-change">{from.scheduled.cityCode}-{to.scheduled.cityCode}</div>
  ),
}));

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(<TransferBar leg={leg} nextLeg={next} viewType="Onlineboard" />);
    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(<TransferBar leg={leg} nextLeg={next} viewType="Onlineboard" />);
    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(<TransferBar leg={leg} nextLeg={next} viewType="Onlineboard" />);
    expect(screen.getByTestId("transfer-time")).toBeTruthy();
  });

  it("renders StationChange when station changes", () => {
    const leg = makeLeg({ arrCity: "LED" });
    const next = makeLeg({ depCity: "MOW" });
    render(<TransferBar leg={leg} nextLeg={next} viewType="Onlineboard" />);
    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(<TransferBar leg={leg} nextLeg={next} viewType="Onlineboard" />);
    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<TransferBarProps> = ({ 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 (
    <div className={className} data-testid="transfer-bar">
      <div className="transfer-bar__icon" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="20" height="20">
          <path d="M3 12h15l-4-4m4 4-4 4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </div>
      <div className="transfer-bar__content">
        <span className="transfer-bar__type">{t("SHARED.INTERMEDIATE-LANDING")}</span>
        <div className="transfer-bar__duration">
          <TransferTime
            arrivalUtc={leg.arrival.times.scheduledArrival.utc}
            departureUtc={nextLeg.departure.times.scheduledDeparture.utc}
          />
        </div>
        <div className="transfer-bar__times">
          <span>{arrivalLocal(leg, viewType)}</span>
          <span> — </span>
          <span>{departureLocal(nextLeg, viewType)}</span>
        </div>
        {separated && (
          <div className="transfer-bar__station-change">
            <StationChange from={leg.arrival} to={nextLeg.departure} />
          </div>
        )}
      </div>
    </div>
  );
};
```

- [ ] **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(<Timeline legs={legs} canChange={false} />);
    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(<Timeline legs={legs} canChange={false} />);
    // 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(<Timeline legs={legs} canChange={false} />);
    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(<Timeline legs={legs} canChange={false} />);
    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(<Timeline legs={legs} canChange={true} />);
    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(<Timeline legs={legs} canChange={true} />);
    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<SectionProps> = ({ legNumber, duration, specifying }) => (
  <div className="timeline-section">
    <div className="timeline-section__separator" />
    <span className="timeline-section__number">{legNumber}</span>
    <span
      className={`timeline-section__duration${
        specifying ? " timeline-section__duration--specifying" : ""
      }`}
    >
      {duration}
    </span>
    <div className="timeline-section__separator" />
  </div>
);

export const Timeline: FC<TimelineProps> = ({ 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 (
    <div className="timeline">
      {index > 0 && (
        <button
          type="button"
          className="timeline__arrow"
          data-testid="timeline-prev"
          onClick={() => setIndex((i) => Math.max(0, i - 1))}
          aria-label="Previous legs"
        >
          {"\u2039"}
        </button>
      )}

      <div className="timeline__content">
        <div className="timeline__row timeline__row--times">
          <span>{depTime(currentLeg, canChange)}</span>
          <Section
            legNumber={index + 1}
            duration={currentLeg.flyingTime}
            specifying={isSpecifying(currentLeg, canChange)}
          />
          <span>{arrTime(currentLeg, canChange)}</span>
          <Section
            legNumber={index + 2}
            duration={nextLeg.flyingTime}
            specifying={isSpecifying(nextLeg, canChange)}
          />
          <span>{depTime(nextLeg, canChange)}</span>
          <span>{arrTime(nextLeg, canChange)}</span>
        </div>

        <div className="timeline__row timeline__row--stations">
          <Station station={currentLeg.departure} align="left" size="medium" />
          <StationChange from={currentLeg.arrival} to={nextLeg.departure} />
          <Station station={nextLeg.arrival} align="right" size="medium" />
        </div>
      </div>

      {index < lastPairStart && (
        <button
          type="button"
          className="timeline__arrow"
          data-testid="timeline-next"
          onClick={() => setIndex((i) => Math.min(lastPairStart, i + 1))}
          aria-label="Next legs"
        >
          {"\u203a"}
        </button>
      )}
    </div>
  );
};
```

- [ ] **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(<FullRouteTimeline legs={[makeLeg(0)]} viewType="Onlineboard" />);
    expect(container.firstChild).toBeNull();
  });

  it("renders container with data-testid=full-route-timeline when 2+ legs", () => {
    render(<FullRouteTimeline legs={[makeLeg(0), makeLeg(1)]} viewType="Onlineboard" />);
    expect(screen.getByTestId("full-route-timeline")).toBeTruthy();
  });

  it("renders the inner Timeline", () => {
    render(<FullRouteTimeline legs={[makeLeg(0), makeLeg(1)]} viewType="Onlineboard" />);
    // 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<FullRouteTimelineProps> = ({ legs, viewType }) => {
  if (legs.length < 2) return null;
  return (
    <section className="full-route-timeline" data-testid="full-route-timeline">
      <Timeline legs={legs} canChange={viewType === "Onlineboard"} />
    </section>
  );
};
```

- [ ] **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(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
    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(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
    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(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
    // 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
        <div className="flight-details" data-testid="flight-details">
          {/* Connection status */}
          <div className="flight-details__status" data-testid="connection-status">
```

Inject `<FullRouteTimeline>` just before the connection-status div:

```tsx
        <div className="flight-details" data-testid="flight-details">
          {displayFlight.routeType === "MultiLeg" && (
            <FullRouteTimeline legs={displayFlight.legs} viewType="Onlineboard" />
          )}

          {/* Connection status */}
          <div className="flight-details__status" data-testid="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 (
    <div className="flight-details__legs" data-testid="flight-legs">
      {legs.map((leg, i) => (
        <Fragment key={leg.index}>
          <div className="flight-details__leg" data-testid={`flight-leg-${leg.index}`}>
            <div className="flight-details__leg-header">
              <span className="flight-details__leg-index">Leg {leg.index + 1}</span>
              <span className="flight-details__leg-status">{leg.status}</span>
            </div>

            <div className="flight-details__leg-stations">
              <div className="flight-details__leg-departure">
                <span className="flight-details__station-code">
                  {leg.departure.scheduled.airportCode}
                </span>
                <span className="flight-details__station-name">
                  {leg.departure.scheduled.airport}
                </span>
                <span className="flight-details__station-city">
                  {leg.departure.scheduled.city}
                </span>
                {leg.departure.terminal && (
                  <span className="flight-details__terminal">
                    Terminal {leg.departure.terminal}
                  </span>
                )}
                {leg.departure.gate && (
                  <span className="flight-details__gate">Gate {leg.departure.gate}</span>
                )}
                <span className="flight-details__time">
                  {leg.departure.times.scheduledDeparture.local}
                </span>
                {leg.departure.times.actualBlockOff && (
                  <span className="flight-details__time-actual">
                    Actual: {leg.departure.times.actualBlockOff.local}
                  </span>
                )}
              </div>

              <div className="flight-details__leg-duration">
                <span>{leg.flyingTime}</span>
              </div>

              <div className="flight-details__leg-arrival">
                <span className="flight-details__station-code">
                  {leg.arrival.scheduled.airportCode}
                </span>
                <span className="flight-details__station-name">
                  {leg.arrival.scheduled.airport}
                </span>
                <span className="flight-details__station-city">
                  {leg.arrival.scheduled.city}
                </span>
                {leg.arrival.terminal && (
                  <span className="flight-details__terminal">
                    Terminal {leg.arrival.terminal}
                  </span>
                )}
                {leg.arrival.bagBelt && (
                  <span className="flight-details__bag-belt">
                    Baggage belt {leg.arrival.bagBelt}
                  </span>
                )}
                <span className="flight-details__time">
                  {leg.arrival.times.scheduledArrival.local}
                </span>
                {leg.arrival.times.actualBlockOn && (
                  <span className="flight-details__time-actual">
                    Actual: {leg.arrival.times.actualBlockOn.local}
                  </span>
                )}
              </div>
            </div>

            {leg.equipment.name && (
              <div className="flight-details__aircraft">
                Aircraft: {leg.equipment.name}
                {leg.equipment.code ? ` (${leg.equipment.code})` : ""}
              </div>
            )}
            <FlightDetailsAccordion leg={leg} viewType="Onlineboard" />
          </div>
          {i < legs.length - 1 && (
            <TransferBar leg={leg} nextLeg={legs[i + 1]!} viewType={viewType} />
          )}
        </Fragment>
      ))}
    </div>
  );
}
```

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 `<FlightLegs legs={legs} />` and change to:
```tsx
<FlightLegs legs={legs} viewType="Onlineboard" />
```

- [ ] **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**
