Add Phase 2 Online Board master plan with 8 sub-plans

This commit is contained in:
2026-04-15 07:46:33 +03:00
parent 7db39cbeec
commit bd9cc92766
@@ -0,0 +1,896 @@
# 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.