# Phase 2 — Online Board MASTER Plan

> **This document is a plan INDEX, not an executable plan.** It lists the Phase 2 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
>
> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt.

**Goal of Phase 2:** Port the Online Board feature from Angular to React, achieving URL parity (100% against the Phase 0 corpus), SEO parity (enhanced with JSON-LD `Flight` + `ItemList` schemas), and live SignalR updates via the `TrackerHub`. Online Board is the hardest feature (SignalR live data + deep-linked search + flight details + real-time UI + SEO) and is deliberately first so its risk surfaces early.

**Phase 2 exit gate** (must pass before Phase 3 starts):

- URL parity 100% verified against the Phase 0 prod-access-log corpus — every `onlineboard/*` URL shape round-trips through `parseOnlineBoardUrl` / `buildOnlineBoardUrl` identically to Angular.
- SEO parity: canonical, hreflang (9 langs + `x-default`), OG tags, JSON-LD (`Flight` for details, `ItemList` of `Flight` for search results) — validated by SSR render + `cheerio` parse + `schema-dts` type check.
- Playwright integration tests passing (4 ported Cypress scenarios + SignalR mock server + error cases).
- VRT within threshold for all online-board routes x 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en).
- Load test at 150 RPS passes on online-board routes (50% headroom above the 100 RPS requirement).
- All Phase 1 exit gates still green (regression gate).
- WCAG AA violations block (upgraded from Phase 1's warn-only).
- Real analytics vendors (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging` environments.

**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 2 implements §9.2 (Phase 2 scope), §3.3-3.5 (routing + URL parity), §4.4 (SignalR), §5 (UI adapter), §6.5-6.8 (SEO/JSON-LD/OG/hreflang).

**Phase 1 prerequisite:** All Phase 1 exit gates must be green before Phase 2 starts. Phase 2 consumes the following Phase 1 contracts: `ApiClient` + `CachedApiClient` (1D), `SignalRConnection` + `useLiveFlights` (1E), `SeoHead` + `buildHreflangSet` + `JsonLdRenderer` (1F-seo), `createI18nInstance` + `useTranslation` (1C), `Logger` + `useLogger` (1G-logger), `Analytics` + `useAnalytics` (1G-analytics), `ErrorBoundary` + `errorToResponse` (1F-layout), `getEnv` (1A-1), root + locale layouts (1F-layout).

---

## Sub-plan inventory

| ID | Sub-plan | Estimated size | File |
|---|---|---|---|
| **2A** | UI adapter layer (`src/ui/flights/`) | Large (20-30 tasks) | `2026-04-14-phase-2a-ui-flights.md` (TBW) |
| **2B** | URL serializer/parser (`src/features/online-board/url.ts`) | Small (5-10 tasks) | `2026-04-14-phase-2b-url-serializer.md` (TBW) |
| **2C** | API client + hooks (`src/features/online-board/api.ts`, hooks) | Medium (10-20 tasks) | `2026-04-14-phase-2c-api-hooks.md` (TBW) |
| **2D** | SignalR wiring | Medium (10-15 tasks) | `2026-04-14-phase-2d-signalr-wiring.md` (TBW) |
| **2E** | Routes + pages | Medium (15-20 tasks) | `2026-04-14-phase-2e-routes-pages.md` (TBW) |
| **2F** | SEO + JSON-LD | Small (8-12 tasks) | `2026-04-14-phase-2f-seo-jsonld.md` (TBW) |
| **2G** | Parity harnesses | Medium (10-15 tasks) | `2026-04-14-phase-2g-parity-harnesses.md` (TBW) |
| **2H** | Integration tests | Medium (10-15 tasks) | `2026-04-14-phase-2h-integration-tests.md` (TBW) |

Sizes: **Small** = 5-12 tasks, **Medium** = 10-20 tasks, **Large** = 20-30 tasks.

---

## Dependency graph

```
           ┌───────────────────┐
           │  2A  UI adapter   │  (flight-display components needed by pages)
           │  src/ui/flights/  │
           └────────┬──────────┘
                    │
    ┌───────────────┼───────────────┐
    │               │               │
    ▼               ▼               │
┌──────────┐  ┌───────────┐        │
│ 2B  URL  │  │ 2C  API   │        │
│ serializer│  │ client +  │        │
│ (indepen-│  │ hooks     │        │
│  dent)   │  │ (uses 2A  │        │
│          │  │  for types)│        │
└────┬─────┘  └─────┬─────┘        │
     │              │               │
     │              ▼               │
     │        ┌───────────┐        │
     │        │ 2D SignalR │        │
     │        │ wiring     │        │
     │        │ (consumes  │        │
     │        │  2C hooks) │        │
     │        └─────┬──────┘        │
     │              │               │
     └──────────────┼───────────────┘
                    ▼
           ┌───────────────────┐
           │  2E  Routes +     │
           │  pages            │
           │  (consumes 2A +   │
           │   2B + 2C + 2D)   │
           └────────┬──────────┘
                    │
                    ▼
           ┌───────────────────┐
           │  2F  SEO +        │
           │  JSON-LD          │
           │  (consumes 2E     │
           │   route context)  │
           └────────┬──────────┘
                    │
                    ▼
           ┌───────────────────┐
           │  2G  Parity       │
           │  harnesses        │
           │  (consumes 2E +   │
           │   2F for baselines)│
           └────────┬──────────┘
                    │
                    ▼
           ┌───────────────────┐
           │  2H  Integration  │
           │  tests            │
           │  (consumes all)   │
           └───────────────────┘
```

### Execution order

**Serial (1 engineer):** 2A → 2B → 2C → 2D → 2E → 2F → 2G → 2H.

Rationale:
- 2A must come first: the UI flight-display components are needed by every page. This is the `src/ui/flights/` population step from design spec §5.
- 2B (URL serializer) is logically independent of 2A but ordered after it in serial because 2E needs both and 2A is larger / higher risk.
- 2C depends on 2A for data model types (flight types used in hooks).
- 2D depends on 2C (wires SignalR push events into the same hooks/state that 2C creates).
- 2E depends on 2A + 2B + 2C + 2D — it's the integration point (pages import UI components, use URL parsing, call API hooks, wire live data).
- 2F depends on 2E (SEO builders need the route context and data shapes that pages define).
- 2G depends on 2E + 2F (parity harnesses test the rendered pages + SEO output).
- 2H depends on everything (Playwright integration tests exercise the full feature).

**Parallel (2+ engineers):** After 2A ships:
- **Engineer 1:** 2B (URL serializer, fully independent)
- **Engineer 2:** 2C (API hooks, needs 2A types)
- Then 2D follows 2C; 2E follows 2B + 2C + 2D; rest is serial.

### Critical path

**2A → 2C → 2D → 2E → 2F → 2G → 2H** is the critical path. 2B sits off the critical path (it's small and independent) and can be slotted alongside 2C.

---

## Contracts — what each sub-plan exports

### 2A — UI adapter layer contracts

**Scope:** Port the subset of Angular `FlightsModule` shared components that the Online Board feature uses. These land in `src/ui/flights/` per the design spec §5 location rule ("does more than one feature use it?"). Feature-specific components land in `src/features/online-board/components/` in sub-plan 2E.

**Exports:**

- **`src/ui/flights/FlightCard.tsx`** — single flight row in search results. Displays carrier logo, flight number, departure/arrival airports and times, status badge, aircraft type. Props-driven, no data fetching.
- **`src/ui/flights/FlightList.tsx`** — scrollable list of `FlightCard` items with empty-state and loading skeleton.
- **`src/ui/flights/FlightDetails.tsx`** — expanded flight details view: route map placeholder, status timeline, departure/arrival info, aircraft info, codeshare info.
- **`src/ui/flights/StatusBadge.tsx`** — flight status indicator (on time, delayed, cancelled, landed, etc.) with color-coded styling.
- **`src/ui/flights/AirportDisplay.tsx`** — airport name + IATA code display with optional city name.
- **`src/ui/flights/TimeDisplay.tsx`** — time formatting component (scheduled vs actual, with delay indicator).
- **`src/ui/flights/SearchForm.tsx`** — online board search form with flight number input, airport autocomplete (PrimeReact Autocomplete), date picker (PrimeReact Calendar), and search type selector.
- **`src/ui/flights/CalendarStrip.tsx`** — horizontal date selector showing available search dates from the calendar API.
- **`src/ui/flights/FlightDetailsSkeleton.tsx`** — Suspense fallback skeleton for the details page.
- **`src/ui/flights/FlightListSkeleton.tsx`** — Suspense fallback skeleton for search result pages.
- **`src/ui/flights/ConnectionStatusBadge.tsx`** — SignalR connection status indicator ("live", "reconnecting", "offline").
- **`src/ui/flights/Breadcrumbs.tsx`** — breadcrumb navigation for online board routes.

**Porting workflow:** per design spec §5.4 — read Angular source, translate template to JSX preserving DOM + class names, translate logic to hooks/props, port SCSS to `.module.scss`, write Vitest test, capture VRT baseline.

**SCSS files:** Each component has a co-located `.module.scss` file ported from the Angular component's SCSS. Class names preserved for VRT pixel parity.

**TypeScript contracts (data model types in `src/ui/flights/types.ts`):**

```ts
/** Simplified flight record for list/card display */
export interface ISimpleFlight {
  id: string;                          // unique flight identifier
  flightNumber: string;                // e.g. "SU 100"
  carrier: string;                     // IATA carrier code, e.g. "SU"
  carrierName: string;                 // localized carrier name
  departure: IAirportTime;
  arrival: IAirportTime;
  status: FlightStatus;
  aircraftType?: string;
  codeshares?: string[];
  distance?: number;
}

export interface IAirportTime {
  airport: string;                     // IATA code
  airportName: string;                 // localized name
  cityName: string;                    // localized city name
  scheduled: string;                   // ISO 8601 datetime
  actual?: string;                     // ISO 8601 datetime (if available)
  terminal?: string;
  gate?: string;
}

export type FlightStatus =
  | "scheduled"
  | "delayed"
  | "departed"
  | "in_flight"
  | "landed"
  | "arrived"
  | "cancelled"
  | "diverted"
  | "unknown";

/** Parsed flight identifier from URL */
export interface IParsedFlightId {
  carrier: string;                     // e.g. "SU"
  flightNumber: string;               // e.g. "100"
  suffix?: string;                     // optional flight suffix
  date: string;                        // yyyyMMdd
}

/** Request type discriminator for online board search */
export type FlightRequestType =
  | "flight"                           // search by flight number
  | "departure"                        // search by departure airport
  | "arrival"                          // search by arrival airport
  | "route";                           // search by departure + arrival airports
```

**Package additions (2A):** `primereact` (PrimeReact component library — specific components: Calendar, Autocomplete, Tooltip), `clsx` (conditional class names).

**Exit gate for 2A:**
- Every UI component has a Vitest test rendering it with representative props and asserting key DOM structure.
- SCSS modules compile with no errors.
- No direct `primereact/*` imports outside `src/ui/` (enforced by 1A-3 ESLint boundary rules).
- VRT baselines captured for each component at 375px, 768px, 1440px viewports.
- `pnpm typecheck` and `pnpm lint` green.

---

### 2B — URL serializer/parser contracts

**Scope:** TDD port of the Angular URL builder/parser for all 6 online board route shapes. Byte-exact parity with Angular's `OnlineBoardFlightNumberUrlParamsResolver`, `OnlineBoardDepartureUrlParamsResolver`, `OnlineBoardArrivalUrlParamsResolver`, `OnlineBoardRouteUrlParamsResolver`. The Phase 0 URL corpus fixtures are the test oracle.

**Exports (`src/features/online-board/url.ts`):**

```ts
import type { FlightRequestType, IParsedFlightId } from "@/ui/flights/types";

/** Discriminated union of all parsed online board URL parameter shapes */
export type OnlineBoardParams =
  | { type: "start" }
  | { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
  | { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
  | { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
  | { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string }
  | { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };

/**
 * Parse a raw URL path segment into typed online board params.
 * Returns null if the path does not match any known online board URL shape.
 *
 * @param routeType - the route prefix ("flight", "departure", "arrival", "route", or "" for details)
 * @param params - the raw URL parameter string (e.g. "SU100-20250115")
 */
export function parseOnlineBoardUrl(routeType: string, params: string): OnlineBoardParams | null;

/**
 * Build a URL path segment from typed online board params.
 * Output is byte-exact match with Angular's URL builder.
 *
 * @returns the path segment without leading slash (e.g. "flight/SU100-20250115")
 */
export function buildOnlineBoardUrl(params: OnlineBoardParams): string;

/**
 * Parse a flight URL parameter string into its constituent parts.
 * Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
 */
export function parseFlightUrlParams(raw: string): IParsedFlightId | null;

/**
 * Build a flight URL parameter string from parts.
 * Output format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
 */
export function buildFlightUrlParams(id: IParsedFlightId): string;

/**
 * Parse a station URL parameter string.
 * Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
 */
export function parseStationUrlParams(raw: string): {
  station: string;
  date: string;
  timeFrom?: string;
  timeTo?: string;
} | null;

/**
 * Parse a route URL parameter string.
 * Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
 */
export function parseRouteUrlParams(raw: string): {
  departure: string;
  arrival: string;
  date: string;
  timeFrom?: string;
  timeTo?: string;
} | null;
```

**Test strategy:**
1. Table-driven tests against the Phase 0 URL corpus (every real URL from prod access logs).
2. Round-trip property tests: `buildOnlineBoardUrl(parseOnlineBoardUrl(type, params)) === originalParams` for all valid inputs.
3. `fast-check` fuzz tests: random carrier codes (2 chars), flight numbers (1-4 digits), IATA codes (3 chars), dates (valid yyyyMMdd range), optional suffixes.
4. Edge cases: missing optional time range, malformed dates, unknown carriers, extra hyphens.

**Exit gate for 2B:**
- 100% of Phase 0 URL corpus fixtures pass round-trip parity.
- Fuzz tests with `fast-check` find no serialization asymmetry.
- `parseOnlineBoardUrl` returns `null` for invalid inputs (never throws).
- Zero `any` types in the module.

---

### 2C — API client + hooks contracts

**Scope:** Online board REST endpoints wrapped in typed functions + React hooks. Consumes `ApiClient` / `CachedApiClient` from Phase 1's 1D.

**Exports (`src/features/online-board/api.ts`):**

```ts
import type { ISimpleFlight, IParsedFlightId, FlightRequestType } from "@/ui/flights/types";

/** Response shape from GET /board */
export interface BoardResponse {
  flights: ISimpleFlight[];
  totalCount: number;
  date: string;
  requestType: FlightRequestType;
}

/** Full flight details response from GET /onlineboard/details */
export interface FlightDetailsResponse {
  flight: ISimpleFlight;
  route: IRoutePoint[];
  codeshares: ICodeshare[];
  statusHistory: IStatusHistoryEntry[];
}

export interface IRoutePoint {
  airport: string;
  airportName: string;
  cityName: string;
  scheduledTime: string;
  actualTime?: string;
  terminal?: string;
  gate?: string;
}

export interface ICodeshare {
  carrier: string;
  carrierName: string;
  flightNumber: string;
}

export interface IStatusHistoryEntry {
  status: string;
  timestamp: string;
}

/** Calendar days response from GET /v1/days/.../board/ */
export type CalendarDaysResponse = string[];   // array of "yyyy-MM-dd" date strings

/**
 * Search flights on the online board.
 * Maps to: GET /board?type={type}&station={station}&date={date}&...
 */
export function searchFlights(
  client: ApiClient,
  params: {
    type: FlightRequestType;
    date: string;
    station?: string;
    departure?: string;
    arrival?: string;
    carrier?: string;
    flightNumber?: string;
    timeFrom?: string;
    timeTo?: string;
  },
): Promise<BoardResponse>;

/**
 * Get flight details.
 * Maps to: GET /onlineboard/details?flightId={carrier}{number}&date={date}
 */
export function getFlightDetails(
  client: ApiClient,
  id: IParsedFlightId,
): Promise<FlightDetailsResponse>;

/**
 * Get available calendar days for a given search context.
 * Maps to: GET /v1/days/{station|route}/board/
 */
export function getCalendarDays(
  client: ApiClient,
  params: {
    type: FlightRequestType;
    station?: string;
    departure?: string;
    arrival?: string;
  },
): Promise<CalendarDaysResponse>;
```

**Exports (`src/features/online-board/hooks/useOnlineBoard.ts`):**

```ts
import type { BoardResponse } from "../api";
import type { OnlineBoardParams } from "../url";

export interface UseOnlineBoardResult {
  data: BoardResponse;
  calendarDays: string[];
  isRefetching: boolean;
  error: Error | null;
  refetch: () => void;
}

/**
 * Hook for online board search pages.
 * SSR: receives initialData from the loader.
 * Client: re-fetches on param change, receives live updates from SignalR (via 2D).
 */
export function useOnlineBoard(
  params: OnlineBoardParams,
  initialData: BoardResponse,
  initialCalendarDays: string[],
): UseOnlineBoardResult;
```

**Exports (`src/features/online-board/hooks/useFlightDetails.ts`):**

```ts
import type { FlightDetailsResponse } from "../api";
import type { IParsedFlightId } from "@/ui/flights/types";

export interface UseFlightDetailsResult {
  data: FlightDetailsResponse;
  isRefetching: boolean;
  error: Error | null;
  refetch: () => void;
}

/**
 * Hook for the flight details page.
 * SSR: receives initialData from the loader.
 * Client: re-fetches on param change, receives live updates from SignalR (via 2D).
 */
export function useFlightDetails(
  id: IParsedFlightId,
  initialData: FlightDetailsResponse,
): UseFlightDetailsResult;
```

**Caching strategy (per design spec §4.2):**
- `searchFlights`: 30s TTL (live data), server LRU + client memory cache via `CachedApiClient`.
- `getFlightDetails`: 30s TTL (live data), same caching layers.
- `getCalendarDays`: 5 min TTL (static reference data).

**Exit gate for 2C:**
- Vitest tests cover: successful API call + response deserialization for all three endpoints; error mapping (404 → `ApiHttpError`, timeout → `ApiTimeoutError`); cache hit for repeated identical queries; hooks render with initial data and update on refetch.
- `useOnlineBoard` and `useFlightDetails` hooks tested with `@testing-library/react-hooks` for SSR initial data pass-through and client-side refetch behavior.
- Zero `any` types.

---

### 2D — SignalR wiring contracts

**Scope:** Connect the generic `useLiveFlights` hook from Phase 1's 1E to the real TrackerHub channels used by Online Board. Wire into the search and details hooks from 2C.

**Exports (`src/features/online-board/hooks/useLiveBoard.ts`):**

```ts
import type { ConnectionStatus } from "@/shared/signalr/connection";
import type { BoardResponse } from "../api";

export interface UseLiveBoardResult {
  data: BoardResponse;
  connectionStatus: ConnectionStatus;
}

/**
 * Wraps useLiveFlights with Online Board-specific channel configuration.
 * Channel: SubscribeDate(date, departure?, arrival?)
 * On RefreshDate push: triggers silent re-fetch of searchFlights().
 */
export function useLiveBoard(
  params: { date: string; departure?: string; arrival?: string },
  initialData: BoardResponse,
): UseLiveBoardResult;
```

**Exports (`src/features/online-board/hooks/useLiveFlightDetails.ts`):**

```ts
import type { ConnectionStatus } from "@/shared/signalr/connection";
import type { FlightDetailsResponse } from "../api";
import type { IParsedFlightId } from "@/ui/flights/types";

export interface UseLiveFlightDetailsResult {
  data: FlightDetailsResponse;
  connectionStatus: ConnectionStatus;
}

/**
 * Wraps useLiveFlights with flight details-specific channel configuration.
 * Channel: Subscribe(flightId@date)
 * On push: triggers silent re-fetch of getFlightDetails().
 */
export function useLiveFlightDetails(
  id: IParsedFlightId,
  initialData: FlightDetailsResponse,
): UseLiveFlightDetailsResult;
```

**TrackerHub channel mapping:**
- Search pages: `SubscribeDate(date, departure?, arrival?)` — server pushes `RefreshDate` when any flight matching the query updates.
- Details page: `Subscribe({carrier}{flightNumber}@{date})` — server pushes updates when the specific flight changes.
- On push: the hook triggers a silent re-fetch of the corresponding REST endpoint (no full-page reload, no flash). The re-fetched data replaces the current state atomically.

**Integration with 2C hooks:** `useOnlineBoard` (2C) internally delegates to `useLiveBoard` (2D) for its live data; `useFlightDetails` (2C) internally delegates to `useLiveFlightDetails` (2D). The 2C hooks are the public API; 2D hooks are internal wiring.

**Exit gate for 2D:**
- Vitest: mock SignalR hub pushes `RefreshDate` → `useLiveBoard` triggers re-fetch and returns updated data.
- Vitest: mock SignalR hub pushes flight update → `useLiveFlightDetails` triggers re-fetch.
- Strict Mode double-mount: exactly one `HubConnection.start()` call (inherits from 1E's guarantee).
- Disconnect scenario: `connectionStatus` transitions to `"offline"`, data remains last-known-good.
- SSR: returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr`.

---

### 2E — Routes + pages contracts

**Scope:** All `src/routes/[lang]/onlineboard/*` route files with loaders, Suspense, `React.lazy`. Also includes feature-specific components in `src/features/online-board/components/` that are not shared across features.

**Route files:**

| Route file | URL pattern | Angular equivalent |
|---|---|---|
| `src/routes/[lang]/onlineboard/page.tsx` | `/{lang}/onlineboard` | Start page (search form) |
| `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` | `/{lang}/onlineboard/flight/SU100-20250115` | Flight number search |
| `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` | `/{lang}/onlineboard/departure/SVO-20250115` | Departure station search |
| `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` | `/{lang}/onlineboard/arrival/JFK-20250115` | Arrival station search |
| `src/routes/[lang]/onlineboard/route/[params]/page.tsx` | `/{lang}/onlineboard/route/SVO-JFK-20250115` | Route search |
| `src/routes/[lang]/onlineboard/[params]/page.tsx` | `/{lang}/onlineboard/SU100-20250115` | Flight details |

**Feature-specific components (`src/features/online-board/components/`):**

- **`OnlineBoardStart.tsx`** — start page with search form, search history, and popular routes.
- **`OnlineBoardSearch.tsx`** — search results page (shared by flight/departure/arrival/route searches) with flight list, calendar strip, connection status badge, and filter controls.
- **`OnlineBoardDetails.tsx`** — flight details page with full flight info, status timeline, route points, codeshare info, and connection status badge.

**Loader pattern (per design spec §3.4):**

```tsx
// Example: routes/[lang]/onlineboard/departure/[params]/page.tsx
import { lazy, Suspense } from "react";
const OnlineBoardSearch = lazy(() =>
  import("@/features/online-board").then(m => ({ default: m.OnlineBoardSearch }))
);

export async function loader({ params }: { params: { lang: string; params: string } }) {
  const parsed = parseOnlineBoardUrl("departure", params.params);
  if (!parsed || parsed.type !== "departure") throw new ApiHttpError("Not found", 404);
  const [data, calendarDays] = await Promise.all([
    searchFlights(apiClient, { type: "departure", station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }),
    getCalendarDays(apiClient, { type: "departure", station: parsed.station }),
  ]);
  const seo = buildOnlineBoardSeo(parsed, cityNames);
  return { data, calendarDays, seo, parsed };
}

export default function Page() {
  const { data, calendarDays, seo, parsed } = useLoaderData<typeof loader>();
  return (
    <>
      <SeoHead {...seo} />
      <Suspense fallback={<FlightListSkeleton />}>
        <OnlineBoardSearch initialData={data} initialCalendarDays={calendarDays} params={parsed} />
      </Suspense>
    </>
  );
}
```

**Feature barrel (`src/features/online-board/index.ts`) — populated in 2E:**

```ts
// Public surface of the online-board feature
export { OnlineBoardStart } from "./components/OnlineBoardStart";
export { OnlineBoardSearch } from "./components/OnlineBoardSearch";
export { OnlineBoardDetails } from "./components/OnlineBoardDetails";
export { parseOnlineBoardUrl, buildOnlineBoardUrl } from "./url";
export { searchFlights, getFlightDetails, getCalendarDays } from "./api";
export { buildOnlineBoardSeo } from "./seo";
export type { BoardResponse, FlightDetailsResponse, CalendarDaysResponse } from "./api";
export type { OnlineBoardParams } from "./url";
```

**MF expose (`src/mf/expose/OnlineBoard.tsx`) — updated from stub to real in 2E:**

```tsx
import { lazy, Suspense } from "react";
import type { HostContract } from "@/host-contract";

const OnlineBoardFeature = lazy(() =>
  import("@/features/online-board").then(m => ({ default: m.OnlineBoardRoot }))
);

export default function OnlineBoard({ hostContract }: { hostContract: HostContract }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <OnlineBoardFeature hostContract={hostContract} />
    </Suspense>
  );
}
```

This requires an `OnlineBoardRoot` component exported from the feature barrel that wraps the internal router for embedded MF usage.

**Exit gate for 2E:**
- All 6 routes render via SSR with correct loader data.
- URL parameters parsed correctly for all route shapes.
- Invalid URL parameters return 404 (via `errorToResponse` from 1F-layout).
- `React.lazy()` + `<Suspense>` pattern verified on every page.
- Feature barrel exports only the public surface (no internal component leakage).
- MF expose wrapper renders the feature root in a test host.
- `pnpm typecheck` and `pnpm lint` green.

---

### 2F — SEO + JSON-LD contracts

**Scope:** Build the `buildOnlineBoardSeo()` function for each online board route type. Produce JSON-LD schemas (`Flight` for details, `ItemList` of `Flight` for search results). Consume `SeoHead`, `buildHreflangSet`, and `JsonLdRenderer` from Phase 1's 1F-seo.

**Exports (`src/features/online-board/seo.ts`):**

```ts
import type { SeoHeadProps } from "@/ui/seo/SeoHead";
import type { OnlineBoardParams } from "./url";
import type { ISimpleFlight, FlightDetailsResponse } from "./api";

/**
 * Build SeoHead props for any online board route.
 * Produces: title, description, canonical, hreflang, OG tags, JSON-LD.
 *
 * JSON-LD schemas:
 *   - Flight details → schema.org/Flight
 *   - Search results → schema.org/ItemList containing Flight items
 *   - Start page → schema.org/WebPage with SearchAction
 */
export function buildOnlineBoardSeo(
  params: OnlineBoardParams,
  context: {
    cityNames: Record<string, string>;  // IATA code → localized city name
    locale: string;
    canonicalOrigin: string;
    flights?: ISimpleFlight[];
    flightDetails?: FlightDetailsResponse;
  },
): SeoHeadProps;

/**
 * Build a JSON-LD Flight object from flight details data.
 * Uses schema-dts types for type safety.
 */
export function buildFlightJsonLd(
  flight: FlightDetailsResponse,
  locale: string,
): import("schema-dts").Flight;

/**
 * Build a JSON-LD ItemList of Flight objects from search results.
 */
export function buildFlightSearchResultsJsonLd(
  flights: ISimpleFlight[],
  params: OnlineBoardParams,
  locale: string,
): import("schema-dts").ItemList;
```

**Title/description strategy:** Ported from Angular's translation keys. Pattern:
- Start page: `t("onlineboard.seo.start.title")` / `t("onlineboard.seo.start.description")`
- Flight search: `t("onlineboard.seo.flight.title", { flightNumber })` / `t("onlineboard.seo.flight.description", { flightNumber, date })`
- Departure: `t("onlineboard.seo.departure.title", { cityName })` / `t("onlineboard.seo.departure.description", { cityName, date })`
- Arrival: `t("onlineboard.seo.arrival.title", { cityName })` / etc.
- Route: `t("onlineboard.seo.route.title", { departureCity, arrivalCity })` / etc.
- Details: `t("onlineboard.seo.details.title", { flightNumber, carrier })` / etc.

**JSON-LD schema details:**

Flight details page emits `schema.org/Flight`:
```json
{
  "@context": "https://schema.org",
  "@type": "Flight",
  "flightNumber": "SU 100",
  "provider": { "@type": "Airline", "iataCode": "SU", "name": "Aeroflot" },
  "departureAirport": { "@type": "Airport", "iataCode": "SVO", "name": "Sheremetyevo", "address": { "@type": "PostalAddress", "addressCountry": "RU" } },
  "arrivalAirport": { "@type": "Airport", "iataCode": "JFK", ... },
  "departureTime": "2025-01-15T10:00:00+03:00",
  "arrivalTime": "2025-01-15T14:30:00-05:00",
  "flightDistance": { "@type": "Distance", "value": "9200 km" }
}
```

Search results pages emit `schema.org/ItemList` containing `Flight` items.

**OG images:** Phase 2 enhancement per design spec §6.7 — dynamic per-flight OG images via Satori, served from `routes/og/flight/[params]/image.tsx`, cached `s-maxage=86400`. Falls back to the static default OG image from Phase 1 if Satori generation fails.

**Exit gate for 2F:**
- `buildOnlineBoardSeo` produces valid `SeoHeadProps` for all 6 route types.
- JSON-LD output validates against `schema-dts` types at compile time.
- CI JSON-LD validation job passes (structured data validator against fixture renders).
- `buildHreflangSet` produces correct 9-language + `x-default` set for each route.
- OG tags present and correct for each route type.
- SSR render + `cheerio` parse asserts `<title>`, `<meta name="description">`, `<link rel="canonical">`, `<link rel="alternate" hreflang>`, `<meta property="og:*">`, `<script type="application/ld+json">`.

---

### 2G — Parity harnesses contracts

**Scope:** Build the URL parity harness and SEO parity harness that were deferred from Phase 1 (the "deferred 1J" mentioned in the Phase 1 master plan). These harnesses test against the real Online Board feature, not the synthetic smoke route. Also establish VRT baselines for all online board routes.

**Exports:**

- **`tests/parity/url-parity.test.ts`** — table-driven Vitest test that reads the Phase 0 URL corpus from `tests/fixtures/phase-0/url-corpus-onlineboard.json` and asserts that `parseOnlineBoardUrl` + `buildOnlineBoardUrl` produce byte-exact matches. Merge-blocking CI gate.

- **`tests/parity/seo-parity.test.ts`** — Vitest test that SSR-renders each online board route type, parses the HTML with `cheerio`, and compares the SEO elements against Phase 0 baselines from `tests/fixtures/phase-0/seo-baselines-onlineboard.json`. Checks: `<title>`, `<meta name="description">`, `<link rel="canonical">`, hreflang set, OG tags. JSON-LD presence and schema type checked (content differs from Angular since Angular had no JSON-LD — this is an enhancement, not parity).

- **`tests/vrt/onlineboard/`** — Playwright VRT baseline screenshots for all 6 route types at 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en). Total: 36 baseline images. Threshold: 0.1% pixel diff (configurable). Committed under `tests/fixtures/phase-2/vrt/`.

- **`scripts/parity/run-url-parity.ts`** — standalone script to run URL parity checks outside of CI (for local development). Reads the corpus, runs both parse and build, reports mismatches with diffs.

**Exit gate for 2G:**
- URL parity: 100% of the Phase 0 corpus passes.
- SEO parity: all baseline comparisons pass (with documented exceptions for JSON-LD enhancement).
- VRT baselines committed and CI gate functional (diff threshold violation blocks merge).
- Parity harness documentation in `tests/parity/README.md` explains how to add new test URLs and update baselines.

---

### 2H — Integration tests contracts

**Scope:** Playwright end-to-end tests covering the full Online Board feature in standalone SSR mode. Ported from the 4 passing Cypress scenarios + new tests for SignalR, error cases, and edge cases.

**Test inventory:**

1. **Start page** — navigates to `/{lang}/onlineboard`, verifies search form renders, submits a flight number search, arrives at the correct search results URL.
2. **Departure search** — navigates to a departure URL, verifies flight list renders with correct airport, verifies calendar strip shows available dates, clicks a different date and verifies URL update.
3. **Flight details** — navigates to a details URL, verifies full flight info renders (status, route, codeshares), verifies breadcrumbs.
4. **Language switch** — navigates to `/ru/onlineboard/...`, switches language to `en`, verifies URL updates to `/en/onlineboard/...` with same parameters, verifies content is in English.
5. **SignalR live update** — mocked SignalR hub pushes a `RefreshDate` event, verifies the flight list updates without page reload, verifies connection status badge shows "live".
6. **SignalR disconnect** — mocked SignalR hub disconnects, verifies "offline" badge appears, verifies last-known data remains displayed.
7. **404 handling** — navigates to an invalid onlineboard URL, verifies 404 page renders with correct HTTP status.
8. **API error handling** — mocked API returns 500, verifies error UI renders (not a white screen), verifies "Retry" button triggers re-fetch.
9. **Empty results** — search returns zero flights, verifies empty-state UI renders.
10. **Responsive** — verifies all pages render correctly at 375px, 768px, 1440px without horizontal scroll.

**SignalR mock server:**

```ts
// tests/mocks/signalr-mock-server.ts
export class MockSignalRServer {
  constructor(port: number);
  start(): Promise<void>;
  stop(): Promise<void>;
  pushRefreshDate(date: string, departure?: string, arrival?: string): void;
  pushFlightUpdate(flightId: string, date: string): void;
  getSubscriptions(): { channel: string; args: unknown[] }[];
}
```

The mock server implements the TrackerHub protocol: accepts `SubscribeDate` and `Subscribe` invocations, records them, and allows tests to programmatically push events.

**Exit gate for 2H:**
- All 10 Playwright test scenarios pass.
- SignalR mock server correctly simulates push events and disconnect.
- Tests run in CI in under 5 minutes.
- No flaky tests (3 consecutive CI runs green).

---

## Shared files — cross-sub-plan modification table

| File | Primary owner | Also modified by | What the modifiers add |
|---|---|---|---|
| `src/features/online-board/index.ts` | 1A-1 (empty barrel) | 2E | Populated with all public exports (components, URL functions, API functions, SEO builders, types) |
| `src/mf/expose/OnlineBoard.tsx` | 1A-2 (stub) | 2E | Updated from stub to real: imports `OnlineBoardRoot` from feature barrel, renders with `React.lazy` + `Suspense` |
| `src/ui/flights/types.ts` | 2A | 2B, 2C, 2D, 2E, 2F | Consumed (read-only) by all downstream sub-plans for type imports |
| `src/observability/analytics/adapters/*.ts` | 1G-analytics (stubs) | 2E | Real vendor script loading wired (Yandex.Metrica, CTM, Variocube, Dynatrace) — replaces structured stubs with real implementations when A7 is resolved |
| `package.json` | 1A-1 | 2A | `primereact`, `clsx` added |
| | | 2B | `fast-check` (dev dep) |
| | | 2H | `@playwright/test` (if not already from Phase 1) |

**Modification protocol** — same as Phase 1: downstream sub-plan tasks explicitly reference the primary owner, quote the pre-modification state, show the full post-modification file, and re-run the owner's exit-gate tests.

---

## Spec-coverage matrix

| Spec section | Topic | Sub-plan(s) |
|---|---|---|
| §3.3 | Routing — file-based, precedence | 2E |
| §3.4 | Loaders, Suspense, `React.lazy` | 2E |
| §3.5 | URL parity — ported serializers | 2B |
| §3.6 | Canonical, hreflang, redirects | 2F |
| §4.4 | SignalR wrapper + hook | 2D |
| §4.5 | State management (hooks, reducers) | 2C, 2D |
| §4.6 | Error handling path | 2C, 2E |
| §5.1 | UI adapter boundary | 2A |
| §5.2 | SCSS Modules | 2A |
| §5.3 | PrimeReact theming | 2A |
| §5.4 | Per-component porting workflow | 2A |
| §5.7 | Responsiveness | 2A, 2H |
| §6.5 | `<SeoHead>` usage | 2F |
| §6.6 | JSON-LD schema coverage (Flight, ItemList) | 2F |
| §6.7 | OG images (dynamic per-flight) | 2F |
| §6.8 | Canonical + hreflang correctness | 2F, 2G |
| §8.4 | Testing strategy (URL parity, VRT, Playwright) | 2G, 2H |
| §9.2 | Phase 2 scope + exit gate | All |

---

## Phase 2 global exit gate — checklist

- [ ] **2A:** All UI flight-display components have Vitest tests + VRT baselines; no direct `primereact/*` imports outside `src/ui/`; SCSS compiles clean.
- [ ] **2B:** 100% of Phase 0 URL corpus passes round-trip parity; `fast-check` fuzz tests green; `parseOnlineBoardUrl` returns `null` (never throws) on invalid input.
- [ ] **2C:** API client functions tested for all three endpoints; hooks tested with initial data + refetch; cache behavior verified; zero `any` types.
- [ ] **2D:** SignalR wiring tested: push → re-fetch → updated data; Strict Mode safe; disconnect → offline badge + last-known data; SSR returns idle.
- [ ] **2E:** All 6 routes render SSR with correct loaders; invalid params → 404; `React.lazy` + `Suspense` on every page; feature barrel exports only public surface; MF expose wrapper functional.
- [ ] **2F:** `buildOnlineBoardSeo` produces valid output for all 6 route types; JSON-LD validates against `schema-dts`; hreflang reciprocal across 9 languages; OG tags correct; CI JSON-LD validator passes.
- [ ] **2G:** URL parity 100% against Phase 0 corpus; SEO parity baselines pass; 36 VRT baselines committed and CI gate functional.
- [ ] **2H:** All 10 Playwright scenarios pass; SignalR mock server functional; 3 consecutive CI runs green (no flakes); tests complete in under 5 minutes.
- [ ] **Load test:** Online board routes sustain 150 RPS with p95 latency under 500ms.
- [ ] **WCAG AA:** `@axe-core/playwright` violations block (upgraded from Phase 1 warn-only).
- [ ] **Analytics:** Real vendor scripts (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging`.
- [ ] **Phase 1 regression:** All Phase 1 exit gates still green on `main`.
- [ ] **Security scan:** `osv-scanner` + `npm audit` green after Phase 2 dependency additions.
- [ ] **Bundle size:** Online board feature chunk within budget (budget TBD in 2A based on Angular bundle analysis).

---

## Risks + open questions for Phase 2

1. **PrimeReact vs PrimeNG DOM differences (T2).** Design spec §5.3 identifies Calendar, Autocomplete, DataTable, and Toast as likely failure cases. 2A absorbs this risk in `src/ui/primitives/` wrappers + compensating CSS. Unresolved diffs go to `docs/visual-parity-exceptions.md`.

2. **SignalR TrackerHub protocol undocumented.** The Angular source is the only specification. 2D must reverse-engineer the exact channel names and message shapes. Risk: Angular uses implicit conventions that aren't obvious from source reading alone. Mitigation: run the Angular app against a test hub and capture wire traffic during 2D.

3. **Analytics vendor credentials (A7) still unknown.** If A7 is unresolved when 2E starts, the analytics adapters stay as structured stubs and the "real vendors in testing/staging" exit gate defers to Phase 3. The rest of Phase 2 is not blocked.

4. **Phase 0 URL corpus coverage.** The parity harness is only as good as the corpus. If prod access logs are incomplete (e.g., rare URL shapes not in logs), parity gaps may surface post-cutover. Mitigation: `fast-check` fuzz tests in 2B supplement the corpus with randomized inputs.

5. **Dynamic OG images via Satori.** Satori is a runtime dependency for generating per-flight OG images. If Satori proves unreliable or slow, Phase 2 falls back to the static default OG image and defers dynamic images to a follow-up.

6. **Load test at 150 RPS.** Phase 1's smoke route may not be representative of Online Board's SSR cost (which includes API calls, SignalR connection setup, and JSON-LD generation). Load test infrastructure must mock upstream APIs at realistic latencies.

---

## Cutover plan (from design spec §9.2)

1. Deploy to `staging`; run full test suite + load test + SEO audit.
2. Canary 5% of `/{lang}/onlineboard/*` prod traffic for 24h (request-id hash bucket behind proxy); rest stays on Angular.
3. Monitor: error rate, p95 latency, `flights.react.error`, `flights.api.error`, SignalR health, Web Vitals, Search Console crawl errors.
4. If clean: 25% → 50% → 100% over 72h, always reversible.
5. Hold at 100% for 1 week. Then retire (not delete) Angular online-board code.

**Cutover is NOT part of the sub-plans** — it executes after 2H passes and is tracked as an operational procedure, not a development task.

---

## How to write each sub-plan

When the user is ready to execute a sub-plan, re-invoke `superpowers:writing-plans` with a specific prompt like:

> "Write sub-plan 2A (UI adapter layer) from `docs/superpowers/plans/2026-04-14-phase-2-online-board-master.md`. Target file: `docs/superpowers/plans/2026-04-14-phase-2a-ui-flights.md`. Follow the contracts defined in the master plan §2A exactly; reference the design spec §5 as source material and the Angular component inventory from Phase 0."

The sub-plan writer must:

1. Read this master plan in full for the dependency + contract context.
2. Read the Phase 1 master plan for the contracts Phase 2 consumes.
3. Read the relevant design spec sections.
4. Read any upstream sub-plans that have already been written (their exit gates lock in file/API shapes).
5. Produce a fully TDD-granular plan at the shape of the Phase 1 sub-plan format.
6. Match the contracts in this master plan byte-for-byte on type signatures. Any contract change requires updating this master plan first.

---

## Self-review

**Spec coverage.** Every Phase 2-relevant design-spec section (§3.3-3.6, §4.4-4.6, §5, §6.5-6.8, §8.4, §9.2) maps to at least one sub-plan in the spec-coverage matrix.

**Placeholder scan.** No `TBD` / `TODO` / `FIXME` outside of the "TBW" markers on sub-plan filenames and the bundle-size budget note (which is deliberately deferred to 2A since it depends on Angular bundle analysis).

**Internal consistency.** Cross-checked: `ISimpleFlight` + `IParsedFlightId` + `FlightRequestType` defined in 2A → consumed by 2B, 2C, 2D, 2E, 2F; `OnlineBoardParams` defined in 2B → consumed by 2C, 2E, 2F; `BoardResponse` + `FlightDetailsResponse` defined in 2C → consumed by 2D, 2E, 2F; `SeoHeadProps` from 1F-seo → consumed by 2F; `useLiveFlights` from 1E → consumed by 2D; `ApiClient` from 1D → consumed by 2C; `SignalRConnection` from 1E → consumed by 2D.

**Phase 1 contract consumption.** Every Phase 1 contract used by Phase 2 is listed in the prerequisite section. No Phase 2 sub-plan creates functionality already provided by Phase 1 — it only wires and extends.

**Dependency graph acyclicity.** The graph is a strict DAG: 2A → 2C → 2D → 2E → 2F → 2G → 2H, with 2B as an independent node feeding into 2E. No cycles.

---

## Next step

- **If you approve this master plan:** say so, and I'll write sub-plan **2A** (UI adapter layer) in the next session.
- **If you want changes:** tell me, I revise.
