# 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 `<Suspense>`, 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, <head>, 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://<cdn-origin>/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://<cdn-origin>/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
              → <Head> emits <title>, 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).**
`lru-cache@^10`, in-memory, ~100MB byte cap via `maxSize` + `sizeCalculation` (default sizer: `JSON.stringify(value).length`; callers may pass a pre-computed size hint from the response `content-length` to avoid O(n) sizing on write). 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). (`@isaacs/ttlcache` was considered but lacks byte-based caps — `lru-cache@^10`, also maintained by isaacs, is the correct primitive for a 100MB hard limit.)

### 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 (`lru-cache@^10` with `maxSize` byte cap, 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.
