diff --git a/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md b/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md new file mode 100644 index 00000000..507f32a6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md @@ -0,0 +1,1073 @@ +# Aeroflot Flights — Angular → React Rewrite Design + +**Status:** Draft for review +**Date:** 2026-04-14 +**Owner:** gnezim +**Scope:** Full rewrite of the Aeroflot Flights web application from Angular 12 to a dual-mode Modern.js app (standalone SSR site + Module Federation 2.0 remote component), delivered in phases via strangler-fig migration. + +--- + +## 0. Context + +### 0.1 Current state + +An ASP.NET host (`Aeroflot.Flights.Web.csproj`, `Startup.cs`, `Program.cs`) serves an Angular 12 SPA from `ClientApp/`. The SPA provides four flows — **Online Board** (real-time arrivals/departures with SignalR live updates), **Schedule** (flight timetable search with multi-leg deep links), **Flights Map** (Leaflet-based route visualization, feature-flagged), and **Popular Requests** — across nine languages (ru, en, es, fr, it, ja, ko, zh, de) with URL-prefixed locales (`/{lang}/…`). + +Key characteristics of the existing codebase: + +- **No NgRx.** State lives in services exposing RxJS `BehaviorSubject`s. +- **Heavy RxJS** in filter and data-source services. +- **No Angular Universal.** Currently CSR-only. +- **PrimeNG 10** for Calendar, Autocomplete, Accordion, Dropdown, DataTable, Tooltip, Dialog, Toast — with Aeroflot-specific SCSS theme overrides. +- **`@ngx-translate` + `messageformat.js`** for i18n (ICU MessageFormat). +- **Application Insights** for telemetry. +- **SignalR `TrackerHub`** for live flight updates (`SubscribeDate(flightDate, departure, arrival)` → pushed `RefreshDate` events). +- **`custom-webpack.config.js`** randomizes `chunkLoadingGlobal` — an early signal that the codebase was authored with micro-frontend embedding in mind. +- **Custom URL schemes** for deep linking, notably the variable-length multi-leg schedule URL (`/schedule/SU0001-2025-01-15/SU0002-2025-01-15/…`) handled via a custom `UrlMatcher`. +- **Tests:** ~175 Vitest-equivalent `.spec.ts` files (modest coverage), 5 Cypress E2E scenarios (currently not passing), no Storybook stories authored. + +### 0.2 Customer requirements (summary) + +1. **Stack:** Modern.js (SSR), Module Federation 2.0 with `mf-manifest.json`, React 18+ with Concurrent Mode and ``, no side effects in component bodies, `React.lazy()` for dynamic imports. Bundler: any MF 2.0-capable (Rspack chosen). +2. **Data:** REST API, JSON; rendered data must stay consistent with API. +3. **Performance:** 100 RPS. +4. **Reliability:** Geo-distributed VMs. +5. **Security:** Component must be isolated — no threat to other site components. +6. **SEO & availability:** 24/7/365, ≤6h MTTR; SEO optimization, JSON-LD + OpenGraph; analytics via Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром (Dynatrace). +7. **Cross-platform:** Embeddable in multiple channel apps (Web, PWA); fluid responsive layout. +8. **Logging & monitoring:** Collect frontend logs in a customer-specified format (TBD), ship to the customer's log system; system-event monitoring to a metrics aggregator. +9. **Module structure:** Must match the customer's standard remote-frontend module template (template not yet available — **SPEC ASSUMPTION: default to Modern.js MF remote layout, reconcile later**). +10. **Design:** Must match customer design system and mockups; must embed other remote components when available. **Pixel-perfect parity with the current Angular app is the near-term design target**, with a UI adapter boundary for future reskinning. + +### 0.3 Non-goals + +- No new UX or features beyond parity with the current Angular app. +- No backend / REST API changes. +- No data-model changes. +- No authentication; the app remains public. +- No Angular ↔ React hybrid runtime. The migration is strangler-fig at the URL-routing level, not at the component level. + +--- + +## 1. High-level architecture + repo layout + +### 1.1 Runtime topology + +A single Modern.js project produces **two deployable artifacts** from the same `src/`: + +1. **Standalone SSR site** — the public entry point (`flights.aeroflot.ru` or equivalent). Served by Modern.js's Node SSR runtime. Owns its own routing, meta tags, JSON-LD, analytics. This is what SEO crawlers, direct visitors, and backlinks hit. **Satisfies:** 1.1.1 (ModernJS SSR), 6 (SEO / JSON-LD / OG / availability), 7 (responsive), 4 (geo-distributed VMs behind a load balancer). + +2. **Module Federation 2.0 remote** — a static MF 2.0 remote with `mf-manifest.json` exposing feature-level entries to channel apps (Web, PWA). Channel apps load and render these entries inside their own SSR/CSR flow. **Satisfies:** 1.1.2 (MF 2.0 + manifest), 1.1.3 (React 18 + Suspense + `React.lazy`), 7 (cross-platform embedding). + +Both artifacts are built in CI from the same commit. A feature component imported from `src/features/online-board/` renders identically in both modes because the feature never knows which mode hosts it — it depends only on a narrow `HostContract` interface that both modes implement. + +### 1.2 Dependency direction (enforced by ESLint boundaries) + +``` +routes/ ─────────┐ (standalone entry only) + ▼ + features/ ◄──── mf/ (remote entry only) + │ + ┌────────────┼──────────────┐ + ▼ ▼ ▼ + ui/ shared/ observability/ + │ │ + ▼ ▼ + i18n/ (otel, logger, + analytics) +``` + +`features/` never imports `routes/` or `mf/`. `ui/` never imports `features/`. Both build targets can tree-shake each other's entry code cleanly. + +### 1.3 `src/` tree + +``` +src/ +├── routes/ # Modern.js file-based routing (standalone mode only) +│ ├── [lang]/ +│ │ ├── onlineboard/ +│ │ │ ├── page.tsx # /{lang}/onlineboard (start) +│ │ │ ├── flight/[params]/page.tsx # /{lang}/onlineboard/flight/SU100-2025-01-15 +│ │ │ ├── departure/[params]/page.tsx # /{lang}/onlineboard/departure/SVO-2025-01-15 +│ │ │ ├── arrival/[params]/page.tsx # /{lang}/onlineboard/arrival/JFK-2025-01-15 +│ │ │ ├── route/[params]/page.tsx # /{lang}/onlineboard/route/SVO-JFK-2025-01-15 +│ │ │ └── [params]/page.tsx # /{lang}/onlineboard/SU100-2025-01-15 (details) +│ │ ├── schedule/ +│ │ │ ├── page.tsx +│ │ │ ├── route/[params]/page.tsx +│ │ │ ├── route/[params]/[returnParams]/page.tsx +│ │ │ └── [...flights]/page.tsx # catch-all for multi-leg URLs +│ │ ├── flights-map/ +│ │ │ ├── page.tsx +│ │ │ └── route/[params]/page.tsx +│ │ ├── popular/page.tsx +│ │ ├── page.tsx # / → redirect to /onlineboard +│ │ └── layout.tsx # locale resolver, canonical, hreflang, analytics +│ ├── error/[code]/page.tsx # 404, 500 +│ ├── layout.tsx # root html shell, , i18n provider +│ └── page.tsx # / → redirect to /{default-lang}/onlineboard +│ +├── mf/ # MF remote entry (remote mode only) +│ ├── expose/ +│ │ ├── OnlineBoard.tsx +│ │ ├── Schedule.tsx +│ │ ├── FlightsMap.tsx +│ │ └── PopularRequests.tsx +│ └── host-contract.ts # TypeScript interface the host must implement +│ +├── features/ +│ ├── online-board/ +│ │ ├── components/ # ported Angular components, JSX, same class names +│ │ ├── hooks/ # useOnlineBoard, useLiveFlights +│ │ ├── api.ts # REST client for this feature +│ │ ├── url.ts # byte-exact URL serializer/parser +│ │ ├── seo.ts # JSON-LD + OG tag builders +│ │ └── index.ts # barrel — only public surface +│ ├── schedule/ (same shape) +│ ├── flights-map/ (same shape) +│ └── popular-requests/(same shape) +│ +├── ui/ # PrimeReact adapter + Aeroflot SCSS +│ ├── primitives/ # Button, Input, Calendar, Autocomplete, … +│ ├── layout/ # SearchResultsLayout, DetailsLayout +│ ├── flights/ # 100+ ported flight-display components +│ ├── icons/ # SVG icons as React components +│ ├── seo/ # SeoHead +│ ├── errors/ # ErrorBoundary +│ └── styles/ # ported global SCSS, fonts, theme overrides +│ +├── shared/ +│ ├── api/ # ApiClient, LRU cache, circuit breaker +│ ├── signalr/ # @microsoft/signalr lifecycle wrapper +│ ├── hooks/ # useLiveFlights, useSearchHistory, useMediaQuery +│ ├── url/ # reusable URL codecs (date, flight-number) +│ └── utils/ # date/tz helpers, formatters +│ +├── i18n/ +│ ├── config.ts # i18next + react-i18next + i18next-icu setup +│ ├── locales/ # ported JSON files (9 languages) +│ └── resolver.ts # URL-prefix → language, SSR-aware +│ +├── observability/ +│ ├── logger/ # Logger interface + JSON-lines transport +│ ├── metrics/ # OpenTelemetry setup, OTLP exporter +│ └── analytics/ # facade + adapters (metrica, ctm, variocube, dynatrace) +│ +└── env/ # runtime env-var resolution +``` + +**Feature barrel rule:** `features/*/index.ts` is the only public entry of each feature. Both `routes/` and `mf/expose/` import exclusively through the barrel. This is what makes feature-by-feature strangler-fig migration safe. + +**Location rule:** ported Angular components from `FlightsModule` (the 100+ shared flight-display components) land in `src/ui/flights/`. Feature-specific components land in `features/*/components/`. The dividing line is "does more than one feature use it?" + +### 1.4 URL language-prefix policy + +**Language prefix always required.** `/{lang}/onlineboard/…` is the canonical form; missing-prefix URLs (`/onlineboard`) 301-redirect to the `Accept-Language`-detected locale. Every in-app link is generated with the prefix. Byte-exact parity with the Angular app's scheme. + +--- + +## 2. Module Federation topology + build targets + +### 2.1 Two build targets, one `modern.config.ts` + +The repo has one `modern.config.ts` that switches behavior on `BUILD_TARGET=standalone|remote`, producing two distinct artifacts from the same source tree. + +**Standalone target (`BUILD_TARGET=standalone`)** + +- Entry: Modern.js conventional routing from `src/routes/`. +- Output: SSR Node server + client bundle + static assets. +- Deployed as: Dockerized Node service behind the load balancer. +- Serves: `flights.aeroflot.ru` (direct visitors, SEO crawlers, deep links). +- Plugins: `@modern-js/plugin-ssr`, `@modern-js/plugin-i18n`. + +**Remote target (`BUILD_TARGET=remote`)** + +- Entry: `src/mf/index.ts` (imports `src/mf/expose/*`). +- Output: Rspack bundle with `mf-manifest.json`, ES module chunks, no Node server. +- Deployed as: static files on CDN / object storage. +- Served at: `https:///mf-manifest.json` + chunk files. +- Plugin: `@module-federation/modern-js` (official Modern.js adapter for MF 2.0). + +**Why the remote is static, not SSR-serving.** The SSR mandate in requirement 1.1.1 is fulfilled by the *standalone* mode. The *remote* mode is for embedding inside channel apps that run their own SSR — hosts call our exposed components at request time and SSR them inside their own process. Our job is to ship JS the host can import and render, not to operate a second SSR tier for embedded use. This is the conventional MF 2.0 pattern and keeps the remote's operational surface minimal. + +### 2.2 `mf-manifest.json` and exposed modules + +Generated by `@module-federation/modern-js` at build time. Served at `https:///mf-manifest.json`. + +**Exposes (feature-level, 4 entries):** + +``` +name: "aeroflot_flights" +exposes: + ./OnlineBoard → src/mf/expose/OnlineBoard.tsx + ./Schedule → src/mf/expose/Schedule.tsx + ./FlightsMap → src/mf/expose/FlightsMap.tsx + ./PopularRequests → src/mf/expose/PopularRequests.tsx +``` + +Each expose is a thin wrapper: imports the feature barrel, accepts a `hostContract` prop, renders the feature root. All four use `React.lazy()` internally for code-splitting within the feature (requirement 1.1.3). + +**Why feature-level, not page-level or component-level.** Page-level would explode the expose count and force the host to understand our internal routing. Component-level would couple the host to our UI adapter and kill independent deploys. Feature-level is the right granularity: one expose per "thing the channel app wants to embed," with internal routing handled inside the exposed component. + +### 2.3 Shared dependencies + +``` +shared: + react: { singleton: true, requiredVersion: "^18.2.0" } + react-dom: { singleton: true, requiredVersion: "^18.2.0" } + react-i18next: { singleton: true } + i18next: { singleton: true } + @module-federation/runtime: { singleton: true } +``` + +**Deliberately NOT shared** (bundled inside the remote): `primereact/*`, `src/ui/*`, `@microsoft/signalr`, `i18next-icu`, locale JSON, `@opentelemetry/*`, `leaflet`. + +**React singleton is non-negotiable** — mixing React instances breaks Suspense, Context, and Concurrent Mode in ways that are extremely hard to debug. Requirement 1.1.3 mandates Concurrent Mode, which forces `singleton: true`. + +### 2.4 Host contract (`src/mf/host-contract.ts`) + +The single interface the host must implement when embedding an exposed component. This is the entire embedding contract. + +```ts +export interface HostContract { + /** Current language URL prefix, e.g. "ru", "en". Drives i18n + JSON-LD locale. */ + locale: string; + + /** Absolute base URL of the standalone site, used for canonical links + JSON-LD. */ + canonicalOrigin: string; + + /** Optional overrides for deep-link navigation inside the host's router. + * If omitted, the remote uses window.location. */ + navigate?: (path: string) => void; + + /** Optional analytics consent state. If omitted, consent is assumed true. */ + consent?: { analytics: boolean; telemetry: boolean }; + + /** Optional host-provided logger to merge with ours (shipped to same backend). */ + logger?: Logger; +} +``` + +Everything else — data fetching, SignalR, state, styling, SEO tags — is handled inside the remote. + +### 2.5 Build artifacts and deploy shape + +| Mode | Artifact | Deploy target | Runtime | +|---|---|---|---| +| Standalone | `dist/standalone/` (Node server + client bundle + assets) | Dockerized on geo-distributed VMs behind LB (req 4) | Node 20, Modern.js SSR | +| Remote | `dist/remote/` (static: `mf-manifest.json` + JS chunks + SCSS) | CDN / object storage, long-TTL on chunks, short-TTL on manifest | Static files only | + +CI build: `BUILD_TARGET=standalone modern build && BUILD_TARGET=remote modern build`. Both artifacts produced from the same commit in the same CI job and deployed in lockstep. + +**SPEC ASSUMPTION (req 9):** directory layout, exposed module names (`./OnlineBoard` etc.), and `name: "aeroflot_flights"` will be reconciled with the customer's standard remote-frontend template when it arrives. The reconciliation is a rename + manifest tweak, not a structural change. + +--- + +## 3. SSR + routing + URL parity + +### 3.1 SSR request lifecycle (standalone mode) + +``` +1. LB → geo-routed to nearest Modern.js VM +2. Node → Modern.js SSR entry receives the Request +3. Middleware → resolve language from URL prefix (src/i18n/resolver.ts) + → attach request-scoped logger, trace id, otel span + → consent/analytics flags (cookies) +4. Route match → Modern.js file router matches src/routes/[lang]/… +5. Loader → route's loader() runs server-side: + → calls REST API via shared/api (lang + trace id forwarded) + → returns { data, seo } to the component +6. Render → React 18 renderToPipeableStream with Suspense boundaries + → emits , meta, canonical, hreflang, JSON-LD, OG + → streaming HTML flushes shell first, feature content as data resolves +7. Hydrate → client boots, i18next picks up SSR locale, features mount + → useLiveFlights opens SignalR ONLY on client (never during SSR) +8. Response → HTML streamed; Cache-Control per route (§8.2) +``` + +### 3.2 Two SSR invariants, enforced + +1. **No fetching in component bodies.** Every request runs in a `loader()` or a `useEffect` — never at module top-level, never in a component body. ESLint rule (`react-hooks/exhaustive-deps` + custom rule banning `apiClient.` / `fetch(` outside `loader` / `useEffect` / event handlers). **Satisfies requirement 1.1.3 explicitly.** +2. **No SignalR on the server.** `useLiveFlights` is a no-op during SSR and opens the websocket only after `useEffect` runs on the client. SSR delivers a data-complete initial render from REST; the websocket takes over for live updates after hydration. + +### 3.3 Routing — Modern.js file-based + +Route-order precedence matters in two cases: + +- **Online Board:** `flight/:p`, `departure/:p`, `arrival/:p`, `route/:p` are more specific than the bare `:params` details route, so they match first. Modern.js's static-before-dynamic rule handles this. +- **Schedule:** the one-way and round-trip routes (`route/[params]` and `route/[params]/[returnParams]`) are static-prefixed and must match before the catch-all `[...flights]`. + +**Catch-all for multi-leg URLs.** `routes/[lang]/schedule/[...flights]/page.tsx` captures the tail as an array of segments. The loader parses it with a ported TS serializer (§3.5) and fetches the legs in parallel. + +**Language guard.** `src/routes/[lang]/layout.tsx` validates `params.lang ∈ {ru,en,es,fr,it,ja,ko,zh,de}` and issues a 404 otherwise. + +### 3.4 Loaders, Suspense, and `React.lazy` + +Every feature page is a `React.lazy()` import inside the route component: + +```tsx +// routes/[lang]/onlineboard/[params]/page.tsx +import { lazy, Suspense } from "react"; +const FlightDetails = lazy(() => + import("@/features/online-board").then(m => ({ default: m.FlightDetails })) +); + +export async function loader({ params }) { + const parsed = parseFlightDetailsUrl(params.params); // feature/url.ts + const data = await onlineBoardApi.getFlight(parsed); // server-side REST + const seo = buildFlightDetailsJsonLd(data); // feature/seo.ts + return { data, seo, parsed }; +} + +export default function Page() { + const { data, seo, parsed } = useLoaderData<typeof loader>(); + return ( + <> + <SeoHead {...seo} /> + <Suspense fallback={<FlightDetailsSkeleton />}> + <FlightDetails initialData={data} params={parsed} /> + </Suspense> + </> + ); +} +``` + +All three React-18 invariants from req 1.1.3 are enforced: loader pattern (no body side effects), Suspense boundary with skeleton, `React.lazy` dynamic import. + +### 3.5 URL parity — ported serializers + +Each feature has a `url.ts` module with pure functions: + +```ts +// features/online-board/url.ts +export function parseFlightUrlParams(raw: string): FlightQuery; // "SU100-2025-01-15" → {...} +export function buildFlightUrlParams(q: FlightQuery): string; // {...} → "SU100-2025-01-15" +``` + +These are ports of the Angular URL resolvers (`OnlineBoardFlightNumberUrlParamsResolver`, `ScheduleRouteResolver`, etc.) translated to TypeScript. Byte-exact, pure, unit-tested. + +**Parity test suite** compares their output to the Angular app's for every route the Angular unit tests cover, plus fuzz tests (date-format variants, flight-number formats, multi-leg URLs with 2–5 segments). **Merge-blocking CI gate.** + +### 3.6 Canonicalization, hreflang, redirects + +- **Canonical:** every feature page emits `<link rel="canonical" href="{canonicalOrigin}/{lang}/{path}">`. In MF-remote mode, `canonicalOrigin` comes from `HostContract.canonicalOrigin` so the canonical always points at the standalone site, never the embedding host. **This preserves SEO equity when embedded.** +- **hreflang:** each page emits one `<link rel="alternate" hreflang="{lang}">` per language (9 total) + `x-default` → `ru`. Generated by a shared helper; CI parity test asserts the set is identical across all 9 language variants of a given route. +- **Redirects (all 301):** `/` → `/{default-lang}/onlineboard`; `/{lang}` → `/{lang}/onlineboard`; any missing-prefix URL → `Accept-Language`-detected equivalent. + +### 3.7 Deferred decisions (resolved in the implementation plan, not here) + +- Exact default-language resolution rule for missing-prefix URLs — will be decided against the current Angular behavior captured in Phase 0 access logs. +- Default `canonicalOrigin` constant for MF-embedded mode when `HostContract.canonicalOrigin` is not supplied. + +--- + +## 4. Data layer: REST, SignalR, state, hydration + +### 4.1 REST client (`src/shared/api/`) + +A single `ApiClient` — thin `fetch` wrapper, not a library. + +- **Isomorphic:** same code on Node (SSR loaders) and browser. +- **Language injection:** every URL built from `(lang, endpoint, query)`. +- **Trace propagation:** injects W3C `traceparent` / `tracestate` headers from the active OTel span. +- **Timeouts:** 5s default, overridable per call. `ApiTimeoutError` on breach. +- **Retries:** GET retries once on network error or 5xx with 200ms jitter. POST/PUT/DELETE never retry. +- **Error typing:** `ApiHttpError(status)`, `ApiTimeoutError`, `ApiNetworkError` — features switch on class, not on strings. +- **Circuit breaker:** 5 consecutive failures → open for 30s → half-open probe → closed on success. + +No Axios. No TanStack Query. No SWR. + +### 4.2 Caching strategy + +**SSR request cache (per-request, discarded after response).** +`Map<string, Promise<T>>` on the request context. Deduplicates concurrent `apiClient.get(url)` calls within one SSR render. + +**Client cache (per-tab, in-memory, TTL-bounded).** +Custom ~80-line cache keyed by full URL. TTL: 30s for search results, 5m for static reference data (airports, cities). Replaces the Angular `CacheService` directly. + +**Shared server-side LRU cache (per-VM).** +`@isaacs/ttlcache`, in-memory, ~100MB cap. Keyed by `(endpoint, queryParams, locale)`. TTL: 30s for live data, 5m for static data. Per-VM, not shared across VMs — no Redis in the frontend tier (adds failure modes). + +### 4.3 SSR → client data handoff + +1. **Server loader** calls REST, returns `initialData`. +2. **Page component** passes `initialData` as a prop to the feature root. +3. **Feature root** seeds local state from `initialData` on mount; `useLiveFlights` then opens the websocket (if applicable). + +No double-fetching. Data is serialized into the HTML via Modern.js's `useLoaderData` hydration, not a custom `window.__INITIAL_STATE__`. + +### 4.4 SignalR wrapper (`src/shared/signalr/`) + +Two layers. + +**Layer 1 — `SignalRConnection` (pure TS).** Singleton per hub URL. Reference-counted subscriptions. Single `HubConnection` with `withAutomaticReconnect([0, 2000, 10000, 30000])`. Grace-period close (5s after last unsubscribe) to survive React 18 Strict Mode double-mount and route-change thrash. Emits lifecycle events (`connected`, `disconnected`, `reconnecting`, `reconnected`) into logger and metrics. **Never imported during SSR** — dynamic-imported inside `useLiveFlights`'s `useEffect`. + +**Layer 2 — `useLiveFlights(params, initialFlights)` hook.** + +```ts +function useLiveFlights( + params: { date: string; departure?: string; arrival?: string }, + initialFlights: Flight[] +): { flights: Flight[]; connectionStatus: "idle"|"connecting"|"live"|"reconnecting"|"offline" }; +``` + +Behavior: + +1. Initializes from `initialFlights` (SSR payload). +2. On `useEffect` mount: dynamic-imports `SignalRConnection`, subscribes to the channel. +3. On `RefreshDate` push: merges into local state with a reducer that preserves UI state (selected flight, expanded rows). +4. On unmount: unsubscribes. Grace-period close handles Strict Mode double-invoke correctly. +5. SSR: returns `{ flights: initialFlights, connectionStatus: "idle" }` without touching the SignalR module. + +**Vitest test simulates two rapid mounts and asserts a single `HubConnection.start()` call.** + +### 4.5 State management + +**No global store.** No Redux, no Zustand, no Jotai. + +- `useReducer` for feature-root filter + result state (ports Angular `BehaviorSubject`s to a single filter reducer). +- `useState` for local UI state. +- `useContext` for request-scoped things: locale, `HostContract`, `ApiClient`. +- `useSyncExternalStore` for subscribing to `SignalRConnection` lifecycle events. + +**`useSearchHistory`** (~60 lines, in `shared/hooks/`) replaces the Angular `SearchHistoryService`: per-language `localStorage` key, returns `[history, push, clear]`. + +### 4.6 Error handling path + +- **SSR loader throws `ApiHttpError(404)`** → renders `routes/error/[code]/page.tsx` with `code=404`, HTTP response 404. +- **SSR loader throws `ApiHttpError(5xx)`** → same, with `code=500`, HTTP 500. +- **SSR loader throws `ApiTimeoutError`** → "data temporarily unavailable" page with HTTP 503 + `Retry-After`, structured log at `error`. +- **Client-side refetch error** → inline error UI (red banner, "Retry" button); logs at `warn`; `flights.api.error` counter. Never yanks the user to a full-page error. +- **SignalR disconnect** → UI "offline" badge; keeps showing last-known data; logs at `info`; `flights.signalr.disconnect` counter. + +--- + +## 5. UI adapter + styling + pixel-parity strategy + +### 5.1 The `src/ui/` boundary + +Every visible DOM element is produced by a component from `src/ui/`. No feature file imports `primereact/*` directly, imports Leaflet directly, or writes raw styled form controls. Enforced by ESLint `no-restricted-imports` + a custom rule banning bare `<button>` / `<input>` in feature code. + +This is the **single swap point** for when the customer's real design system arrives: reskin `src/ui/` and feature code stays untouched. + +### 5.2 Styling — SCSS Modules, no CSS-in-JS, no Tailwind + +- **SCSS Modules** (`Component.module.scss`) for component-scoped styles. Zero runtime cost. +- **Global SCSS** for font-face, PrimeReact theme overrides, reset, CSS custom properties. +- **No Tailwind, no styled-components, no emotion.** Pixel parity + SSR complexity make them the wrong choice here. +- **Class-name preservation.** Ported Angular components emit HTML with identical class names. Enforced by VRT (§8.4) — mismatches surface as pixel diffs. + +### 5.3 PrimeReact theming — port, don't rewrite + +1. Extract the current PrimeNG SCSS overrides from `ClientApp/src/styles/`. +2. Rebase onto PrimeReact's theme structure. PrimeReact and PrimeNG share the "Prime" design system and CSS class-naming conventions (`p-button`, `p-autocomplete-panel`, `p-calendar-header`) — selector overrides mostly just work. +3. Variable-rename diff: PrimeReact v10 uses CSS custom properties (`--primary-color`) where PrimeNG v10 used SCSS variables (`$primary-color`). Convert the variable layer, leave selectors intact. + +**Expected failure cases** — 5–15 PrimeReact components will render slightly off vs. their PrimeNG siblings due to DOM-structure differences. The hardest: **Calendar** (multi-month range picker), **Autocomplete** (suggestion panel), **DataTable**, **Toast**. Absorbed in `src/ui/primitives/` wrappers + compensating CSS. Unresolved diffs go to `docs/visual-parity-exceptions.md` with a screenshot and reason, signed off at phase exit. + +### 5.4 Per-component porting workflow + +1. Read Angular source (`.ts` + `.html` + `.scss` + `.spec.ts`). +2. Translate template to JSX preserving DOM + class names: `*ngIf` → `{cond && <…>}`, `*ngFor` → `.map(…)`, `[class.x]` → `clsx()`, `(click)` → `onClick`, `{{expr}}` → `{expr}`. +3. Translate logic: inputs/outputs → props, lifecycle → `useEffect`, services → hooks or props, pipes → functions / `useMemo`. +4. Port `.scss` → `.module.scss`. +5. Write Vitest unit test mirroring the Angular spec surface. +6. Capture Playwright VRT baseline for the component in isolation. +7. Diff against Angular via the parity harness. + +Components port in waves by dependency order: primitives → layout → flight displays → feature pages. + +### 5.5 Leaflet — separate adapter + +`src/features/flights-map/map/MapCanvas.tsx` is the only file allowed to import `leaflet`. Dynamic-imported via `React.lazy()` so Leaflet's ~150kB doesn't bloat bundles for other features. SSR-safe via `<ClientOnly>` wrapper that returns `null` on the server. Tile URLs, marker icons, polyline styling, cluster behavior ported verbatim from the Angular component. + +### 5.6 Fonts, icons, images + +- **Fonts:** ported to `src/ui/styles/_typography.scss` + `public/fonts/`. `font-display: swap` preserved. Self-hosted. +- **SVG icons:** ported as React components via `@svgr/webpack` (Rspack supports it). Typed, tree-shakeable. +- **Raster images:** ported to `public/images/` at current Angular paths so `<img src>` references don't need rewriting. + +### 5.7 Responsiveness + +- SCSS media queries port directly. +- Angular `BreakpointObserver` → `useMediaQuery` hook (~15 lines, SSR-safe: returns desktop default on server). +- VRT runs at 3 viewports: 375px (mobile), 768px (tablet), 1440px (desktop). + +--- + +## 6. i18n + SEO + JSON-LD / OpenGraph + +### 6.1 i18n runtime + +**Stack:** `i18next` + `react-i18next` + `i18next-icu` + `i18next-resources-to-backend`. + +**Why:** `i18next-icu` uses ICU MessageFormat — the same grammar as the Angular app's `messageformat.js` compiler. **The 9 existing JSON translation files port with zero content changes.** No re-authoring plurals, no translator round-trip. + +### 6.2 Locale resolution + +**Standalone:** Modern.js middleware reads `params.lang`, validates against `{ru,en,es,fr,it,ja,ko,zh,de}`, creates a request-scoped `i18next` instance loaded with only that language's JSON. + +**Remote:** locale comes from `HostContract.locale`. + +**Hydration:** SSR payload includes serialized locale bundle under `window.__I18N__`; client `i18next` boots synchronously before hydration. No flicker, no FOUC. + +### 6.3 JSON file port + +- Copy `ClientApp/src/assets/i18n/*.json` → `src/i18n/locales/{lang}/common.json`. +- Schema unchanged — keys, nesting, ICU syntax byte-for-byte. +- `interpolation.escapeValue: false` to match `@ngx-translate`. Offset by a lint rule in CI that scans for raw HTML / `on\w+=` / `<script>` in strings. +- Single namespace `common` for Phase 1. Feature namespaces can be introduced later. + +### 6.4 Locale-aware formatting + +- **Replace Moment.js with `date-fns` + `date-fns-tz`.** Moment is maintenance-only; `date-fns` tree-shakes, has TS types, supports all 9 locales. +- **Custom Angular pipe logic** (`DurationPipe`, `TzOffsetPipe`, `DayChangePipe`, `TransferTimePipe`) ports to `src/shared/utils/datetime/` as pure functions, unit-tested with the fixtures from the Angular spec files. +- **Numbers:** `Intl.NumberFormat`. +- **Timezone data:** `@date-fns/tz` (bundled tzdata). + +**High-risk ports:** `formatTzOffset` and `formatDayChange`. Both have DST edge cases. Contract tests replay real API responses against the Angular output and assert identical strings. CI blocks on any drift. + +### 6.5 SEO — `<head>` management + +**No `react-helmet` / `react-helmet-async`.** Modern.js's built-in `<Head>` / `useHead` works correctly with streaming SSR + Suspense. + +`<SeoHead>` emits: + +- `<title>`, `<meta name="description">` +- `<link rel="canonical">` (absolute, always points at standalone site) +- One `<link rel="alternate" hreflang>` per language + `x-default` → `ru` +- Open Graph (`og:title`, `og:description`, `og:url`, `og:image`, `og:type`, `og:locale`, `og:site_name`) +- Twitter Card (`twitter:card="summary_large_image"`, `twitter:title`, `twitter:description`, `twitter:image`) +- One or more `<script type="application/ld+json">` JSON-LD blocks + +### 6.6 JSON-LD schema coverage + +Each feature exports a `seo.ts` module with typed builders (types from `schema-dts`). + +| Feature | Route | Schema | +|---|---|---| +| Online Board | Flight details | `Flight` (flightNumber, airline, airports, times, distance, status) | +| Online Board | Search results | `ItemList` of `Flight` | +| Schedule | Search results | `ItemList` of `Flight` per leg | +| Flights Map | Map page | `WebPage` + `mainEntity: Place` | +| Popular Requests | Listing | `CollectionPage` + `ItemList` | +| Every page (root) | — | `WebSite` + `SearchAction` | + +Airport references emit `Airport` sub-entities with IATA code, name, `addressCountry`, `geoCoordinates`. Richer than the current Angular app's JSON-LD, driven by req 6. + +**Emission rule:** JSON-LD is generated inside the loader (server-side) from the same REST data the component renders. Never generated on the client. Guarantees JSON-LD reflects what the crawler sees. + +**Validation:** CI job runs a JSON-LD validator (`schema-dts` runtime validator or Google Structured Data Testing Tool) against fixture renders. Malformed JSON-LD blocks the build. + +### 6.7 OpenGraph images + +- **Phase 1:** static branded Aeroflot OG image in `public/og/default.png`, used as fallback and on start pages. +- **Phase 2 enhancement:** dynamic per-flight OG images via Satori, served from `routes/og/flight/[params]/image.tsx`, cached `s-maxage=86400`. + +### 6.8 Canonical + hreflang correctness (the two landmines) + +1. **Canonical in embedded mode still points at the standalone site** — never the host's URL. The feature's `url.ts` + `HostContract.canonicalOrigin` build the correct URL regardless of embedding. +2. **`hreflang` must be reciprocal.** A shared helper generates the full set once; SSR renders it identically on every language variant. CI parity test renders all 9 variants and asserts the `hreflang` sets are byte-identical. + +--- + +## 7. Observability — logging, metrics, analytics, errors + +### 7.1 Three separate pipelines + +| Pipeline | Producer | Consumer | Transport | +|---|---|---|---| +| Logs | `Logger` interface | Customer log system | JSON-lines over HTTP POST (batched) | +| Metrics | `@opentelemetry/api` | Metrics aggregator (OTLP-compatible) | OTLP/HTTP | +| Analytics | `track(event, props)` facade | Metrica / CTM / Variocube / Dynatrace RUM | Vendor scripts, lazy-loaded | + +### 7.2 Logging + +**`Logger` interface:** + +```ts +export interface Logger { + debug(msg: string, fields?: LogFields): void; + info(msg: string, fields?: LogFields): void; + warn(msg: string, fields?: LogFields): void; + error(msg: string, fields?: LogFields & { err?: Error }): void; + child(context: LogFields): Logger; +} +``` + +**Default transport: `JsonLinesHttpTransport`.** Buffers up to 100 records or 5s; POSTs `application/x-ndjson`; drops oldest on backpressure; final flush via `navigator.sendBeacon` (browser) or SIGTERM handler (Node); redacts `password`, `token`, `authorization`, `cookie`, `email`, `phone` fields. + +**Standard fields** on every record via a request-root `child()`: + +``` +ts, level, msg, service, mode, env, version, +trace_id, span_id, locale, route, +user_agent (browser), request_id (server) +``` + +**Customer format hook.** Requirement 8's log format is TBD. Default transport today = `JsonLinesHttpTransport`. When the customer's spec arrives, we write `CustomerFormatTransport implements LogTransport` and swap it at `src/observability/logger/index.ts`. Features and middleware don't change. **SPEC ASSUMPTION: default transport will be replaced when the format lands.** + +### 7.3 Metrics — OpenTelemetry + +**SDK:** `@opentelemetry/api` + `@opentelemetry/sdk-node` (server) + `@opentelemetry/sdk-web` (browser) + OTLP/HTTP exporters. + +**Auto-instrumentations:** `fetch` / `undici`, `http` (server), router transitions. + +**Custom metrics (Phase 1 minimum):** + +- `flights.ssr.request.duration` — histogram (labels: route, status, locale) +- `flights.api.request.duration` — histogram (labels: endpoint, status, feature) +- `flights.api.error` — counter (labels: endpoint, status, feature) +- `flights.signalr.connected` — up-down counter (labels: hub) +- `flights.signalr.message.received` — counter (labels: channel) +- `flights.signalr.disconnect` — counter (labels: reason) +- `flights.feature.render` — counter (labels: feature, mode) +- `flights.web_vitals.{lcp,fid,cls,inp,ttfb}` — histograms + +**OTLP endpoint:** configurable (`OTEL_EXPORTER_OTLP_ENDPOINT`). Any OTLP-speaking aggregator works (Dynatrace, Grafana/Mimir, Prometheus-via-adapter). + +**Sampling:** traces 100% in dev/testing, 10% head-based in prod; metrics 100% always; errors always 100%. + +### 7.4 Analytics + +**Facade:** + +```ts +export interface Analytics { + track(event: string, props?: Record<string, unknown>): void; + page(url: string, props?: Record<string, unknown>): void; +} +``` + +**Four vendor adapters** in `src/observability/analytics/adapters/`: `metrica.ts`, `ctm.ts`, `variocube.ts`, `dynatrace.ts`. + +**Loading rules (strict):** + +1. **Never loaded during SSR.** Vendor scripts are injected client-side only. +2. **Lazy-loaded post-hydration.** `<AnalyticsLoader>` on the root layout waits for `requestIdleCallback` (or 2s fallback), then dynamic-imports each enabled adapter. +3. **Consent-gated.** `HostContract.consent.analytics === false` (or standalone consent cookie = no) → no adapter loads, `track()` is a no-op. +4. **Per-vendor feature flag.** `ANALYTICS_ENABLED: { metrica, ctm, variocube, dynatrace }` per env. Disabled vendors tree-shake out. +5. **Fail silently.** Load failure emits `flights.analytics.load_failed` metric; feature code never sees an exception. + +**Dynatrace dual role.** Dynatrace (Ключ-Астром) is used as an **analytics vendor only** (RUM agent for user-behavior). Server-side metrics still go through OpenTelemetry → OTLP (§7.3). Dynatrace's OTLP ingress can still be the OTLP endpoint if the customer wants — configuration, not architecture. + +### 7.5 Error handling layers + +1. **SSR loader errors** → Modern.js loader-error boundary → `routes/error/[code]/page.tsx` with HTTP status; `error`-level log; OTel span status ERROR. +2. **Client React error boundaries** — one at root layout, one per feature root. Port the Angular error component as the fallback UI. Log at `error` with `err` + component stack; `flights.react.error` counter. Offer "Retry" that resets the boundary. +3. **Global unhandled rejections / window errors** — installed on client mount, forwarded to logger + metrics. + +**User-facing vs. unexpected:** expected API errors (404 flight not found) → inline feature UI. Unexpected errors (API 500, render crash) → boundary fallback. Features explicitly classify. + +### 7.6 Correlation + +One W3C `trace_id` + `span_id` across logs, metrics, and analytics. An investigation can pivot from an error in logs → trace → metrics for that endpoint → the user's analytics session. + +### 7.7 Client performance budgets (observability, not hard gates) + +| Metric | Budget | +|---|---| +| LCP | < 2.5s p75 | +| INP | < 200ms p75 | +| CLS | < 0.1 p75 | +| TTFB | < 800ms p75 | + +Reported via `web-vitals` → metrics pipeline. Regressions open issues; do not block CI. + +### 7.8 Dev-mode ergonomics + +- Logs in dev: `stdout` / `console.log` via `ConsoleTransport`. +- Metrics in dev: OTLP exporter points at a local collector + Grafana (optional docker-compose), auto-disabled if unreachable. +- Analytics in dev: all adapters forced off; `NoopAnalytics` keeps feature code paths exercised. + +--- + +## 8. Security, performance, reliability, testing, CI/CD + +### 8.1 Security — component isolation (req 5) + +**Sandbox controls in code:** + +- No `eval`, no `new Function`, no `document.write`. ESLint `no-eval` + custom rule banning `innerHTML = ` in favor of React rendering. +- No `postMessage` listeners that skip `event.origin` validation against a `HostContract`-supplied allowlist. +- MF `chunkLoadingGlobal` remains randomized (as in the Angular `custom-webpack.config.js`). +- **localStorage namespace:** all keys prefixed `aeroflot_flights_`, scoped by language. A `storage.ts` wrapper is the only module allowed to touch `window.localStorage` (ESLint rule). + +**Content Security Policy (standalone SSR response, per-request nonce):** + +``` +default-src 'self'; +script-src 'self' 'nonce-{per-request}' + https://mc.yandex.ru https://ctm.example + https://variocube.example https://js.dynatrace.com; +style-src 'self' 'unsafe-inline'; +img-src 'self' data: https:; +connect-src 'self' https://platform.aeroflot.ru + wss://platform.aeroflot.ru + https://{otlp-endpoint} https://mc.yandex.ru …; +font-src 'self' data:; +frame-ancestors https://*.aeroflot.ru; +base-uri 'self'; +form-action 'self'; +object-src 'none'; +upgrade-insecure-requests; +``` + +Nonce is generated in SSR middleware and propagated via React Context to `<Head>`. + +**Remote mode** doesn't set CSP itself (host's SSR does). Remote-exposed components emit **no inline scripts**, so the host's CSP doesn't need to allowlist us. + +**HTTP security headers:** + +``` +Strict-Transport-Security: max-age=63072000; includeSubDomains; preload +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: geolocation=(), camera=(), microphone=() +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Resource-Policy: cross-origin +``` + +**Supply-chain:** + +- `npm audit` + `osv-scanner` every CI run; `high` / `critical` block merge. +- Dependabot weekly. +- `npm ci` in CI; lockfile integrity required. +- `package.json` `overrides` for pinning vulnerable transitives. + +**Secrets:** never in the frontend bundle. Log-shipping tokens, OTLP auth headers are server-only env vars. + +### 8.2 Performance (req 3 — 100 RPS) + +**Target: 100 RPS per VM, p95 < 500ms under load.** Aggregate capacity = `N × 100 RPS` via geo-distribution (req 4). + +**Mechanisms:** + +1. **Loader-level caching** — per-request dedup + per-VM LRU (`@isaacs/ttlcache`, 100MB cap, 30s TTL live / 5m TTL static). +2. **HTTP cache headers by route type:** + + | Route type | `Cache-Control` | + |---|---| + | Start pages | `public, max-age=60, s-maxage=300, stale-while-revalidate=600` | + | Search results | `public, max-age=30, s-maxage=60, stale-while-revalidate=300` | + | Flight details | `public, max-age=60, s-maxage=120, stale-while-revalidate=600` | + | Error pages | `public, max-age=60` | + | OG images | `public, max-age=3600, s-maxage=86400` | + | Static assets (content-hashed) | `public, max-age=31536000, immutable` | + +3. **CDN in front** — **SPEC ASSUMPTION:** vendor is customer-chosen (likely Yandex Cloud CDN). Our cache headers are compatible with any standards-compliant CDN. +4. **Bundle-size budget (CI-enforced):** + - Initial client JS (above-the-fold): ≤ **180 kB gz** (excluding React/react-dom singletons) + - Per-feature lazy chunk: ≤ **80 kB gz** + - Leaflet chunk (flights-map only): ≤ **180 kB gz** + - PrimeReact contribution: ≤ **60 kB gz** + - CI runs a bundle analyzer on every PR; diffs against main; violations block merge. +5. **Image handling:** `loading="lazy"` + `decoding="async"` + explicit `width`/`height` to prevent CLS. +6. **Streaming SSR:** React 18 `renderToPipeableStream` flushes shell + `<head>` first. +7. **Node runtime tuning:** Node 20, `--max-old-space-size=2048`, `UV_THREADPOOL_SIZE=16`, keep-alive HTTP agent for upstream REST. + +**Load test as a CI gate (nightly, not per-PR).** `k6` or `autocannon` hits a deployed staging instance with a production-mix (70% online board, 20% schedule, 5% flights-map, 5% popular), ramping to 150 RPS for 10 min. Thresholds: p95 < 500ms, error rate < 0.1%. + +### 8.3 Reliability & fault tolerance (req 4) + +**Geographic distribution.** Standalone SSR runs on **≥2 VMs in ≥2 AZs/regions** behind L7 LB with `/health` checks. Remote mode is static files on CDN → geo-distributed by default. + +**`/health` endpoint.** `200 OK` if the process is up and the last upstream REST ping succeeded within 60s. `503` if upstream unreachable > 60s (serves stale cache, signals degradation to LB). + +**Graceful degradation ladder:** + +1. Healthy — SSR + live SignalR + fresh REST. +2. **Cache-serving** — upstream transient fail → LRU serves last-known-good ≤5 min; `warn` log; `flights.api.cache_served_stale` metric. +3. **SignalR degraded** — hub unreachable → REST-only data, "offline" badge, feature still works. +4. **Full upstream outage** — REST unreachable >5 min + empty cache → error page with `Retry-After`. **Never a white screen.** + +**MTTR target:** **≤60s after process crash** (auto-restart via supervisor) — far tighter than req 6's 6h contractual ceiling. + +**Graceful shutdown.** SIGTERM → stop accepting → drain in-flight (30s) → flush log buffer → exit. + +**Circuit breaker** on upstream REST (§4.1) prevents cascade. + +### 8.4 Testing strategy + +**Stack:** Vitest (unit) + Playwright (e2e + VRT). No Karma, no Cypress. + +**Unit tests (Vitest + React Testing Library).** Every pure function in `features/*/url.ts`, `features/*/seo.ts`, `shared/utils/`, `observability/logger/`, `shared/api/`. Every component in `src/ui/primitives/` and `src/ui/flights/` (renders without crash, responds to key props, handles empty/loading/error states). **70% line coverage on `src/features/`, `src/shared/`, `src/ui/`, `src/observability/`.** Hard gate: coverage may not decrease in a PR. The SignalR wrapper includes a Strict-Mode double-mount test. + +**URL parity tests (Vitest, merge-blocking).** Table-driven over URLs from the Phase 0 prod-access-log corpus. `fast-check` property fuzz tests (generated flight numbers, dates, IATA codes, multi-leg URLs). **Any URL regression blocks merge.** + +**Contract tests against real REST API** (`@contract` tag, nightly hard gate, per-PR warn-only). `zod` schemas validate response shape against TypeScript types. + +**i18n parity test** — render every route in every language, assert no missing-key warnings. + +**SEO tests** — SSR-render each feature, parse with `cheerio`, assert `<title>`, `<meta name="description">`, `canonical`, full `hreflang` set, OG tags, JSON-LD parses + validates against `schema-dts` types, every `<script>` has a CSP nonce. + +**Integration tests (Playwright, standalone mode).** Ported Cypress scenarios + new SignalR live-update test (mocked hub) + Flights Map load + Popular Requests + language switch + error pages (404, 500). + +**Visual regression tests (Playwright snapshots) — the pixel-parity gate.** + +- **Baseline source:** current Angular prod build. `scripts/refresh-baselines.ts` hits the Angular prod URL and writes PNGs to `tests/visual/baselines/`. +- **Matrix for Phase 1:** ~10 curated routes × 3 viewports (375/768/1440) × 2 languages (ru, en) = **60 baselines**. Expanded per-feature per phase. +- **Threshold:** ≤ **0.1%** pixel diff per image. Per-test `mask` option for dynamic regions (live flight times, map tiles). +- **CI gate:** baseline violation blocks merge. Updates go through a PR regenerating baselines, reviewed explicitly. +- **Phase-end re-baselining:** when a feature's port is accepted, baselines regenerate from the React build and lock in — future diffs compare against the React baseline. + +**Performance tests (nightly):** `k6` / `autocannon` load test; Lighthouse CI with perf budget; bundle-size tracker graphs. + +**Accessibility (`@axe-core/playwright`).** WCAG AA violations **warn** in Phase 1, **block** in Phase 2. (Angular baseline a11y state is unknown; we don't want to discover the Angular app fails WCAG and block our own rewrite.) + +### 8.5 CI/CD + +**Provider: GitHub Actions.** **SPEC ASSUMPTION** — if Aeroflot uses GitLab CI or TeamCity, pipelines port directly (pure Docker + npm scripts, no GH-specific glue). + +**Per-PR pipeline (< 20 min target):** + +``` +1. Install (pnpm i --frozen-lockfile) ~90s +2. Lint (eslint . --cache) ~30s +3. Type check (tsc --noEmit) ~60s +4. Unit tests (vitest run --coverage) ~120s +5. URL parity tests ~20s +6. SEO tests ~40s +7. Build both targets (standalone + remote) ~180s +8. Bundle-size gate ~10s +9. Integration tests (Playwright) ~240s +10. Visual regression ~180s +11. Security scan (osv-scanner, npm audit) ~60s +``` + +**Nightly:** contract tests vs. staging, load test, Lighthouse CI, Dependabot. + +**Deploy pipeline (on merge to `main`):** + +1. Reuse PR-built artifacts (don't rebuild). +2. Deploy standalone to canary VM (1 of N), 5% traffic slice. +3. Automated canary analysis (15 min): compare error rate, p95 latency, `flights.react.error` to previous-deploy baseline. Auto-rollback on > 2× regression. +4. Roll out remaining VMs sequentially with health-check waits. +5. Deploy remote to CDN with `mf-manifest.json` version bump. +6. Post-deploy smoke test hits the deployed URL. + +**Environments:** `development` (local), `testing` (CI-shared), `staging` (prod-mirror), `production` (canary-gated). Env config at runtime (`src/env/`), not build-time — same artifact redeployable across envs. + +--- + +## 9. Strangler-fig migration phasing + +### 9.1 Operating model + +The current Angular app stays in prod throughout. Each phase delivers a vertical slice that ships to production behind a **route-level reverse-proxy toggle**: specific URL prefixes route to React, the rest continue to hit Angular. By Phase 6 the Angular app serves zero traffic and is decommissioned. + +**Proxy routing:** + +``` +/{lang}/onlineboard/* → React (after Phase 2) +/{lang}/schedule/* → React (after Phase 3) +/{lang}/flights-map/* → React (after Phase 4) +/{lang}/popular/* → React (after Phase 5) +/* → Angular (default until Phase 6) +``` + +Every phase cutover is reversible — flipping the proxy rule restores Angular traffic for that route. **SPEC ASSUMPTION:** the customer controls the edge routing layer; we provide route rules as config, not infra. + +### 9.2 Phases + +#### Phase 0 — Preflight (≈1 week, no production change) + +- Capture current URLs from Angular prod access logs → URL parity corpus. +- Capture current JSON-LD / OG / hreflang on ~20 representative routes → SEO parity baselines. +- Capture Playwright VRT baselines of the Angular prod build (60 images per §8.4). +- Inventory PrimeNG components, SCSS tokens, theme overrides → `src/ui/` backlog. +- Inventory `@ngx-translate` keys actually in use → translation-file port manifest. +- Confirm customer decisions flagged as assumptions: module template (§2.5), CDN vendor (§8.2), CI provider (§8.5), log format (§7.2). + +**Exit gate:** Phase 0 deliverables committed as fixtures. No React code yet. + +#### Phase 1 — Foundation (≈4 weeks, no production change) + +Build everything needed to ship one feature safely. Nothing ships to users. + +**In scope:** + +- Modern.js + Rspack project skeleton; both build targets working end-to-end with `mf-manifest.json`. +- `src/ui/` adapter layer with PrimeReact primitives + Aeroflot SCSS theme (enough to support Online Board; more added as later phases need). +- `src/i18n/` with i18next + i18next-icu, 9 locale files ported. +- `src/shared/api/` ApiClient + LRU + circuit breaker + request-scoped dedup. +- `src/shared/signalr/` wrapper with Strict-Mode tests (no feature wired). +- `src/observability/` — Logger, OTel, analytics facade with all four adapters (stubbed loaders). +- Root layout: `<Head>`, locale provider, analytics loader, error boundary, canonical + hreflang helper. +- `routes/error/` 404/500 pages. +- URL / SEO / VRT parity harnesses (infra only). +- CI pipeline (§8.5) with bundle-size gate. +- Deploy to `testing` with canary placeholder. +- Security hardening: CSP, HTTP headers, supply-chain scan. +- Smoke route `routes/[lang]/smoke/page.tsx` exercising the full foundation end-to-end. + +**Out of scope:** any of the 4 features, production traffic, proxy rule change. + +**Exit gate:** both artifacts built in CI; `mf-manifest.json` consumable by a test host; smoke route SSRs in `testing` with all observability pipelines emitting correctly; all Phase 1 CI gates green on `main`; security scan clean. + +#### Phase 2 — Online Board feature (≈5 weeks) + +Port the hardest feature first (SignalR live data + deep-linked search + flight details + real-time UI + SEO). + +**In scope:** + +- `src/features/online-board/` — Start, Search (arrival / departure / route / flight), Flight Details. +- `useLiveFlights` wired to real `TrackerHub` with full lifecycle + Strict Mode correctness. +- All `routes/[lang]/onlineboard/*` routes with loaders, Suspense, `React.lazy`. +- URL parity tests for every `onlineboard/*` shape from the Phase 0 corpus → 100% pass required. +- SEO parity: canonical, hreflang, OG, JSON-LD (`Flight` + `ItemList`). +- Playwright integration tests (4 ported Cypress + SignalR mock + error cases). +- Playwright VRT for all online-board routes × 3 viewports × 2 langs. +- The subset of `src/ui/flights/` Online Board uses. +- Real analytics vendors enabled in `testing` + `staging`. +- Load test for online-board routes at 150 RPS. + +**Cutover:** + +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. + +**Exit gate:** 100% of `/{lang}/onlineboard/*` on React for 1 week clean; URL parity 100% verified against prod access logs; VRT exceptions signed off. + +#### Phase 3 — Schedule feature (≈4 weeks) + +Port the feature with the most complex routing (variable-length multi-flight catch-all) and the heaviest Calendar usage. + +**In scope:** + +- `src/features/schedule/` — Start, Search (one-way / round-trip), Flight Details (multi-leg). +- Catch-all `routes/[lang]/schedule/[...flights]/page.tsx` with ported serializer. +- PrimeReact Calendar range picker + Aeroflot theme overrides (the hardest pixel-parity target — deliberately isolated to this phase so its risk doesn't compound with SignalR from Phase 2). +- Full URL parity including multi-leg fuzz corpus. +- SEO: JSON-LD (`ItemList` of `Flight` per leg). +- VRT expanded to all schedule routes. + +**Cutover:** same canary mechanics, scoped to `/{lang}/schedule/*`. + +**Exit gate:** 100% of `/{lang}/schedule/*` on React for 1 week clean; Calendar pixel-parity delta ≤ threshold or documented. + +#### Phase 4 — Flights Map feature (≈3 weeks) + +Port the Leaflet visualization. Isolated because Leaflet has its own failure modes. + +**In scope:** + +- `src/features/flights-map/` — Start + Map Search Results. +- Leaflet wrapper (only file allowed to `import "leaflet"`). +- `<ClientOnly>` boundary. +- Tile URLs, marker icons, clustering ported byte-equivalent. +- Feature flag: `env.features.flightsMap` matches Angular's `FeatureFlagGuard` behavior per env. +- VRT masks the tile area (time-varying); tests UI chrome only. +- Integration test: click a route, verify marker + polyline rendering. + +**Cutover:** same mechanics. + +**Exit gate:** 100% of `/{lang}/flights-map/*` on React (where flag on), feature-flag parity verified, 1 week clean. + +#### Phase 5 — Popular Requests + last-cleanup chrome (≈2 weeks) + +**In scope:** + +- `src/features/popular-requests/`. +- Language switcher (ported from Angular layout). +- Footer / header chrome if not already in Phase 1 root layout. +- `useSearchHistory` wired to `localStorage` with per-language namespacing. +- Any components Phase 0 missed that surface during porting. + +**Cutover:** scoped to `/{lang}/popular/*`. Chrome changes go live with the Phase 5 deploy across all React-served routes. + +**Exit gate:** 100% of `/{lang}/popular/*` on React; no route remains Angular-served except the root `/` redirect and language-prefix fallbacks. + +#### Phase 6 — Cutover + decommission (≈2 weeks) + +- Flip default proxy rule: `/*` → React. Angular backend receives zero traffic. +- 1 week soak confirming zero Angular hits. +- Tag Angular code in git; archive `ClientApp/` (separate branch / archived repo). +- Decide ASP.NET host fate: **SPEC ASSUMPTION** → delete unless it serves something unrelated to the frontend; confirm at Phase 6 kickoff. +- Simplify / remove reverse-proxy split rules. +- Post-mortem → fed into a "lessons learned" appendix of this document. + +**Exit gate:** zero Angular traffic for 1 week; Angular archived; prod error rate ≤ pre-migration baseline. + +### 9.3 Cross-cutting practices + +**Per-phase parity contract** — every exit gate must prove: + +1. **URL parity** — every URL the Angular app served for this feature still works byte-exactly. +2. **SEO parity** — JSON-LD / OG / hreflang / canonical equivalent to or richer than Angular's. +3. **Visual parity** — VRT diffs below threshold, or documented exceptions. + +Non-negotiable. + +**Rollback window.** Every phase's "flip-back-to-Angular" proxy rule is retained for 2 weeks after reaching 100% React. Gives a hot-rollback window and a longer cold-rollback via redeploy. + +**Communication rhythm.** Each phase kickoff produces a 1-pager (scope, risks, exit gate). Each phase end produces a ≤1-page retro. Stored in `docs/superpowers/migration/phase-{n}.md`. + +**Team-size sanity check (not a contract):** with 2 full-time frontend engineers, the calendar estimates above ≈ **20 weeks total**. With 1 engineer, ~1.8× longer. With 3+, Phases 2/3/4 can partially parallelize, but the critical path stays ~16 weeks due to prod-soak at each gate. + +### 9.4 Explicit non-promises + +- **No parallel feature development.** Phase 2 must reach its exit gate before Phase 3 starts. +- **No green-field redesigns.** Each phase is a port. New UX is stacked after Phase 6. +- **No API changes.** REST contract unchanged; any backend evolution is a separate project. + +--- + +## 10. Spec assumptions — reconciliation list + +These are the explicit assumptions this spec makes about decisions that are not yet confirmed by the customer. Each will be tightened at Phase 0 or when the information arrives. + +| # | Assumption | Spec section | Impact if wrong | +|---|---|---|---| +| A1 | Customer's "standard remote-frontend module template" not yet available → design to Modern.js default MF remote layout | §2.5, §1.3 | Directory rename + manifest tweak, not a structural change | +| A2 | CDN vendor is customer-chosen (likely Yandex Cloud CDN); our cache headers are standards-compliant and work with any | §8.2 | Cache header tuning per CDN specifics | +| A3 | CI provider is GitHub Actions; if GitLab CI / TeamCity instead, pipelines port directly | §8.5 | Rewrite of pipeline YAML, no code change | +| A4 | Customer log format is TBD; `JsonLinesHttpTransport` is the interim transport; a new `CustomerFormatTransport` will swap in when the format lands | §7.2 | New transport module, no feature change | +| A5 | The ASP.NET host (`Aeroflot.Flights.Web.csproj`) can be deleted at Phase 6 unless it serves something unrelated to the frontend | §9.2 (Phase 6) | May need to keep it running if it has non-frontend responsibilities | +| A6 | Dynatrace RUM is used for analytics only; OTel → OTLP remains the metrics path | §7.4 | None — this is an architectural guarantee, not an assumption about customer intent | +| A7 | Default-language resolution for missing-prefix URLs matches Angular's current behavior | §3.7 | Verified against Phase 0 access-log capture | + +--- + +## 11. What this spec satisfies vs. requirements + +| Req | Topic | Section(s) | +|---|---|---| +| 1.1.1 | ModernJS SSR | §1.1, §2.1, §3.1 | +| 1.1.2 | MF 2.0 + `mf-manifest.json` + any MF 2.0 bundler (Rspack) | §2.1, §2.2 | +| 1.1.3 | React 18 Concurrent Mode, `<Suspense>`, no body side effects, `React.lazy` | §3.2, §3.4, §4.4 | +| 2 | REST API, JSON | §4.1 | +| 2b | Rendered data consistent with API | §4.3, §4.4 | +| 3 | 100 RPS | §8.2 | +| 4 | Geo-distributed VMs, fault tolerance | §8.3 | +| 5 | Security / isolation | §8.1 | +| 6 | 24/7/365, ≤6h MTTR | §8.3 | +| 6 | SEO | §3.6, §6.5, §6.6, §6.8 | +| 6 | JSON-LD + OpenGraph | §6.5, §6.6, §6.7 | +| 6 | Analytics (Metrica / CTM / Variocube / Dynatrace) | §7.4 | +| 7 | Cross-platform Web/PWA embedding | §2.1, §2.4 | +| 7 | Responsive / fluid layout | §5.7 | +| 8 | Logging to customer system | §7.2 | +| 8 | System-event monitoring to aggregator | §7.3 | +| 9 | Customer module template | §2.5 (spec assumption A1) | +| 10 | Design system / mockups | §5 (pixel parity with Angular as Phase 1 target, swap-point for future DS) | +| 10 | Embed other remote components | §2.4, §5.1 | + +--- + +## 12. Open items deferred to the implementation plan + +The implementation plan (written after this spec is approved) will resolve: + +- Default-language rule for missing-prefix URLs (§3.7). +- Default `canonicalOrigin` constant for MF-embedded mode when `HostContract.canonicalOrigin` is absent (§3.7). +- Per-route Cache-Control tuning after real traffic measurements (§8.2). +- Phase-1 vs. Phase-2 split of `src/ui/flights/` components based on which feature ports them first. +- Exact consent-cookie name and schema for the standalone site (§7.4). +- Per-vendor analytics event schema (event names, property conventions) — decided with stakeholder review in Phase 0 / Phase 2. + +These are implementation details that don't affect architecture and don't need to be decided in this document.