Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cbc28074b | |||
| 8a5279745c | |||
| 98c6eca90e | |||
| 031ad7c15d | |||
| 78205c378e | |||
| 99af1fe00d | |||
| e172df8cf9 | |||
| 0a8ccfe36e | |||
| 7d202b9436 | |||
| 5839692e52 | |||
| 8dde2db9d2 | |||
| a60494366f | |||
| 0f5d7915be | |||
| a2cf781b02 | |||
| dc030aceea | |||
| aa61049229 | |||
| 6703c5a2f2 | |||
| c67686463a | |||
| 93f49cddae | |||
| a072cd3bd2 | |||
| 7ad61554cb | |||
| 1c5e85ea8e | |||
| f7813b04b1 | |||
| 969281c71a | |||
| 008bc3339c | |||
| 5bcf23ee4e | |||
| 728193e8b0 | |||
| 9bd3697a17 | |||
| 1e9523088b | |||
| e20686b11f | |||
| 44ae7f1642 | |||
| c68d53dfa6 | |||
| 6788f609e6 | |||
| cd07542dc5 | |||
| 80b9090ef9 | |||
| 98971cab26 | |||
| c6c0ce2bfc | |||
| 3bed4f9173 | |||
| 8218dffcd9 | |||
| 9e08057704 | |||
| 73d724f76a | |||
| 7d8cb63600 | |||
| b3ab73253d | |||
| fc03c08278 | |||
| bd9cc92766 | |||
| 7db39cbeec | |||
| 56cc9e1af2 | |||
| ca6ae0eea2 | |||
| f0e540aa3f | |||
| 8c8b8b985e | |||
| 9b1fb7388f | |||
| 0f4180de14 | |||
| 59d5a7314e | |||
| 5d041cc4c6 | |||
| 4f93d0a9bf | |||
| 10b97339bf | |||
| 2742568a85 | |||
| 726db20f89 | |||
| ebe6c38713 | |||
| 4c52bb4032 | |||
| 7052052742 | |||
| b6a007d3b6 | |||
| b431010241 | |||
| b6da51fce5 | |||
| 6d3e3ae986 | |||
| 858b8e1d1f | |||
| fc57556010 | |||
| 2eb118cb8b | |||
| 1409df458b | |||
| bb50e63866 | |||
| 599f35f14a | |||
| 8abe8acf70 | |||
| ad9b35f725 | |||
| 6a4be07911 | |||
| 8878dcb6c8 | |||
| 515151ed81 | |||
| fe31bbfb65 | |||
| e95781a069 | |||
| 0b25a1a9e7 | |||
| 2dc1a1f194 | |||
| ddedddd15d | |||
| 8e7adef5e3 | |||
| dfa9e0283d | |||
| 498d049acd | |||
| 9a1b4bace1 | |||
| 98414ee4af | |||
| 1a40686490 | |||
| c095fad7ad | |||
| 7992d2705a | |||
| 04c5432aef | |||
| 65c8c8b55f | |||
| cb5e5b0106 | |||
| fd62d6f123 | |||
| 6ef9ce4ed7 | |||
| 454fb0bdb9 | |||
| 7103b9ffb1 | |||
| a8c648c818 | |||
| 1fd2788c35 | |||
| bf3087d45e | |||
| 33d4c94298 | |||
| 3067f8f111 | |||
| 050f311a60 | |||
| 8459e1661b | |||
| e9640b17fd | |||
| f1acf7827d | |||
| 7bbda35041 | |||
| 826758f03d | |||
| 604ed75498 | |||
| 4fd7ae3f94 | |||
| 5881f9ed72 | |||
| 2bdfde43f6 | |||
| c41089e5c8 | |||
| 4eede82961 | |||
| 2eda0a675e | |||
| e200256fdc | |||
| 5b67aa25fa | |||
| 9d5898e8d5 | |||
| 0b9ea74617 | |||
| 9c29091b58 | |||
| 23db51997b | |||
| 8a8075972d | |||
| 9acccffe8c | |||
| 9c5f834334 | |||
| f7d367a315 | |||
| 4d41b46975 | |||
| 765174b674 | |||
| 7c99ab069d | |||
| 5cce054f36 | |||
| 064436527a | |||
| 00d1c9827c | |||
| 59a94b50b9 | |||
| f309f62553 | |||
| 03bab8d7f8 | |||
| f41656ce5f | |||
| 4ba4159723 | |||
| 013fad6236 | |||
| 75dbec0737 | |||
| b4a41cc801 |
@@ -0,0 +1,74 @@
|
||||
# Deploy workflow — template for CI/CD pipeline
|
||||
# Real registry URLs and deployment targets come from customer (A2/A8)
|
||||
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "9"
|
||||
# Placeholder: replace with customer registry
|
||||
REGISTRY: "registry.example.com"
|
||||
IMAGE_STANDALONE: "flights-web-standalone"
|
||||
IMAGE_REMOTE: "flights-web-remote"
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build both targets
|
||||
run: pnpm build:both
|
||||
|
||||
- name: Build Docker images
|
||||
run: |
|
||||
docker build -f Dockerfile.react -t ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }} .
|
||||
docker build -f Dockerfile.remote -t ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }} .
|
||||
|
||||
# Placeholder: push to customer registry
|
||||
# - name: Push Docker images
|
||||
# run: |
|
||||
# docker push ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }}
|
||||
# docker push ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }}
|
||||
|
||||
# Placeholder: deploy to testing environment
|
||||
# - name: Deploy to testing
|
||||
# run: |
|
||||
# echo "Deploy standalone image to testing environment"
|
||||
# echo "Run post-deploy smoke test"
|
||||
|
||||
# Placeholder: auto-rollback on health-check failure
|
||||
# - name: Post-deploy health check
|
||||
# run: |
|
||||
# curl -f https://testing.example.com/health || echo "Health check failed — trigger rollback"
|
||||
@@ -13,6 +13,8 @@ dist/
|
||||
ClientApp/dist/
|
||||
ClientApp/coverage/
|
||||
ClientApp/.storybook-out/
|
||||
.pnpm-store/
|
||||
.pnpm-debug.log
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -28,3 +30,8 @@ appsettings.Development.json
|
||||
|
||||
# wwwroot build output (keep static assets, ignore generated JS)
|
||||
wwwroot/dist/
|
||||
|
||||
# Module Federation build artifacts
|
||||
@mf-types.zip
|
||||
@mf-types/
|
||||
.mf/
|
||||
|
||||
@@ -1,129 +1,68 @@
|
||||
# CLAUDE.md
|
||||
# Aeroflot.Flights.Web
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
## Current State
|
||||
|
||||
## Project Overview
|
||||
ASP.NET host (`Aeroflot.Flights.Web.csproj`, `Startup.cs`, `Program.cs`) serving an Angular 12 SPA located in `ClientApp/` (Angular CLI + custom webpack config, Karma, Cypress, Storybook/Compodoc).
|
||||
|
||||
This is the Aeroflot Flights Web application — a flight information/booking interface. The current codebase is **Angular 12** (located in `ClientApp/`), and it is being **rewritten to React** using ModernJS with Module Federation 2.0 as a remote micro-frontend component.
|
||||
## Target: React Remote Component Rewrite
|
||||
|
||||
## Current Angular App (ClientApp/)
|
||||
The Angular app is being rewritten as a **remote frontend component** embeddable in the customer's channel apps (Web, PWA). Requirements below are contractual — treat them as hard constraints when designing or implementing the new codebase.
|
||||
|
||||
### Dev Commands
|
||||
### 1. Tech Stack
|
||||
|
||||
```bash
|
||||
npm start # Dev server on :4200 (proxies /api, /flights → flights.test.aeroflot.ru)
|
||||
npm run build:prod # Production build
|
||||
npm run build:dev # Dev build with source maps
|
||||
npm run build:testing # Testing environment build
|
||||
npm run test # Karma/Jasmine with coverage → coverage/test/
|
||||
npm run test:ci # Tests with TeamCity reporter
|
||||
npm run lint # ESLint
|
||||
npm run pretty # Prettier (ts + html)
|
||||
npm run analyze # Webpack bundle analyzer
|
||||
npm run storybook # Storybook component docs
|
||||
```
|
||||
- **ModernJS (SSR)** for the frontend framework.
|
||||
- **Module Federation 2.0**. Any bundler with MF 2.0 support is acceptable: Webpack 5, Rsbuild, Rspack, or Vite.
|
||||
- Must emit `mf-manifest.json` at `https://<domain>/mf-manifest.json` exposing components and logic. Reference: https://module-federation.io/guide/basic/webpack.html.
|
||||
- **React 18+**, Concurrent Mode compatible.
|
||||
- `<Suspense>` support required when async loading is used.
|
||||
- Component bodies must be side-effect free — **no `fetch` outside `useEffect`**.
|
||||
- Dynamic imports must use `React.lazy()`.
|
||||
|
||||
### Path Aliases (tsconfig.json)
|
||||
### 2. Data & Integrations
|
||||
|
||||
| Alias | Resolves To |
|
||||
|---|---|
|
||||
| `@app/*` | `src/app/*` |
|
||||
| `@components/*` | `src/app/components/*` |
|
||||
| `@shared/*` | `src/app/shared/*` |
|
||||
| `@modules/*` | `src/app/modules/*` |
|
||||
| `@features/*` | — (use explicit paths) |
|
||||
| `@online-board/*` | `src/app/features/online-board/*` |
|
||||
| `@schedule/*` | `src/app/features/schedule/*` |
|
||||
| `@toolkit/*` | `src/app/toolkit/*` |
|
||||
| `@utils/*` | `src/app/utils/*` |
|
||||
| `@typings/*` | `src/typings/*` |
|
||||
| `@environment` | `src/environments/environment` |
|
||||
- Consumes customer REST APIs, JSON payloads only.
|
||||
- Rendered data must stay consistent with API responses (no stale state leaking into the UI).
|
||||
|
||||
### Architecture
|
||||
### 3. Performance
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── features/ # Lazy-loaded feature modules
|
||||
│ ├── online-board/ # Main flight departure/arrival board
|
||||
│ ├── schedule/ # Schedule search
|
||||
│ ├── flights-map/ # Map view (feature-flag gated)
|
||||
│ └── popular-requests/
|
||||
├── modules/
|
||||
│ ├── components/ # Reusable display components
|
||||
│ ├── pages/ # Page-level components (board, details, schedule, errors)
|
||||
│ └── prime-components-module.ts
|
||||
├── shared/
|
||||
│ ├── services/ # ~37 services (API, localization, settings, SEO, etc.)
|
||||
│ ├── pipes/
|
||||
│ ├── pipes-legacy/
|
||||
│ ├── models-legacy/ # ~50 legacy DTOs
|
||||
│ ├── interceptor/ # AppInterceptor (HTTP)
|
||||
│ └── shared.module.ts
|
||||
├── guards/
|
||||
│ └── feature-flag.guard.ts
|
||||
└── toolkit/ # Custom UI component library
|
||||
```
|
||||
- Must sustain **100 RPS**.
|
||||
|
||||
**State management**: No NgRx/Akita — pure RxJS with BehaviorSubjects exposed as Observables from services.
|
||||
### 4. Availability & Fault Tolerance
|
||||
|
||||
**Real-time**: `@microsoft/signalr` for WebSocket connections (tracker hub URL set per environment).
|
||||
- VMs hosting the component must be geographically distributed.
|
||||
- 24/7/365 availability; recovery time ≤ 6 hours after infra is restored.
|
||||
|
||||
**Routing**: All language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`. Feature modules are lazy-loaded; `flights-map` is guarded by `FeatureFlagGuard`.
|
||||
### 5. Security
|
||||
|
||||
**i18n**: `@ngx-translate` with `messageformat` compiler. Translation JSON files in `src/assets/i18n/`. Supports 9 languages.
|
||||
- Component must be isolated — no attack surface exposed to other components of the host site.
|
||||
|
||||
**UI**: PrimeNG 10 + custom `toolkit/` components.
|
||||
### 6. SEO & Accessibility
|
||||
|
||||
### Environment Config
|
||||
- SEO optimization required.
|
||||
- Render microdata: **JSON-LD** and **OpenGraph**.
|
||||
- Web analytics integrations: **Yandex.Metrica, CTM, Variocube, Key-Astrom (Dynatrace)**.
|
||||
|
||||
Each `src/environments/environment*.ts` exposes:
|
||||
- `apiRootUrl` / `wsRootUrl` — proxied in dev, real URLs in prod
|
||||
- `features.flightsMap` — boolean feature flag
|
||||
- Refresh intervals, calendar date ranges
|
||||
- Ticket purchase time windows (prod only)
|
||||
### 7. Cross-Platform
|
||||
|
||||
## React Rewrite Requirements
|
||||
- Embeddable in multiple channel apps (Web, PWA).
|
||||
- Fully responsive ("fluid") layout across all screen sizes.
|
||||
|
||||
The new component must be a **ModernJS SSR** remote micro-frontend with:
|
||||
### 8. Logging & Monitoring
|
||||
|
||||
### Stack
|
||||
- **Framework**: ModernJS (SSR enabled)
|
||||
- **Bundler**: Webpack 5, Rsbuild, Rspack, or Vite — whichever supports Module Federation 2.0
|
||||
- **Module Federation**: Must expose `mf-manifest.json` at `https://<domain>/mf-manifest.json`
|
||||
- **React**: 18+ with Concurrent Mode, `<Suspense>` support, no side-effects outside `useEffect`, dynamic imports via `React.lazy()`
|
||||
- Frontend log collection in a customer-specified format, shipped to the customer's log aggregation system.
|
||||
- System event monitoring with export to a metrics aggregator.
|
||||
|
||||
### Functional Parity (port from Angular)
|
||||
- **Features to port**: online-board, schedule, flights-map, popular-requests
|
||||
- **Data source**: REST API (JSON) — same endpoints currently proxied under `/api`
|
||||
- **Real-time**: SignalR hub integration
|
||||
- **Maps**: Leaflet (or equivalent)
|
||||
- **i18n**: 9 languages
|
||||
- **Multi-theme**: Responsive / "rubber layout" for Web + PWA embedding
|
||||
### 9. Module Structure
|
||||
|
||||
### Non-functional Requirements
|
||||
- SEO: SSR-rendered meta tags, JSON-LD, OpenGraph markup
|
||||
- Analytics: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром
|
||||
- Logging: Structured frontend log collection → customer logging system
|
||||
- Monitoring: System events → metrics aggregator
|
||||
- Isolation: Component must not affect or be affected by host application styles/globals
|
||||
- Availability: 24/7, recovery < 6h after hardware restoration
|
||||
- Must conform to the customer's standard remote frontend module structure for uniform deployment.
|
||||
|
||||
### Code Style for React Code
|
||||
- Prettier config from `.prettierrc.json`: single quotes, trailing commas `all`, 4-space indent, semicolons
|
||||
- ESLint config from `.eslintrc.js`: max line length 80, TypeScript strict
|
||||
### 10. Design
|
||||
|
||||
## Markdown Style
|
||||
- Implement against customer-provided mockups using the customer's design system.
|
||||
- Must embed other customer remote components when available.
|
||||
|
||||
Do not wrap or break lines in markdown files. Write each paragraph or list item as a single long line.
|
||||
## Commit Rules
|
||||
|
||||
## Release & Changelog
|
||||
|
||||
This project uses [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). Version is tracked in two places: `pyproject.toml` and `audio_transcribe/__init__.py`.
|
||||
|
||||
**Per-commit rule**: When committing a `fix:`, `feat:`, or breaking change, also add a line to the `[Unreleased]` section of `CHANGELOG.md` under the appropriate heading (`### Added`, `### Fixed`, `### Changed`, `### Removed`). This keeps the changelog current while context is fresh.
|
||||
|
||||
**Releasing**: Use `/release` to bump version, stamp changelog, commit, tag, and optionally push. The skill auto-detects the bump level from commit prefixes (`fix:` → patch, `feat:` → minor, `BREAKING CHANGE` → major) and lets you override.
|
||||
|
||||
## Git Conventions
|
||||
|
||||
Do not include `Co-Authored-By` lines in commit messages.
|
||||
- Never add `Co-Authored-By` lines to commit messages.
|
||||
- Commit messages in English, concise, focused on "why" not "what".
|
||||
- Commit autonomously when changes are complete and stable — no need to ask for permission. Group related edits into logical commits. Still ask before pushing, force-pushing, or any destructive git operation.
|
||||
|
||||
@@ -93,5 +93,9 @@
|
||||
"Android > 4.3",
|
||||
"iOS > 9",
|
||||
"Edge > 13"
|
||||
]
|
||||
],
|
||||
"main": ".eslintrc.js",
|
||||
"keywords": [],
|
||||
"license": "ISC",
|
||||
"description": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Dockerfile.react — Multi-stage build for standalone SSR server
|
||||
# Coexists with the legacy ASP.NET Dockerfile
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Stage 2: Build standalone target
|
||||
FROM deps AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY modern.config.ts module-federation.config.ts tsconfig.json ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN pnpm build:standalone
|
||||
|
||||
# Stage 3: Minimal production image
|
||||
FROM node:24-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=build /app/dist/standalone/ ./dist/standalone/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["node", "dist/standalone/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Dockerfile.remote — nginx-based static file server for remote MF artifact
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Stage 2: Build remote target
|
||||
FROM deps AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY modern.config.ts module-federation.config.ts tsconfig.json ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN pnpm build:remote
|
||||
|
||||
# Stage 3: Serve static files with nginx
|
||||
FROM nginx:alpine AS runtime
|
||||
|
||||
COPY --from=build /app/dist/remote/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Frozen public barrels
|
||||
|
||||
**Rule.** Cross-module imports inside `src/` go through exactly these five public entries:
|
||||
|
||||
- `@/features/online-board`
|
||||
- `@/features/schedule`
|
||||
- `@/features/flights-map`
|
||||
- `@/features/popular-requests`
|
||||
- `@/ui`
|
||||
|
||||
No file outside `src/features/<feature>/` may import from `src/features/<feature>/components/...` or any deeper path. No file outside `src/ui/` may import `src/ui/primitives/Button`. Enforcement lands in sub-plan **1A-3** via `eslint-plugin-boundaries` + `no-restricted-imports`.
|
||||
|
||||
**Why this is frozen.** Phase 0 assumption **A1** (customer's standard remote-frontend module template) may arrive after 1A-1 ships. When it does, the rename pass documented in `rename-pass-plan.md` must be a mechanical move: rename directories, update import paths at the five barrels, done. If cross-module imports fan out through deep paths, the rename becomes a surgery across dozens of files.
|
||||
|
||||
**What this unblocks.** Every Phase 1 sub-plan can add *internal* files to a feature/UI directory without coordinating with other sub-plans, because nothing outside the barrel depends on internals. The barrel file itself is the review gate.
|
||||
|
||||
**What this costs.** Small friction when a sub-plan wants to expose a new symbol — it must update the barrel. Acceptable cost for the refactor safety it buys.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Modern.js + MF 2.0 spike report
|
||||
|
||||
**Date:** 2026-04-14
|
||||
**Goal:** Validate that Modern.js 2.x + `@module-federation/modern-js` can produce both standalone SSR and CDN-static remote artifacts from a single source tree, and pin versions for 1A-2.
|
||||
|
||||
## Pinned version matrix
|
||||
|
||||
| Package | Version | Source |
|
||||
|---|---|---|
|
||||
| `@modern-js/app-tools` | `2.70.8` | pnpm-lock.yaml of scratch project |
|
||||
| `@modern-js/runtime` | `2.70.8` | pnpm-lock.yaml of scratch project |
|
||||
| `@modern-js/plugin-ssr` | N/A (SSR is built into `@modern-js/app-tools` in v2.x) | verified: no separate package exists in the dep tree |
|
||||
| `@module-federation/modern-js` | `2.3.2` | pnpm-lock.yaml of scratch project |
|
||||
| `@module-federation/enhanced` | `2.3.2` | pnpm-lock.yaml of scratch project |
|
||||
| `react` | `18.3.1` | resolved from `^18.2.0` |
|
||||
| `react-dom` | `18.3.1` | resolved from `^18.2.0` |
|
||||
| `@types/react` | `18.3.28` | resolved |
|
||||
| `@types/react-dom` | `18.3.7` | resolved |
|
||||
| `typescript` | `5.5.4` | resolved from `~5.5.0` |
|
||||
| `@rspack/core` (via Modern.js) | `1.7.11` | resolved transitively |
|
||||
| `@rsbuild/core` (via Modern.js) | `1.7.3` | resolved transitively |
|
||||
|
||||
## Dual-build feasibility
|
||||
|
||||
- [x] Single `modern.config.ts` with `BUILD_TARGET` branching produces both targets -- **YES**
|
||||
- [x] `dist/standalone/` contains Node SSR server -- **YES** (contains `bundles/` directory with `main.js`, `main-server-loaders.js`, and SSR-rendered chunk bundles)
|
||||
- [x] `dist/remote/mf-manifest.json` contains all exposed modules -- **YES** (contains `./Hello` expose with asset paths)
|
||||
|
||||
All three criteria pass. The `BUILD_TARGET` env var read at config time cleanly switches between SSR and CSR-only modes.
|
||||
|
||||
### How it works
|
||||
|
||||
`modern.config.ts` reads `process.env.BUILD_TARGET` and branches:
|
||||
|
||||
```typescript
|
||||
const isRemote = process.env.BUILD_TARGET === 'remote';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [appTools({ bundler: 'rspack' }), moduleFederationPlugin()],
|
||||
server: isRemote ? {} : { ssr: { mode: 'stream' } },
|
||||
output: {
|
||||
distPath: { root: isRemote ? 'dist/remote' : 'dist/standalone' },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- `BUILD_TARGET=standalone` (or unset): SSR enabled via `server.ssr.mode: 'stream'`, outputs to `dist/standalone/`. Produces both client-side JS + Node SSR bundles in `bundles/`.
|
||||
- `BUILD_TARGET=remote`: SSR disabled (empty `server` config), outputs to `dist/remote/`. Produces only client-side JS + MF artifacts. No `bundles/` directory.
|
||||
|
||||
Both targets get the MF plugin, so `mf-manifest.json` + `remoteEntry.js` + `@mf-types.zip` are emitted in both builds.
|
||||
|
||||
## Emitted mf-manifest.json shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "spike",
|
||||
"name": "spike",
|
||||
"metaData": {
|
||||
"name": "spike",
|
||||
"type": "app",
|
||||
"buildInfo": {
|
||||
"buildVersion": "1.0.0",
|
||||
"buildName": "modernjs-mf-spike"
|
||||
},
|
||||
"remoteEntry": {
|
||||
"name": "remoteEntry.js",
|
||||
"path": "",
|
||||
"type": "global"
|
||||
},
|
||||
"types": {
|
||||
"path": "",
|
||||
"name": "",
|
||||
"zip": "@mf-types.zip",
|
||||
"api": "@mf-types.d.ts"
|
||||
},
|
||||
"globalName": "spike",
|
||||
"pluginVersion": "2.3.2",
|
||||
"prefetchInterface": false,
|
||||
"publicPath": "/"
|
||||
},
|
||||
"shared": [
|
||||
{
|
||||
"id": "spike:react-dom",
|
||||
"name": "react-dom",
|
||||
"version": "18.3.1",
|
||||
"singleton": true,
|
||||
"requiredVersion": "^18.2.0",
|
||||
"assets": {
|
||||
"js": {
|
||||
"async": [],
|
||||
"sync": ["static/js/async/396.e11e8a41.js"]
|
||||
},
|
||||
"css": { "async": [], "sync": [] }
|
||||
},
|
||||
"fallback": ""
|
||||
},
|
||||
{
|
||||
"id": "spike:react",
|
||||
"name": "react",
|
||||
"version": "18.3.1",
|
||||
"singleton": true,
|
||||
"requiredVersion": "^18.2.0",
|
||||
"assets": {
|
||||
"js": {
|
||||
"async": [],
|
||||
"sync": ["static/js/async/75.30dce1d1.js"]
|
||||
},
|
||||
"css": { "async": [], "sync": [] }
|
||||
},
|
||||
"fallback": ""
|
||||
}
|
||||
],
|
||||
"remotes": [],
|
||||
"exposes": [
|
||||
{
|
||||
"id": "spike:Hello",
|
||||
"name": "Hello",
|
||||
"assets": {
|
||||
"js": {
|
||||
"sync": ["static/js/async/__federation_expose_Hello.24756ba8.js"],
|
||||
"async": []
|
||||
},
|
||||
"css": { "sync": [], "async": [] }
|
||||
},
|
||||
"path": "./Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key observations about the manifest shape:
|
||||
- Top-level `id` and `name` match the `name` field in `module-federation.config.ts`.
|
||||
- `metaData.remoteEntry.name` is the `filename` field from config.
|
||||
- `metaData.pluginVersion` reflects `@module-federation/modern-js` version.
|
||||
- `metaData.publicPath` defaults to `"/"` -- must be overridden for CDN deployment.
|
||||
- `exposes[].path` uses the `./Hello` convention from config.
|
||||
- `shared[]` entries include resolved `version` and `requiredVersion`.
|
||||
- `@mf-types.zip` is auto-generated for TypeScript type sharing between remotes.
|
||||
|
||||
## Gotchas discovered
|
||||
|
||||
1. **`@module-federation/modern-js` is the correct package name, not `-v3`.** Both `@module-federation/modern-js` and `@module-federation/modern-js-v3` exist on npm at the same version (2.3.2), but the non-suffixed name is the primary package with the correct exports. The `-v3` variant appears to be an alias. Use `@module-federation/modern-js`.
|
||||
|
||||
2. **Modern.js 3.x (v3.1.3) is incompatible with `@module-federation/modern-js` v2.3.2.** Modern.js 3.x uses Rspack 2.0.0-rc.0, which has breaking API changes (`api.modifyWebpackConfig` removed, Rspack hook shape changes). The MF plugin crashes with `TypeError: api.modifyWebpackConfig is not a function` (SSR plugin) and `TypeError: Cannot read properties of undefined (reading 'tap')` (EmbedFederationRuntimePlugin). Use Modern.js 2.x (v2.70.8) which ships Rspack 1.7.11.
|
||||
|
||||
3. **`routes/layout.tsx` is mandatory.** Modern.js v2 requires a root layout component at `src/routes/layout.tsx`. Without it, the build fails with `Error: The root layout component is required`. The generated scaffold from `@modern-js/create` includes this automatically, but manual project setup must add it.
|
||||
|
||||
4. **SSR stream mode logs a misleading warning.** When SSR is enabled with `mode: 'stream'`, the build logs `splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"`. This is a no-op warning (it changes `async` to `async`, which is the same value) -- safe to ignore.
|
||||
|
||||
5. **`@modern-js/plugin-ssr` does not exist as a separate package.** In Modern.js 2.x, SSR support is built into `@modern-js/app-tools`. The design spec references `@modern-js/plugin-ssr` but this is not needed -- just set `server.ssr.mode` in `modern.config.ts`.
|
||||
|
||||
6. **`appTools({ bundler: 'rspack' })` is required.** Without the explicit `bundler: 'rspack'` option, Modern.js 2.x defaults to webpack, which would conflict with the MF plugin's Rspack-specific code paths.
|
||||
|
||||
7. **`publicPath` in the manifest defaults to `"/"`**. For CDN deployment, `output.assetPrefix` must be set in `modern.config.ts` to the CDN URL (e.g., `https://cdn.example.com/flights/`). This will propagate into `metaData.publicPath` in `mf-manifest.json`.
|
||||
|
||||
8. **Type generation (`@mf-types.zip`) happens automatically.** The MF plugin generates a type bundle at `dist/@mf-types.zip` containing TypeScript declarations for all exposed modules. This enables type-safe consumption in host apps without manual type sharing.
|
||||
|
||||
## Known incompatibilities
|
||||
|
||||
- `@module-federation/modern-js` v2.3.2 has a peer dependency on `react@"^16.3.0 || ^17.0.0 || ^18.0.0"`, which means React 19 is not officially supported. React 18.3.1 resolves cleanly. If the project later needs React 19, the MF plugin version must be updated or the peer dep overridden.
|
||||
- Modern.js 3.x (v3.1.3, released with `@modern-js/create@3.1.3`) is not compatible with `@module-federation/modern-js` v2.3.2. The MF plugin team will need to release a v3-compatible version. For now, pin to Modern.js 2.70.8.
|
||||
|
||||
## Decision
|
||||
|
||||
**GO.** The dual-build approach works cleanly with Modern.js 2.70.8 + `@module-federation/modern-js` 2.3.2. A single `modern.config.ts` with `BUILD_TARGET` env var branching produces:
|
||||
- `dist/standalone/`: Node SSR server + client bundle (stream mode)
|
||||
- `dist/remote/`: CDN-static MF 2.0 remote with `mf-manifest.json`, `remoteEntry.js`, typed exports
|
||||
|
||||
No workarounds or hacks required. The version matrix is stable and the manifest shape is well-defined. Proceed with Tasks 2-10 of Phase 1A-2 using these pinned versions.
|
||||
@@ -0,0 +1,23 @@
|
||||
# A1 rename-pass rework plan
|
||||
|
||||
**Trigger.** Phase 0 assumption **A1** — "customer's standard remote-frontend module template" — resolves to a directory layout that differs from the one this repo uses.
|
||||
|
||||
**Scope.** Move/rename directories inside `src/` to match the customer template. Update import paths *only at the five frozen public barrels* (see `frozen-barrels.md`). Do not restructure feature internals.
|
||||
|
||||
**Preconditions.**
|
||||
- The frozen-barrel rule has been enforced since 1A-1 (Task 11) and 1A-3 ESLint rules are passing on `main`.
|
||||
- Customer template document is in hand and reviewed for explicit directory conventions.
|
||||
|
||||
**Steps (to be fleshed out when A1 resolves).**
|
||||
1. Create a target-layout scratch file mapping current path → new path for every file under `src/`.
|
||||
2. Run the rename as a single automated pass (`git mv`) inside an isolated worktree.
|
||||
3. Update `tsconfig.json` `paths` aliases if the top-level segments change.
|
||||
4. Update `vitest.config.ts` aliases to match.
|
||||
5. Update the five barrel files — this is the *only* hand-edit needed for consumer code.
|
||||
6. Run `pnpm typecheck && pnpm lint && pnpm test` — green before commit.
|
||||
7. Run all Phase 1 exit-gate checks (from master plan) — green before PR.
|
||||
8. Single commit: `Rename src/ layout to match customer module template (A1)`.
|
||||
|
||||
**Escape valve.** If the rename touches more than the five barrels, something violated the frozen-barrel rule between 1A-1 and now. Fix the violation first (move the cross-boundary import through a barrel), then retry the rename.
|
||||
|
||||
**Owner.** This task is attached to 1A-1's exit gate and fires on A1 resolution, whether that happens during Phase 1 or early Phase 2.
|
||||
@@ -0,0 +1,340 @@
|
||||
# Flights Web — Operational Runbook
|
||||
|
||||
**Version:** 1.0 (Phase 1I)
|
||||
**Last updated:** 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## 1. Incident Response Decision Tree
|
||||
|
||||
```
|
||||
Is the service returning errors?
|
||||
|
|
||||
+-- YES: Check /health endpoint
|
||||
| |
|
||||
| +-- /health returns 503
|
||||
| | -> Upstream API issue (see Section 6.1)
|
||||
| |
|
||||
| +-- /health returns 200 but users see errors
|
||||
| | -> Application-level bug. Check logs (Section 5).
|
||||
| | -> If recent deploy: rollback (Section 3).
|
||||
| |
|
||||
| +-- /health unreachable (connection refused / timeout)
|
||||
| -> Container/VM is down.
|
||||
| -> Check container orchestrator status.
|
||||
| -> If all replicas down: escalate to infra team (Severity 1).
|
||||
| -> If partial: rely on load balancer, investigate affected nodes.
|
||||
|
|
||||
+-- NO: Check for degraded performance
|
||||
|
|
||||
+-- Latency > 2x baseline
|
||||
| -> Check OTel metrics for slow spans.
|
||||
| -> Check upstream API latency.
|
||||
| -> If upstream: see Section 6.1.
|
||||
| -> If internal: check for memory pressure, CPU saturation.
|
||||
|
|
||||
+-- Intermittent errors in logs
|
||||
-> Check error rate trend.
|
||||
-> If rising: prepare for rollback.
|
||||
-> If stable/low: monitor for 15 min, then investigate.
|
||||
```
|
||||
|
||||
### Severity Levels
|
||||
|
||||
| Severity | Criteria | Response Time | Who to Page |
|
||||
|----------|----------|---------------|-------------|
|
||||
| S1 | Service fully down, all users affected | Immediate | On-call engineer + team lead |
|
||||
| S2 | Partial outage, >10% error rate | 15 min | On-call engineer |
|
||||
| S3 | Degraded performance, no data loss | 1 hour | On-call engineer (next business day if after hours) |
|
||||
| S4 | Minor issue, workaround exists | Next business day | Assigned engineer |
|
||||
|
||||
---
|
||||
|
||||
## 2. Canary Rollout Procedure
|
||||
|
||||
### Pre-rollout Checklist
|
||||
|
||||
- [ ] All CI checks pass (typecheck, lint, test)
|
||||
- [ ] Docker images built and pushed to registry
|
||||
- [ ] Rollback image tag identified (current production tag)
|
||||
- [ ] Monitoring dashboards open
|
||||
|
||||
### Rollout Steps
|
||||
|
||||
1. **Deploy canary** (5% traffic) to a single node in one geographic region
|
||||
2. **Monitor for 10 minutes:**
|
||||
- Error rate must stay below 0.5%
|
||||
- p99 latency must not exceed 2x baseline
|
||||
- `/health` must return 200 on the canary
|
||||
- No new error patterns in logs
|
||||
3. **Expand to 25%** if canary is healthy
|
||||
4. **Monitor for 15 minutes** with same criteria
|
||||
5. **Expand to 100%** across all geographic regions
|
||||
6. **Post-deploy verification:**
|
||||
- `/health` returns 200 on all nodes
|
||||
- Smoke test passes end-to-end
|
||||
- No error rate spike in the first 30 minutes
|
||||
|
||||
### Abort Criteria
|
||||
|
||||
Roll back immediately if any of these occur during canary:
|
||||
- Error rate exceeds 1%
|
||||
- `/health` returns 503 on canary nodes
|
||||
- p99 latency exceeds 5x baseline
|
||||
- Any S1/S2 incident triggered
|
||||
|
||||
---
|
||||
|
||||
## 3. Rollback Procedure
|
||||
|
||||
### 3.1 Automatic Rollback
|
||||
|
||||
The deploy pipeline monitors `/health` after deployment. If the health check fails within the first 5 minutes post-deploy:
|
||||
|
||||
1. Pipeline automatically reverts to the previous image tag
|
||||
2. Alert fires to the on-call channel
|
||||
3. Engineer investigates the failed deployment logs
|
||||
|
||||
**No manual action required** for auto-rollback. Verify the rollback succeeded by checking:
|
||||
- `/health` returns 200
|
||||
- Error rate returns to baseline
|
||||
- Previous image tag is running on all nodes
|
||||
|
||||
### 3.2 Manual Rollback
|
||||
|
||||
If auto-rollback did not trigger or a problem is discovered later:
|
||||
|
||||
1. **Identify the last-known-good image tag** from the deployment history
|
||||
2. **Redeploy the previous image:**
|
||||
```bash
|
||||
# Placeholder — actual commands depend on customer's deployment tool
|
||||
# Example:
|
||||
# deploy --image $REGISTRY/flights-web-standalone:$PREVIOUS_SHA --env production
|
||||
```
|
||||
3. **Verify rollback:**
|
||||
- `/health` returns 200 on all nodes
|
||||
- Error rate returns to baseline
|
||||
- Smoke test passes
|
||||
4. **Post-mortem:** file an incident report within 24 hours
|
||||
|
||||
---
|
||||
|
||||
## 4. Health-Check Interpretation
|
||||
|
||||
### Endpoint: `GET /health`
|
||||
|
||||
| Response | Status | Meaning | Action |
|
||||
|----------|--------|---------|--------|
|
||||
| `{ "status": "ok" }` | 200 | Upstream API reachable within last 60s | None |
|
||||
| `{ "status": "degraded", "reason": "upstream_unreachable" }` | 503 | No successful upstream ping in 60s | Check upstream API status; see Section 6.1 |
|
||||
|
||||
### Common Causes of 503
|
||||
|
||||
1. **Upstream API is down** — check upstream service status page / monitoring
|
||||
2. **Network partition** — the node cannot reach the upstream API; check network policies
|
||||
3. **DNS resolution failure** — verify DNS configuration on the node
|
||||
4. **Upstream API overloaded** — ping times out; coordinate with upstream team
|
||||
|
||||
### Load Balancer Behavior
|
||||
|
||||
When `/health` returns 503, the load balancer should stop routing traffic to that node. When the upstream recovers and `/health` returns 200 again, traffic automatically resumes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Log Query Cookbook
|
||||
|
||||
Logs are shipped in JSON Lines format to the customer's log aggregation system.
|
||||
|
||||
### Log Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-04-14T12:00:00.000Z",
|
||||
"level": "error",
|
||||
"msg": "Request failed",
|
||||
"fields": {
|
||||
"traceId": "abc123",
|
||||
"path": "/api/flights",
|
||||
"status": 500,
|
||||
"err": "TypeError: Cannot read properties of undefined"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Queries
|
||||
|
||||
**Find all errors in the last hour:**
|
||||
```
|
||||
level:error AND ts:[now-1h TO now]
|
||||
```
|
||||
|
||||
**Find errors for a specific trace:**
|
||||
```
|
||||
fields.traceId:"abc123"
|
||||
```
|
||||
|
||||
**Find slow requests (logged by the API client on timeout):**
|
||||
```
|
||||
msg:"Retrying request" OR msg:"upstream_timeout"
|
||||
```
|
||||
|
||||
**Find health-check failures:**
|
||||
```
|
||||
msg:"upstream_unreachable" OR (path:"/health" AND status:503)
|
||||
```
|
||||
|
||||
**Find graceful shutdown events:**
|
||||
```
|
||||
msg:"SIGTERM received" OR msg:"Server closed" OR msg:"Drain timeout exceeded"
|
||||
```
|
||||
|
||||
**Find CSP violations (if CSP reporting is enabled):**
|
||||
```
|
||||
msg:"csp-violation" OR fields.type:"csp-report"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Known-Failure Playbooks
|
||||
|
||||
### 6.1 Upstream API Down
|
||||
|
||||
**Symptoms:** `/health` returns 503; API client logs show retry exhaustion.
|
||||
|
||||
**Impact:** Users see error pages or stale data (if caching is in place).
|
||||
|
||||
**Steps:**
|
||||
1. Confirm upstream status via the upstream team's status page or monitoring
|
||||
2. If upstream is aware and working on it: monitor, no action needed on our side
|
||||
3. If upstream is unaware: escalate via agreed communication channel
|
||||
4. If outage exceeds 30 minutes: consider enabling a maintenance page
|
||||
5. Recovery is automatic — once upstream responds, `/health` returns 200 within 60s
|
||||
|
||||
### 6.2 SignalR Hub Offline
|
||||
|
||||
**Symptoms:** Real-time flight updates stop; SignalR reconnection logs appear.
|
||||
|
||||
**Impact:** Users see stale board data; manual refresh still works via REST API.
|
||||
|
||||
**Steps:**
|
||||
1. Check SignalR hub process/container status
|
||||
2. Verify WebSocket connectivity from the node to the SignalR hub
|
||||
3. The client auto-reconnects with exponential backoff — recovery is usually automatic
|
||||
4. If hub is permanently down: REST polling fallback should activate (if implemented)
|
||||
5. Inform users if downtime exceeds 5 minutes
|
||||
|
||||
### 6.3 CSP Violation Spike
|
||||
|
||||
**Symptoms:** Spike in CSP violation reports; possibly broken page functionality.
|
||||
|
||||
**Impact:** Scripts or styles blocked by Content-Security-Policy; UI may be partially broken.
|
||||
|
||||
**Steps:**
|
||||
1. Check CSP violation reports for the blocked resource URL
|
||||
2. If a legitimate resource is blocked: update CSP policy in `src/server/middleware/csp.ts`
|
||||
3. If a third-party script is the source: investigate whether it was injected (security concern)
|
||||
4. If after a deploy: the new code may reference resources not in the CSP allowlist — fix or rollback
|
||||
5. CSP is in report-only mode during Phase 1 — no user impact, but violations should be tracked
|
||||
|
||||
### 6.4 Analytics Adapter Load Failure
|
||||
|
||||
**Symptoms:** `flights.analytics.load_failed` counter increases; analytics data gaps.
|
||||
|
||||
**Impact:** Analytics data not collected; no user-facing impact.
|
||||
|
||||
**Steps:**
|
||||
1. Check which adapter(s) failed (Yandex.Metrica, CTM, Variocube, Dynatrace)
|
||||
2. Verify the adapter's external script URL is reachable from the client
|
||||
3. Check for CORS or CSP blocking the adapter script
|
||||
4. If a single adapter: low priority, monitor
|
||||
5. If all adapters: likely a CSP or network issue affecting all external scripts
|
||||
|
||||
### 6.5 OTel Exporter Unreachable
|
||||
|
||||
**Symptoms:** Metrics and traces stop appearing in the monitoring dashboard.
|
||||
|
||||
**Impact:** No observability data; no user-facing impact.
|
||||
|
||||
**Steps:**
|
||||
1. Check the OTel collector/exporter endpoint connectivity
|
||||
2. Verify the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable is correct
|
||||
3. Check for network policy changes that may block the exporter
|
||||
4. The SDK buffers data locally — some data may be recoverable once the exporter is reachable again
|
||||
5. If the exporter is permanently moved: update the endpoint configuration and redeploy
|
||||
|
||||
### 6.6 Memory Pressure / OOM Kill
|
||||
|
||||
**Symptoms:** Container restarts; OOM kill events in container orchestrator logs.
|
||||
|
||||
**Impact:** Requests in flight are dropped; load balancer reroutes to healthy nodes.
|
||||
|
||||
**Steps:**
|
||||
1. Check container memory limits vs actual usage
|
||||
2. Review recent deploys for memory leaks (new dependencies, unbounded caches)
|
||||
3. If a specific route causes high memory: check for large API responses or unbounded data structures
|
||||
4. Short-term: increase memory limits
|
||||
5. Long-term: profile the application to find the leak; fix and redeploy
|
||||
|
||||
---
|
||||
|
||||
## Recovery SLA
|
||||
|
||||
**Target:** Service recovery within 6 hours after infrastructure is restored.
|
||||
|
||||
**Recovery steps:**
|
||||
1. Infrastructure team restores VMs / containers across geographic regions
|
||||
2. Deployment tool re-deploys the last-known-good image
|
||||
3. `/health` checks confirm upstream connectivity
|
||||
4. Load balancer re-enables traffic to recovered nodes
|
||||
5. On-call engineer verifies end-to-end functionality
|
||||
6. Incident report filed within 24 hours of resolution
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 6 Cutover Reference
|
||||
|
||||
This section cross-references the full cutover runbook at `docs/superpowers/plans/2026-04-15-phase-6-cutover.md`.
|
||||
|
||||
### 7.1 Traffic Ramp Quick Reference
|
||||
|
||||
During cutover, traffic shifts from Angular to React over 72 hours:
|
||||
- **T+0h:** 5% React / 95% Angular
|
||||
- **T+12h:** 25% React / 75% Angular
|
||||
- **T+24h:** 50% React / 50% Angular
|
||||
- **T+48h:** 100% React / 0% Angular
|
||||
|
||||
Each step requires explicit go/no-go from the on-call engineer. Full details in the cutover runbook Section 3.
|
||||
|
||||
### 7.2 Cutover Rollback
|
||||
|
||||
During or after the traffic ramp, if a rollback is needed:
|
||||
|
||||
1. Flip proxy weights back to Angular (< 1 minute)
|
||||
2. Verify Angular is serving traffic via response headers
|
||||
3. Confirm error rate and latency return to baseline
|
||||
4. File post-mortem within 24 hours
|
||||
|
||||
**Trigger criteria:** error rate > 1% for 5+ minutes, p95 > 2x baseline for 10+ minutes, or > 50% of React nodes returning 503.
|
||||
|
||||
Full rollback procedure in the cutover runbook Section 5.
|
||||
|
||||
### 7.3 Post-Cutover Soak
|
||||
|
||||
After reaching 100% React traffic, a 7-day soak period is required before Angular decommission. Soak pass criteria:
|
||||
- Zero Angular hits in access logs
|
||||
- Error rate < 0.1%
|
||||
- p95 < 500ms
|
||||
- Core Web Vitals in "Good" threshold
|
||||
- No Search Console regressions
|
||||
|
||||
Full soak criteria in the cutover runbook Section 6.
|
||||
|
||||
### 7.4 Angular Decommission
|
||||
|
||||
After soak sign-off:
|
||||
1. Tag the Angular codebase: `git tag -a angular-final`
|
||||
2. Create archive branch: `archive/angular-spa`
|
||||
3. Remove Angular/ASP.NET files (requires customer approval)
|
||||
4. Infrastructure cleanup
|
||||
|
||||
Full decommission steps in the cutover runbook Section 7.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,975 @@
|
||||
# Phase 1 — Foundation MASTER Plan
|
||||
|
||||
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 1 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
|
||||
>
|
||||
> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt.
|
||||
|
||||
**Goal of Phase 1:** Build the complete Modern.js + Module Federation 2.0 foundation on which all four feature migrations (Phases 2–5) will be implemented. Nothing in Phase 1 ships to production users — the output is a working dual-build artifact deployed to the `testing` environment with all observability, security, and CI pipelines live.
|
||||
|
||||
**Phase 1 exit gate** (must pass before Phase 2 starts):
|
||||
|
||||
- Both build targets (standalone SSR + MF 2.0 remote) produce valid artifacts in CI.
|
||||
- `mf-manifest.json` is served from the `testing` environment and consumable by a test host.
|
||||
- The smoke route (`/ru/smoke`) renders via SSR in `testing` with all observability pipelines (logger, metrics, analytics) emitting correctly, verified by inspecting the log / metrics / analytics capture endpoints.
|
||||
- All Phase 1 CI gates pass on `main`: lint, typecheck, unit (70%+ coverage on `src/features/` + `src/shared/` + `src/ui/` + `src/observability/`), bundle size, security scan.
|
||||
- Security hardening live: CSP with per-request nonce (including the stream-transform nonce injection workaround for React issue #24883), HTTP security headers, dependency scanning green.
|
||||
- Canary deploy pipeline functional (smoke route deployed via canary path with auto-rollback on health-check failure).
|
||||
- Operational runbook published in `docs/superpowers/phase-1/runbook.md`.
|
||||
- Responsive baseline assertions passing on root layout + error pages at 320 / 768 / 1280 / 1920 widths.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md`. Phase 1 implements sections §1–§8 of the spec (everything except the feature ports in §9.2 Phase 2+).
|
||||
|
||||
**Phase 0 prerequisites — split gate.** Phase 0 produces a customer-confirmation checklist answering assumptions A1–A9. Phase 1 splits the gate:
|
||||
|
||||
- **Hard blockers** (1A-1 does not start until all resolved): **A2** (CDN vendor), **A3** (CI provider), **A5** (ASP.NET host fate), **A6** (metrics endpoint), **A8** (prod URL / access logs), **A9** (Node 24 available on customer deploy VMs — new).
|
||||
- **Stub-allowed** (Phase 1 ships stubs, swap task pending customer response): **A1** (module template), **A4** (log format), **A7** (analytics vendor credentials).
|
||||
|
||||
---
|
||||
|
||||
## Sub-plan inventory
|
||||
|
||||
| ID | Sub-plan | Spec section | Estimated size | File |
|
||||
|---|---|---|---|---|
|
||||
| **1A-1** | Project skeleton (src tree, tsconfig, eslint base, env, package.json, zod) | §1.3, §2.1 | Medium | `2026-04-14-phase-1a1-skeleton.md` (TBW) |
|
||||
| **1A-2** | MF 2.0 + dual build targets + RemoteLoader + MF spike | §2.1, §2.2, §2.3, §2.4, §2.5 | Medium | `2026-04-14-phase-1a2-mf-builds.md` (TBW) |
|
||||
| **1A-3** | ESLint boundaries + layered dependency rules | §1.2 | Small | `2026-04-14-phase-1a3-eslint-boundaries.md` (TBW) |
|
||||
| **1B** | CI pipeline | §8.5 | Medium | `2026-04-14-phase-1b-ci.md` (TBW) |
|
||||
| **1C** | i18n runtime + locale port | §6.1–§6.4 | Medium | `2026-04-14-phase-1c-i18n.md` (TBW) |
|
||||
| **1D** | API client + caches + circuit breaker | §4.1, §4.2 | Medium | `2026-04-14-phase-1d-api-client.md` (TBW) |
|
||||
| **1E** | SignalR wrapper + `useLiveFlights` hook | §4.4 | Medium | `2026-04-14-phase-1e-signalr.md` (TBW) |
|
||||
| **1F-layout** | Root layout + locale layout + error routes + smoke route + ErrorBoundary + error→HTTP mapper | §1.3, §3.1, §3.3 | Medium | `2026-04-14-phase-1f-layout.md` (TBW) |
|
||||
| **1F-seo** | SeoHead + hreflang builder + JsonLdRenderer | §3.6, §6.5, §6.6, §6.7, §6.8 | Small | `2026-04-14-phase-1f-seo.md` (TBW) |
|
||||
| **1G-logger** | Logger types + JSON-lines transport + console transport + provider | §7.1, §7.2 | Medium | `2026-04-14-phase-1g-logger.md` (TBW) |
|
||||
| **1G-metrics** | OpenTelemetry init (server/browser) + custom metric instruments | §7.3, §7.6, §7.7 | Medium | `2026-04-14-phase-1g-metrics.md` (TBW) |
|
||||
| **1G-analytics** | Analytics facade + four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace) | §7.4 | Small | `2026-04-14-phase-1g-analytics.md` (TBW) |
|
||||
| **1H** | Security hardening (CSP + nonce stream transform + headers + storage) | §8.1 | Small | `2026-04-14-phase-1h-security.md` (TBW) |
|
||||
| **1I** | Deploy pipeline + health + graceful shutdown + runbook | §8.3, §8.5 | Medium | `2026-04-14-phase-1i-deploy.md` (TBW) |
|
||||
|
||||
Sizes: **Small** ≈ 5–10 tasks, **Medium** ≈ 10–20 tasks. (No "Large" sub-plans after the 1A/1F/1G splits.)
|
||||
|
||||
Parity harnesses (URL / SEO / VRT) and real parity tests are **deferred to Phase 2**, to be designed against the first real feature migration rather than against a synthetic smoke route.
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 1A-1 Skeleton │◄── every sub-plan depends on 1A-1
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 1A-2 MF 2.0 │
|
||||
│ + builds + MF │
|
||||
│ spike first │
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 1A-3 ESLint │
|
||||
│ boundaries │
|
||||
└────────┬────────┘
|
||||
┌──────────────────────────┼──────────────────┬──────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────┐ ┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐
|
||||
│ 1B │ │ 1C i18n │ │ 1D API │ │ 1E Sig- │ │ 1G-logger │ │ 1F-seo │
|
||||
│ CI │ │ │ │ client │ │ nalR │ │ (type-only │ │ (pure │
|
||||
│ │ │ │ │ │ │ │ │ file first) │ │ funcs) │
|
||||
└──┬───┘ └────┬───┘ └─────┬────┘ └──────────┘ └──────┬───────┘ └────┬────┘
|
||||
│ │ │ │ │
|
||||
│ │ │ ┌─────────────────────┘ │
|
||||
│ │ │ ▼ │
|
||||
│ │ │ ┌──────────────┐ │
|
||||
│ │ │ │ 1G-metrics │ │
|
||||
│ │ │ │ (depends on │ │
|
||||
│ │ │ │ 1G-logger) │ │
|
||||
│ │ │ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ │ ▼ │
|
||||
│ │ │ ┌──────────────┐ │
|
||||
│ │ │ │ 1G-analytics │ │
|
||||
│ │ │ │ (depends on │ │
|
||||
│ │ │ │ 1G-logger) │ │
|
||||
│ │ │ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────┴─────────┴────────────────────────────────────┤
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 1F-layout (root layout + │◄─────────────────┘
|
||||
│ │ error routes + smoke route) │
|
||||
│ │ (consumes 1C + 1D + │
|
||||
│ │ 1G-logger/metrics/analytics │
|
||||
│ │ + 1F-seo) │
|
||||
│ └──────┬───────────────────────┘
|
||||
│ ▼
|
||||
│ ┌──────────────────────────────┐
|
||||
│ │ 1H Security hardening │
|
||||
│ │ (middleware + nonce stream │
|
||||
│ │ transform into 1F-layout) │
|
||||
│ └──────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────┐
|
||||
│ 1I Deploy pipeline + runbook │
|
||||
│ (consumes 1A-2 + 1B + 1H) │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Execution order
|
||||
|
||||
**Serial (1 engineer):** 1A-1 → 1A-2 → 1A-3 → 1C → 1F-seo → 1G-logger → 1D → 1G-metrics → 1G-analytics → 1F-layout → 1H → 1I → 1B → 1E.
|
||||
|
||||
Rationale:
|
||||
- 1A-1/2/3 unlock everything.
|
||||
- 1C is cheap and 1F-layout depends on it.
|
||||
- 1F-seo is pure functions, no runtime deps on 1C/1D/1G — slot it in early.
|
||||
- 1G-logger's type-only file (`Logger`, `LogFields`, `LogLevel`) must ship early since 1A-1's `HostContract` depends on it. (See "Logger type extraction" below.)
|
||||
- 1D depends on 1G-logger's types (for request-scoped child loggers).
|
||||
- 1G-metrics and 1G-analytics depend on 1G-logger.
|
||||
- 1F-layout consumes 1C + 1D + all three 1G sub-plans + 1F-seo — it's the integration point.
|
||||
- 1H modifies files 1F-layout creates, so it follows.
|
||||
- 1I consumes 1A-2 + 1B + 1H.
|
||||
- 1B can be slotted earlier if CI-enforced gates become blocking; the order above assumes PR-quality gates are enough until 1I.
|
||||
- 1E can come last because its only consumer is Phase 2 Online Board.
|
||||
|
||||
**Parallel (2+ engineers):** After 1A-3 ships, the following can proceed in parallel: **1B, 1C, 1D, 1E, 1F-seo, 1G-logger** (and its dependents 1G-metrics/1G-analytics). 1F-layout and later remain sequential because of shared-file constraints.
|
||||
|
||||
### Critical path
|
||||
|
||||
**1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I → exit gate** is the critical path for one engineer. 1G-logger / 1G-metrics / 1G-analytics / 1D / 1F-seo all feed 1F-layout but sit off the critical path once 1C ships (they run in parallel with 1C if there's more than one engineer, or in series but interleaved if there's only one).
|
||||
|
||||
---
|
||||
|
||||
## Logger type extraction (cross-cutting)
|
||||
|
||||
`HostContract` in 1A-1 has an optional `logger?: Logger` field (design spec §2.4). `Logger` is defined in 1G-logger. To avoid a plan-dependency cycle:
|
||||
|
||||
- **1G-logger's first task** ships `src/observability/logger/types.ts` containing **only the type definitions** (`Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). No runtime code, no transports.
|
||||
- **1A-1** imports `Logger` from `@/observability/logger/types` in its `HostContract` definition.
|
||||
- Runtime logger implementation (transports, provider, factories) lands later in 1G-logger and does not retroactively affect 1A-1.
|
||||
|
||||
This "type-only file first" pattern is the canonical workaround for plan-order cycles in this master plan; any other sub-plan hitting a similar cycle follows the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## Contracts — what each sub-plan exports
|
||||
|
||||
This is the section that lets sub-plans be written and reviewed independently. Every sub-plan must produce its contracts without breaking changes once another sub-plan depends on them. Contracts are enforced via TypeScript types — any change to an exported type is a cross-sub-plan review gate.
|
||||
|
||||
### 1A-1 — Project skeleton contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **Project layout** — the `src/` directory tree from design spec §1.3. Every other sub-plan adds files inside this tree. No sub-plan is allowed to create top-level directories outside `src/` / `tests/` / `scripts/` / `docs/` without explicit call-out.
|
||||
- **`tsconfig.json`** — strict mode, path aliases (`@/` → `src/`, `@phase0/` → `scripts/phase-0/`), `noUncheckedIndexedAccess`, `isolatedModules`.
|
||||
- **`.eslintrc.cjs` base** — the baseline config. 1A-3 adds the boundaries rules on top.
|
||||
- **`package.json` scripts** — `dev`, `build:standalone`, `build:remote`, `build:both`, `test`, `test:coverage`, `lint`, `typecheck`. Pinned **Node 24** via `engines` and `.nvmrc`.
|
||||
- **`package.json` dependencies owned by 1A-1** — `zod` (for env validation and `storage.ts` schema validation — used by both 1A-1 `src/env/` and 1H `src/shared/storage.ts`, so it lives in the common base).
|
||||
- **`src/env/index.ts`** — runtime env-var reader returning a Zod-validated typed `Env` object. Other sub-plans read env vars exclusively through this module.
|
||||
- **`src/host-contract.ts`** — the `HostContract` type, reproduced byte-for-byte from design spec §2.4:
|
||||
|
||||
```ts
|
||||
import type { Logger } from "@/observability/logger/types";
|
||||
|
||||
export interface HostContract {
|
||||
locale: string; // "ru", "en", ...
|
||||
canonicalOrigin: string; // "https://flights.aeroflot.ru"
|
||||
navigate?: (path: string) => void; // optional deep-link nav override
|
||||
consent?: { analytics: boolean; telemetry: boolean }; // optional, else assumed true
|
||||
logger?: Logger; // optional host logger merge
|
||||
}
|
||||
```
|
||||
|
||||
- **Empty feature and UI barrel files** — `src/features/{online-board,schedule,flights-map,popular-requests}/index.ts` and `src/ui/index.ts` exist but export nothing. Phase 2+ populates them. **Exit-gate rule: the public barrel surface is frozen — no other sub-plan creates new cross-boundary imports outside these barrels.**
|
||||
|
||||
**TypeScript contracts:**
|
||||
|
||||
```ts
|
||||
// src/env/index.ts
|
||||
export interface Env {
|
||||
NODE_ENV: "development" | "testing" | "staging" | "production";
|
||||
BUILD_TARGET: "standalone" | "remote";
|
||||
PROD_ORIGIN: string;
|
||||
API_BASE_URL: string;
|
||||
SIGNALR_HUB_URL: string;
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
||||
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
||||
LOGS_ENDPOINT?: string;
|
||||
ANALYTICS_ENABLED: AnalyticsProviders; // imported from src/observability/analytics/types
|
||||
VERSION: string; // git sha, injected at build time
|
||||
}
|
||||
export function getEnv(): Env;
|
||||
```
|
||||
|
||||
**Exit gate for 1A-1:**
|
||||
- `pnpm typecheck` and `pnpm lint` green on an empty src tree plus the env module.
|
||||
- `src/env/index.ts` round-trips a test `Env` object through Zod validation and surfaces a readable error on malformed input.
|
||||
- **Frozen barrel rule** documented in 1A-1's deliverables — subsequent sub-plans may add exports *to* these barrels but may not create parallel public surfaces.
|
||||
- **Rename-pass rework task** attached to this sub-plan's exit gate: if A1 (customer module template) resolves after 1A-1 ships, a bounded rename pass moves the src tree to match the template without rewriting internals. The frozen barrel rule ensures this rename is a mechanical operation.
|
||||
|
||||
---
|
||||
|
||||
### 1A-2 — MF 2.0 + dual build targets + RemoteLoader contracts
|
||||
|
||||
**First task (gate):** **Modern.js + MF 2.0 spike.** A 2–4 hour timeboxed experiment that boots the simplest possible Modern.js + Rspack + `@module-federation/modern-js` end-to-end example (a single exposed hello-world component), documents gotchas in `docs/superpowers/phase-1/modernjs-mf-spike.md`, and produces a **pinned version matrix** (Modern.js X.Y.Z + `@module-federation/modern-js` A.B.C + Rspack P.Q.R). If the spike fails or reveals a hard blocker, 1A-2 halts and the issue escalates to the customer before committing to Modern.js.
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`modern.config.ts`** — the Modern.js build config with `BUILD_TARGET=standalone|remote` branching. Later sub-plans (1G-metrics, 1H, 1I) modify this file via explicit "modify" tasks — 1A-2 owns the base structure.
|
||||
- **Dual build targets** — `pnpm build:standalone` produces `dist/standalone/` (Node server + client bundle); `pnpm build:remote` produces `dist/remote/` (static chunks + `mf-manifest.json`).
|
||||
- **`src/mf/remote-loader.ts`** — Module Federation 2.0 runtime API wrapper so Phase 2+ can consume other customer remotes without touching `modern.config.ts` again:
|
||||
|
||||
```ts
|
||||
export interface RemoteModuleRef<T = unknown> {
|
||||
name: string; // remote name (e.g. "customer-ui")
|
||||
module: string; // exposed module path (e.g. "./Header")
|
||||
}
|
||||
|
||||
export function loadRemoteModule<T>(ref: RemoteModuleRef<T>): Promise<T>;
|
||||
export function registerRemote(entry: { name: string; entry: string }): void;
|
||||
```
|
||||
|
||||
- **`src/mf/host-entry.ts`** — the entry point for the remote build target that consumes `HostContract` and bootstraps the React subtree into a host-provided mount point.
|
||||
|
||||
**TypeScript contracts:** `RemoteModuleRef`, `loadRemoteModule`, `registerRemote` (above). `HostContract` is imported from 1A-1 (`src/host-contract.ts`).
|
||||
|
||||
**Exit gate for 1A-2:**
|
||||
- Spike doc committed with pinned version matrix.
|
||||
- `pnpm build:both` produces `dist/standalone/` (Node server + client bundle) and `dist/remote/` (static chunks + `mf-manifest.json`) with zero type errors.
|
||||
- A minimal integration test loads a test remote via `loadRemoteModule` and asserts the returned module.
|
||||
|
||||
---
|
||||
|
||||
### 1A-3 — ESLint boundaries contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`.eslintrc.cjs` additions** — `eslint-plugin-boundaries` rules enforcing the layered dependency direction from design spec §1.2:
|
||||
- `features/` cannot import `routes/` or `mf/`
|
||||
- `ui/` cannot import `features/`
|
||||
- `shared/` cannot import `features/`, `routes/`, `mf/`, `observability/`
|
||||
- `observability/` cannot import `features/`, `routes/`, `mf/`
|
||||
- **`no-restricted-imports` rules:**
|
||||
- `@opentelemetry/sdk-metrics` — allowed only in `src/observability/metrics/otel.ts` (keeps module-level instrument exports safe by forcing meter acquisition through `@opentelemetry/api`'s proxy meter).
|
||||
- `window.localStorage` / `window.sessionStorage` — allowed only in `src/shared/storage.ts`.
|
||||
- `@microsoft/signalr` — forbidden in any file that's part of the SSR bundle (enforced via file-path pattern).
|
||||
- `react-i18next` — forbidden outside `src/i18n/provider.tsx` (feature code goes through the re-export).
|
||||
|
||||
**Exit gate for 1A-3:** Each rule has a fabricated violation test in `tests/eslint/` that asserts the rule fires.
|
||||
|
||||
---
|
||||
|
||||
### 1B — CI pipeline contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`.github/workflows/ci.yml`** (or equivalent for the CI provider chosen via A3). Runs on every PR and every push to `main`: install, lint, typecheck, unit tests, build both targets, bundle-size gate, security scan.
|
||||
- **`.github/workflows/nightly.yml`** — nightly-only: contract tests, load test (stub until Phase 2), Lighthouse CI.
|
||||
- **`scripts/ci/bundle-size-gate.ts`** — reads the Rspack build stats and compares against budgets in `docs/superpowers/phase-1/bundle-budgets.json`. Fails the build if any budget is exceeded.
|
||||
- **`scripts/ci/check-coverage-delta.ts`** — reads Vitest coverage JSON and the prior-commit's coverage JSON (from the base branch), fails if coverage decreases. Uses `git show <base>:coverage-summary.json` to read the baseline; tolerates missing baseline (first run).
|
||||
|
||||
**TypeScript contracts:** none exported (scripts are CI-internal).
|
||||
|
||||
**Exit gate for 1B:** A PR with a trivially-broken test fails CI; a PR with a green test passes CI in under 20 minutes. Bundle-size gate flags a fabricated regression.
|
||||
|
||||
---
|
||||
|
||||
### 1C — i18n runtime contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/i18n/config.ts`** — factory that creates a request-scoped `i18next` instance configured with `i18next-icu`, loaded with a single locale's bundle:
|
||||
|
||||
```ts
|
||||
export function createI18nInstance(options: {
|
||||
locale: Language;
|
||||
initialResources?: Record<string, Record<string, unknown>>;
|
||||
}): Promise<i18n>;
|
||||
```
|
||||
|
||||
- **`src/i18n/resolver.ts`** — locale resolution from URL prefix:
|
||||
|
||||
```ts
|
||||
export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de";
|
||||
export const LANGUAGES: readonly Language[];
|
||||
export function isLanguage(x: string): x is Language;
|
||||
export function resolveLocaleFromPath(pathname: string): Language | null;
|
||||
export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null;
|
||||
```
|
||||
|
||||
- **`src/i18n/locales/{lang}/common.json`** — 9 files, ported from `ClientApp/src/assets/i18n/*.json` using the Phase 0 translation-key inventory to drop dead keys (optional; by default, all keys port). ICU MessageFormat syntax preserved byte-for-byte.
|
||||
- **`src/i18n/serializer.ts`** — helpers to serialize the loaded locale bundle into the SSR HTML payload under `window.__I18N__` and rehydrate it on the client:
|
||||
|
||||
```ts
|
||||
export function serializeI18nForHydration(i18n: i18n): string; // emits a JSON string
|
||||
export function hydrateI18nFromWindow(): Promise<i18n>; // reads window.__I18N__
|
||||
```
|
||||
|
||||
- **`src/i18n/provider.tsx`** — React Context provider + `<I18nProvider i18n={...}>` component + `useI18n()` accessor. **Re-exports `useTranslation` from `react-i18next`** so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule).
|
||||
|
||||
**Exit gate for 1C:** Vitest test renders a component with `<I18nProvider>` loaded with `ru` and asserts `t("common.someKey")` returns the Russian value. SSR + hydration roundtrip test using `renderToString` verifies no client-side re-fetch or flash.
|
||||
|
||||
---
|
||||
|
||||
### 1D — API client contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/shared/api/client.ts`** — the `ApiClient` class:
|
||||
|
||||
```ts
|
||||
export interface ApiClientRetryOptions {
|
||||
maxRetries?: number; // default 3 (idempotent only)
|
||||
timeoutFactor?: number; // default 2 (exponential backoff)
|
||||
statusCodes?: number[]; // default [408, 429, 500, 502, 503, 504]
|
||||
}
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
locale: Language;
|
||||
traceId?: string;
|
||||
fetchImpl?: typeof fetch; // for tests (ignored on server path where undici is used)
|
||||
defaultTimeoutMs?: number; // default 5000
|
||||
retry?: ApiClientRetryOptions;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
constructor(options: ApiClientOptions);
|
||||
get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
|
||||
post<T>(path: string, body: unknown): Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
**Server path** uses `undici.RetryAgent` under the hood (statusCodes + timeoutFactor wired into the agent). **Client path** uses `globalThis.fetch` with a thin hand-rolled retry wrapper that applies the same config.
|
||||
|
||||
- **`src/shared/api/errors.ts`** — typed error classes:
|
||||
|
||||
```ts
|
||||
export class ApiError extends Error { constructor(message: string); }
|
||||
export class ApiHttpError extends ApiError { status: number; body?: unknown; }
|
||||
export class ApiTimeoutError extends ApiError { timeoutMs: number; }
|
||||
export class ApiNetworkError extends ApiError { cause?: Error; }
|
||||
```
|
||||
|
||||
- **`src/shared/api/cache.ts`** — three distinct cache types:
|
||||
|
||||
```ts
|
||||
// (1) SSR request-scoped dedup: one-shot, discarded after response
|
||||
export class RequestScopedCache {
|
||||
get<T>(key: string): Promise<T> | undefined;
|
||||
set<T>(key: string, promise: Promise<T>): void;
|
||||
}
|
||||
|
||||
// (2) Client-side per-tab in-memory TTL cache (count-capped)
|
||||
export class ClientMemoryCache<T> {
|
||||
constructor(options: { max: number; defaultTtlMs: number });
|
||||
get(key: string): T | undefined;
|
||||
set(key: string, value: T, ttlMs?: number): void;
|
||||
delete(key: string): void;
|
||||
clear(): void;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// (3) Shared per-VM LRU cache with BYTE cap (~100MB), backed by lru-cache@^10
|
||||
export class ServerLruCache<T> {
|
||||
constructor(options: {
|
||||
maxBytes: number; // e.g. 100 * 1024 * 1024
|
||||
defaultTtlMs: number;
|
||||
sizeCalculation?: (value: T, key: string) => number; // default: JSON.stringify length
|
||||
});
|
||||
get(key: string): T | undefined;
|
||||
set(key: string, value: T, ttlMs?: number): void;
|
||||
delete(key: string): void;
|
||||
clear(): void;
|
||||
calculatedSize: number; // current bytes used
|
||||
}
|
||||
|
||||
// Key convention used by all three caches
|
||||
export function cacheKey(endpoint: string, query: Record<string, unknown>, locale: Language): string;
|
||||
```
|
||||
|
||||
Default TTLs from design spec §4.2: **30s for live data, 5 min for static reference data** (wired via call-site overrides).
|
||||
|
||||
- **`src/shared/api/circuit-breaker.ts`**:
|
||||
|
||||
```ts
|
||||
export interface CircuitBreakerOptions {
|
||||
failureThreshold?: number; // default 5
|
||||
openDurationMs?: number; // default 30_000
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
constructor(options?: CircuitBreakerOptions);
|
||||
exec<T>(fn: () => Promise<T>): Promise<T>;
|
||||
reset(): void;
|
||||
state: "closed" | "open" | "half-open";
|
||||
}
|
||||
```
|
||||
|
||||
- **`src/shared/api/cached-client.ts`** — **caching decorator** layered above `ApiClient` (not inside it):
|
||||
|
||||
```ts
|
||||
export interface CachedClientOptions {
|
||||
client: ApiClient;
|
||||
requestScoped?: RequestScopedCache; // SSR only
|
||||
clientMemory?: ClientMemoryCache<unknown>;
|
||||
serverLru?: ServerLruCache<unknown>;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
export class CachedApiClient {
|
||||
constructor(options: CachedClientOptions);
|
||||
get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
|
||||
}
|
||||
```
|
||||
|
||||
Feature code opts into caching by wrapping `ApiClient` in `CachedApiClient`; uncached calls go through the raw `ApiClient`.
|
||||
|
||||
- **`src/shared/api/provider.tsx`** — React Context provider + `useApiClient()` hook. SSR-aware: on the server, the client is constructed per-request with the resolved locale; on the client, a single instance is shared across the tab.
|
||||
|
||||
**Dependency on 1G-logger:** `ApiClientOptions.logger?: Logger` consumes the type-only import from `src/observability/logger/types`. 1D must not ship before 1G-logger's type-only file.
|
||||
|
||||
**Package additions (1D):** `undici` (explicit dep, even though Node 24 includes it — pin for deterministic behavior), `lru-cache@^10`.
|
||||
|
||||
**Exit gate for 1D:** Vitest tests cover: success response deserialization; retry on `[408, 429, 500, 502, 503, 504]`; no retry on other 4xx; timeout; `Retry-After` honored on 429/503; circuit breaker open/half-open/closed transitions; request-scoped cache dedup; client-memory TTL eviction; server LRU byte cap eviction under load.
|
||||
|
||||
---
|
||||
|
||||
### 1E — SignalR wrapper contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/shared/signalr/connection.ts`** — the reference-counted connection wrapper:
|
||||
|
||||
```ts
|
||||
export interface HubOptions {
|
||||
hubUrl: string;
|
||||
reconnectDelaysMs?: number[]; // default [0, 2000, 10000, 30000]
|
||||
gracePeriodMs?: number; // default 5000
|
||||
}
|
||||
|
||||
export class SignalRConnection {
|
||||
constructor(options: HubOptions);
|
||||
subscribe(channel: string, handler: (message: unknown) => void): () => void;
|
||||
onStatusChange(handler: (status: ConnectionStatus) => void): () => void;
|
||||
get status(): ConnectionStatus;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline";
|
||||
|
||||
export function getSharedConnection(options: HubOptions): SignalRConnection;
|
||||
```
|
||||
|
||||
- **`src/shared/hooks/useLiveFlights.ts`** — **generic** live-data hook (not hardcoded to flight-search params):
|
||||
|
||||
```ts
|
||||
export function useLiveFlights<TParams, TData>(
|
||||
params: TParams,
|
||||
initialData: TData[],
|
||||
config: {
|
||||
hubUrl: string;
|
||||
channelKey: (params: TParams) => string;
|
||||
},
|
||||
): { data: TData[]; connectionStatus: ConnectionStatus };
|
||||
```
|
||||
|
||||
SSR-safe: during SSR, returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr` (enforced by 1A-3's ESLint SSR-bundle guard).
|
||||
|
||||
**Exit gate for 1E:**
|
||||
- Two rapid `useEffect` mounts (Strict Mode double-invoke simulation) result in exactly one `HubConnection.start()` call.
|
||||
- Unmount + remount within the grace period reuses the connection; unmount + remount after the grace period creates a fresh one.
|
||||
- SSR render path does not import `@microsoft/signalr` (asserted by inspecting the SSR bundle stats for the absence of the package).
|
||||
|
||||
---
|
||||
|
||||
### 1F-layout — Root layout + routes + error mapper contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/routes/layout.tsx`** — root HTML shell: `<html>`, `<head>`, `<Scripts>`, `<Links>`, root `<I18nProvider>`, root `<ApiClientProvider>`, root `<ErrorBoundary>`, root `<AnalyticsLoader>` (from 1G-analytics). Wrapped by `<LoggerProvider>` (from 1G-logger).
|
||||
|
||||
- **`src/routes/[lang]/layout.tsx`** — locale-scoped layout: validates `params.lang`, creates the request-scoped i18next instance (via 1C's `createI18nInstance`), builds the canonical URL + hreflang set (via 1F-seo's `buildHreflangSet`), passes them into `<SeoHead>`. Creates a request-scoped logger child and an OTel span (from 1G-metrics).
|
||||
|
||||
- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. Fully responsive (assertions below).
|
||||
|
||||
- **`src/routes/[lang]/smoke/page.tsx`** — smoke route that exercises every foundation subsystem: emits a log at `info`, emits a metric counter, calls `track("smoke.pageview")`, renders `{t("smoke.heading")}`, fetches a dummy API endpoint via `ApiClient.get`, renders `<SeoHead>` with canonical + hreflang. Visible in `testing` env at `/ru/smoke` and `/en/smoke`. Uses `React.lazy()` + `<Suspense>` + a server loader as a deliberate stress test of the React 18 concurrent + streaming path.
|
||||
|
||||
- **`src/routes/error/map.ts`** — error-to-HTTP mapper consumed by the SSR loader path:
|
||||
|
||||
```ts
|
||||
export interface ErrorResponse {
|
||||
status: 404 | 500 | 503;
|
||||
headers?: Record<string, string>;
|
||||
errorCode: "not_found" | "internal" | "unavailable";
|
||||
}
|
||||
|
||||
export function errorToResponse(error: unknown): ErrorResponse;
|
||||
```
|
||||
|
||||
Mapping rules (design spec §4.6):
|
||||
- `ApiHttpError` with `status === 404` → `{ status: 404, errorCode: "not_found" }`
|
||||
- `ApiHttpError` with `status` in 500–599 → `{ status: 500, errorCode: "internal" }`
|
||||
- `ApiTimeoutError` → `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }`
|
||||
- Unknown → `{ status: 500, errorCode: "internal" }`
|
||||
|
||||
- **`src/ui/errors/ErrorBoundary.tsx`** — React error boundary component. Logs the error via `useLogger()`, emits the `flights.react.error` metric, shows a fallback UI with a "Retry" button that resets the boundary's state.
|
||||
|
||||
**Shared file ownership flag:** `src/routes/layout.tsx` is owned by 1F-layout. Sub-plans 1G-analytics (analytics loader mount), 1G-logger (provider wrap), and 1H (CSP nonce propagation + stream transform) modify this file via explicit tasks, referencing the 1F-layout-shipped version as the base.
|
||||
|
||||
**Exit gate for 1F-layout:**
|
||||
- `/ru/smoke` and `/en/smoke` render via SSR in the `testing` env.
|
||||
- `<head>` contains title, description, canonical, 9 hreflang alternates + `x-default`, OG tags, one JSON-LD block, one `<meta>` description (generated by 1F-seo).
|
||||
- Missing `lang` URLs redirect 301 to `/ru/smoke`.
|
||||
- 404 route returns HTTP 404 with the error page body.
|
||||
- **Responsive baseline (R2):** Playwright renders `/ru/smoke` and `/ru/error/404` at widths **320, 768, 1280, 1920** — no horizontal scroll, all critical content visible, error page layout stable. Baselines committed under `tests/fixtures/phase-1/responsive/`.
|
||||
|
||||
---
|
||||
|
||||
### 1F-seo — SeoHead + hreflang + JsonLdRenderer contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/ui/seo/SeoHead.tsx`** — the `<SeoHead>` component from design spec §6.5:
|
||||
|
||||
```ts
|
||||
export interface SeoHeadProps {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical: string;
|
||||
hreflang: Array<{ lang: Language | "x-default"; href: string }>;
|
||||
og: {
|
||||
title: string; description: string; url: string;
|
||||
image: string; type: "website" | "article";
|
||||
locale: string; siteName: string;
|
||||
};
|
||||
twitter?: {
|
||||
card: "summary" | "summary_large_image";
|
||||
title?: string; description?: string; image?: string;
|
||||
};
|
||||
jsonLd?: unknown | unknown[];
|
||||
noindex?: boolean;
|
||||
}
|
||||
export function SeoHead(props: SeoHeadProps): JSX.Element;
|
||||
```
|
||||
|
||||
- **`src/shared/seo/hreflang.ts`** — reusable reciprocal-hreflang builder:
|
||||
|
||||
```ts
|
||||
export function buildHreflangSet(args: {
|
||||
canonicalOrigin: string;
|
||||
pathWithoutLocale: string; // e.g. "/onlineboard/flight/SU100-2025-01-15"
|
||||
}): Array<{ lang: Language | "x-default"; href: string }>;
|
||||
```
|
||||
|
||||
Per design spec §1.4: `x-default` points to the Russian (`ru`) variant.
|
||||
|
||||
- **`src/shared/seo/json-ld.tsx`** — `schema-dts`-typed JSON-LD renderer helper:
|
||||
|
||||
```ts
|
||||
import type { Thing } from "schema-dts";
|
||||
|
||||
export interface JsonLdRendererProps {
|
||||
data: Thing | Thing[];
|
||||
}
|
||||
|
||||
export function JsonLdRenderer(props: JsonLdRendererProps): JSX.Element;
|
||||
export function serializeJsonLd(data: Thing | Thing[]): string;
|
||||
```
|
||||
|
||||
Phase 2+ feature sub-plans ship typed builders (`buildFlightJsonLd`, `buildFlightSearchResultsJsonLd`, etc.) that consume `JsonLdRenderer` — no infrastructure work needed per feature.
|
||||
|
||||
**Exit gate for 1F-seo:** Unit tests cover `buildHreflangSet` for the 9 languages + `x-default`, `SeoHead` emits the full `<head>` shape, and `JsonLdRenderer` round-trips a typed `Thing` through `serializeJsonLd` → DOM string.
|
||||
|
||||
---
|
||||
|
||||
### 1G-logger — Logger contracts
|
||||
|
||||
**Sequencing:** The **first task** of 1G-logger is `src/observability/logger/types.ts` shipping **only type definitions**, so 1A-1's `HostContract` can import `Logger` without waiting on the runtime implementation.
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/observability/logger/types.ts`** (type-only, ships first):
|
||||
|
||||
```ts
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
export type LogFields = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface LogTransport {
|
||||
write(record: LogRecord): void;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LogRecord {
|
||||
ts: string; level: LogLevel; msg: string; fields: LogFields;
|
||||
}
|
||||
```
|
||||
|
||||
- **`src/observability/logger/json-lines-transport.ts`** — default `JsonLinesHttpTransport` with batching, backpressure drop, redaction, `sendBeacon` flush.
|
||||
- **`src/observability/logger/console-transport.ts`** — dev-mode transport.
|
||||
- **`src/observability/logger/root.ts`** — `createRootLogger()` factory that reads env config and picks a transport.
|
||||
- **`src/observability/logger/provider.tsx`** — React context + `useLogger()` hook. Server: request-scoped child logger. Client: shared root logger.
|
||||
|
||||
**A4-trigger task (from requirements gap R3):** When the customer provides the log format (A4 resolution), a follow-on task creates `src/observability/logger/customer-format-transport.ts` implementing the customer-specified format, and updates `createRootLogger()` to pick this transport by default. **No consumer code changes** — the `Logger` interface is stable. This task is attached to 1G-logger's exit gate and fires on A4 resolution (could be during Phase 1, or deferred to early Phase 2).
|
||||
|
||||
**Exit gate for 1G-logger:**
|
||||
- Type-only file ships first, verified by 1A-1 successfully importing `Logger`.
|
||||
- Vitest tests cover: batching + flush; redaction of sensitive fields; transport backpressure drops old records; dev-mode console transport; `child()` context propagation.
|
||||
|
||||
---
|
||||
|
||||
### 1G-metrics — OpenTelemetry + custom instruments contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup (the **only** file allowed to import from `@opentelemetry/sdk-metrics`, enforced by 1A-3):
|
||||
|
||||
```ts
|
||||
export function initServerOtel(env: Env): void; // called once per Node process
|
||||
export function initBrowserOtel(env: Env): void; // called once per tab
|
||||
export function getMeter(name: string): Meter; // thin re-export of @opentelemetry/api
|
||||
export function getTracer(name: string): Tracer;
|
||||
```
|
||||
|
||||
- **`src/observability/metrics/custom.ts`** — minimum-set custom metrics from design spec §7.3 as exported module-level instruments. **This pattern is safe** because the instruments are created against `@opentelemetry/api`'s proxy meter, which lazy-resolves to the real meter after `initServerOtel`/`initBrowserOtel` runs:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("flights");
|
||||
|
||||
export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration");
|
||||
export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration");
|
||||
export const flightsApiError = meter.createCounter("flights.api.error");
|
||||
export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected");
|
||||
export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received");
|
||||
export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect");
|
||||
export const flightsFeatureRender = meter.createCounter("flights.feature.render");
|
||||
export const flightsReactError = meter.createCounter("flights.react.error");
|
||||
// web-vitals histograms created at init time inside initBrowserOtel, not exported statically
|
||||
```
|
||||
|
||||
**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1G-metrics modifies it to wire OTel SDK init + request tracing plugin into the Modern.js middleware chain.
|
||||
|
||||
**Exit gate for 1G-metrics:**
|
||||
- Integration test: `initServerOtel` runs, a counter is incremented, and the test reader observes the recorded value (proves the proxy meter resolved correctly).
|
||||
- ESLint rule from 1A-3 blocks a fabricated `import { MeterProvider } from "@opentelemetry/sdk-metrics"` in a file outside `otel.ts`.
|
||||
|
||||
---
|
||||
|
||||
### 1G-analytics — Analytics facade contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/observability/analytics/types.ts`**:
|
||||
|
||||
```ts
|
||||
export interface AnalyticsProviders {
|
||||
metrica: boolean;
|
||||
ctm: boolean;
|
||||
variocube: boolean;
|
||||
dynatrace: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsProps { [k: string]: unknown; }
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
kind: "track" | "page";
|
||||
name: string; // event name or page URL
|
||||
props: AnalyticsProps;
|
||||
provider: string; // "metrica" | "ctm" | "variocube" | "dynatrace"
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
track(event: string, props?: AnalyticsProps): void;
|
||||
page(url: string, props?: AnalyticsProps): void;
|
||||
}
|
||||
```
|
||||
|
||||
- **`src/observability/analytics/facade.ts`**:
|
||||
|
||||
```ts
|
||||
export function createAnalytics(options: {
|
||||
enabled: AnalyticsProviders;
|
||||
consent: { analytics: boolean; telemetry: boolean };
|
||||
logger: Logger;
|
||||
}): Analytics;
|
||||
```
|
||||
|
||||
- **`src/observability/analytics/adapters/{metrica,ctm,variocube,dynatrace}.ts`** — four adapters implementing:
|
||||
|
||||
```ts
|
||||
export interface AnalyticsAdapter {
|
||||
name: string;
|
||||
load(): Promise<void>;
|
||||
track(event: string, props?: AnalyticsProps): void;
|
||||
page(url: string, props?: AnalyticsProps): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Phase 1 ships these as structured stubs** (R8): each stub's `load()`/`track()`/`page()` emits an `AnalyticsEvent` to a **test-observable sink** (`src/observability/analytics/sink.ts`) with the `provider` field set to the adapter's name. Real vendor scripts wire in Phase 2A (alongside the Online Board migration) after A7 resolves.
|
||||
|
||||
- **`src/observability/analytics/sink.ts`** — test-observable event sink; exports `getRecordedEvents()` and `resetEvents()` for integration tests. In production, the sink is a no-op ring buffer.
|
||||
|
||||
- **`src/observability/analytics/loader.tsx`** — `<AnalyticsLoader>` component that mounts in the root layout (owned by 1F-layout, modified by 1G-analytics to add the mount). Waits for `requestIdleCallback`, then imports enabled adapters and calls `.load()`.
|
||||
|
||||
- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. Server: returns a `NoopAnalytics`. Client: returns the instance from `<AnalyticsLoader>`.
|
||||
|
||||
**Exit gate for 1G-analytics:**
|
||||
- Integration test: smoke route calls `track("smoke.pageview")`, all four stub adapters emit exactly one `AnalyticsEvent` to the sink with matching props.
|
||||
- `consent.analytics = false` short-circuits before any adapter is invoked.
|
||||
- Adapter load failure emits `flights.analytics.load_failed` counter.
|
||||
|
||||
---
|
||||
|
||||
### 1H — Security hardening contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/server/middleware/csp.ts`** — Modern.js middleware that generates a per-request nonce, sets the `Content-Security-Policy` header (per design spec §8.1), and exposes the nonce to the React render tree via a request-scoped context.
|
||||
|
||||
```ts
|
||||
export function cspMiddleware(options: { reportOnly?: boolean }): ModernMiddleware;
|
||||
export const CspNonceContext: React.Context<string>; // default ""; components reading client-side no-op on empty
|
||||
```
|
||||
|
||||
- **`src/server/middleware/nonce-stream-transform.ts`** — **workaround for React issue #24883.** React 18's `renderToPipeableStream({ nonce })` only applies the nonce to inline `bootstrapScriptContent`, **not to external `bootstrapScripts` src URLs.** This middleware post-processes the SSR HTML stream to inject `nonce="{nonce}"` on every `<script>` tag that doesn't already have one.
|
||||
|
||||
```ts
|
||||
export function wrapSsrStreamWithNonce(
|
||||
stream: NodeJS.ReadableStream,
|
||||
nonce: string,
|
||||
): NodeJS.ReadableStream;
|
||||
```
|
||||
|
||||
Integration test (required for the exit gate): every `<script>` tag in the SSR output of `/ru/smoke` carries the per-request nonce.
|
||||
|
||||
- **`src/server/middleware/security-headers.ts`** — Modern.js middleware setting HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy (per design spec §8.1).
|
||||
|
||||
- **`src/shared/storage.ts`** — the only module allowed to touch `window.localStorage` / `window.sessionStorage` (enforced by 1A-3). Namespaced keys + Zod schema validation on read:
|
||||
|
||||
```ts
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string, schema: ZodSchema<T>): T | null,
|
||||
set<T>(key: string, value: T, schema: ZodSchema<T>): void,
|
||||
delete(key: string): void,
|
||||
clear(): void,
|
||||
};
|
||||
```
|
||||
|
||||
**Shared file ownership flags:**
|
||||
|
||||
- `modern.config.ts` (owned by 1A-2) — 1H modifies to register CSP, nonce-stream-transform, and security-headers middlewares.
|
||||
- `src/routes/layout.tsx` (owned by 1F-layout) — 1H modifies to propagate the CSP nonce into `<Head>`-emitted inline scripts.
|
||||
|
||||
**Exit gate for 1H:**
|
||||
- SSR render emits a CSP header with a unique nonce per request; **every** `<script>` in the output (both inline and external `bootstrapScripts`) carries the nonce.
|
||||
- `storage.get` with a mismatching schema returns `null` rather than corrupt data.
|
||||
- ESLint: `window.localStorage` usage outside `src/shared/storage.ts` fails lint (rule from 1A-3).
|
||||
|
||||
---
|
||||
|
||||
### 1I — Deploy pipeline + runbook contracts
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`Dockerfile`** at repo root — multi-stage build: **Node 24** base, installs pnpm, runs `pnpm build:standalone`, produces a minimal production image with the standalone server as the entrypoint.
|
||||
- **`Dockerfile.remote`** — static-file server image for the remote-mode artifact (nginx base, copies `dist/remote/` into `/usr/share/nginx/html`).
|
||||
- **`src/server/routes/health.ts`** — health endpoint registered at `/health` via Modern.js's middleware API:
|
||||
|
||||
```ts
|
||||
export function healthMiddleware(options: {
|
||||
apiClient: ApiClient;
|
||||
upstreamTimeoutMs?: number;
|
||||
}): ModernMiddleware;
|
||||
```
|
||||
|
||||
Returns 200 if the last successful upstream REST ping is within 60s, 503 otherwise.
|
||||
|
||||
- **`src/server/shutdown.ts`** — graceful shutdown handler: on SIGTERM, stops accepting new requests, drains in-flight for 30s, flushes the log buffer, exits.
|
||||
- **`.github/workflows/deploy.yml`** — reuses PR-built artifacts, builds Docker images, pushes to the customer's registry, triggers a canary deploy to the `testing` env via the customer's deployment tool, runs post-deploy smoke test, auto-rolls back on health-check failure.
|
||||
- **`docs/superpowers/phase-1/runbook.md`** — operational runbook (requirements gap R1) covering:
|
||||
1. Incident response decision tree (user impact → severity → who pages whom)
|
||||
2. Canary rollout procedure with monitoring checklist
|
||||
3. Rollback procedure (auto and manual)
|
||||
4. Health-check interpretation (`/health` 200/503 meanings, common causes)
|
||||
5. Log query cookbook (how to find errors in the customer log aggregator)
|
||||
6. Known-failure playbooks for: upstream API down, SignalR hub offline, CSP violation spike, analytics adapter load failure, OTel exporter unreachable.
|
||||
|
||||
**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1I modifies to register the health middleware and the graceful shutdown hook.
|
||||
|
||||
**Exit gate for 1I:**
|
||||
- Merge to `main` triggers the deploy workflow; `testing` env shows a new revision; `/health` returns 200.
|
||||
- SIGTERM drains in-flight and exits 0 within 31s.
|
||||
- Forced health-check failure auto-rolls back.
|
||||
- Runbook reviewed by at least one engineer and one ops-adjacent reader; 6-hour recovery SLA walked through step-by-step.
|
||||
|
||||
---
|
||||
|
||||
## Shared files — cross-sub-plan modification table
|
||||
|
||||
| File | Primary owner | Also modified by | What the modifiers add |
|
||||
|---|---|---|---|
|
||||
| `modern.config.ts` | 1A-2 | 1G-metrics | OTel SDK init + request tracing plugin |
|
||||
| | | 1H | CSP + nonce-stream-transform + security-headers middleware |
|
||||
| | | 1I | Health middleware + graceful shutdown hook |
|
||||
| `src/routes/layout.tsx` | 1F-layout | 1G-logger | `<LoggerProvider>` wrap |
|
||||
| | | 1G-analytics | `<AnalyticsLoader>` mount |
|
||||
| | | 1H | CSP nonce propagation into `<Head>` inline scripts |
|
||||
| `src/routes/[lang]/layout.tsx` | 1F-layout | 1G-logger | Per-request logger child creation |
|
||||
| | | 1G-metrics | OTel span attachment |
|
||||
| `.eslintrc.cjs` | 1A-1 (base) | 1A-3 | Boundaries plugin rules + `no-restricted-imports` rules |
|
||||
| `package.json` | 1A-1 | 1A-2 | `@module-federation/modern-js`, Modern.js, Rspack (pinned from spike) |
|
||||
| | | 1B | CI-related dev deps (`@vitest/coverage-v8`, size-limit, osv-scanner wrappers) |
|
||||
| | | 1C | `i18next`, `react-i18next`, `i18next-icu`, `i18next-resources-to-backend` |
|
||||
| | | 1D | `lru-cache@^10`, `undici` (pinned) |
|
||||
| | | 1E | `@microsoft/signalr` |
|
||||
| | | 1F-seo | `schema-dts` |
|
||||
| | | 1G-logger | — (stdlib only) |
|
||||
| | | 1G-metrics | `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-web`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals` |
|
||||
| | | 1G-analytics | — (stubs only in Phase 1) |
|
||||
| | | 1I | — (Node 24 native `undici`; additional runtime deps TBD) |
|
||||
| `tsconfig.json` | 1A-1 | — | No modifications after 1A-1 |
|
||||
|
||||
**Modification protocol.** When a downstream sub-plan modifies a file owned by another, its task must:
|
||||
1. Explicitly reference the primary owner ("modifying `modern.config.ts`, which was created in 1A-2 Task N").
|
||||
2. Quote the expected pre-modification state of the file (from the primary owner's exit gate).
|
||||
3. Show the full post-modification file, not just a diff.
|
||||
4. Re-run the primary owner's exit-gate test to prove the modification didn't break it.
|
||||
|
||||
---
|
||||
|
||||
## Spec-coverage matrix
|
||||
|
||||
| Spec section | Topic | Sub-plan(s) |
|
||||
|---|---|---|
|
||||
| §1.1, §1.2 | Runtime topology, dependency direction | 1A-1 + 1A-3 |
|
||||
| §1.3 | `src/` tree | 1A-1 |
|
||||
| §1.4 | URL language-prefix policy | 1C + 1F-layout |
|
||||
| §2.1 | Two build targets | 1A-2 |
|
||||
| §2.2 | `mf-manifest.json` + exposed modules | 1A-2 |
|
||||
| §2.3 | Shared dependencies (React singleton) | 1A-2 |
|
||||
| §2.4 | `HostContract` | 1A-1 (type defined; `RemoteLoader` consumer path in 1A-2) |
|
||||
| §2.5 | Build artifacts + deploy shape | 1A-2 + 1I |
|
||||
| §3.1 | SSR request lifecycle | 1F-layout + 1G-logger + 1G-metrics + 1H |
|
||||
| §3.2 | Two SSR invariants | 1A-3 (ESLint) + 1E (client-only dynamic import) |
|
||||
| §3.3 | File-based routing | 1F-layout |
|
||||
| §3.4 | Loaders, Suspense, `React.lazy` | 1F-layout (smoke route demonstrates the pattern) |
|
||||
| §3.5 | URL parity — ported serializers | Phase 2 (deferred with 1J) |
|
||||
| §3.6 | Canonical / hreflang / redirects | 1F-seo + 1F-layout |
|
||||
| §4.1 | REST client | 1D |
|
||||
| §4.2 | Caching strategy (three caches, `lru-cache@^10` byte cap) | 1D |
|
||||
| §4.3 | SSR → client data handoff | 1F-layout (smoke route demonstrates pattern) |
|
||||
| §4.4 | SignalR wrapper + hook | 1E |
|
||||
| §4.5 | State management | (no Phase 1 work) |
|
||||
| §4.6 | Error handling path | 1D (`ApiError` types) + 1F-layout (`errorToResponse` + `ErrorBoundary`) |
|
||||
| §5 | UI adapter + styling | (Phase 2) |
|
||||
| §6.1–§6.4 | i18n | 1C |
|
||||
| §6.5 | `<SeoHead>` | 1F-seo |
|
||||
| §6.6 | JSON-LD schema coverage | 1F-seo (`JsonLdRenderer` helper; feature-specific builders in Phase 2+) |
|
||||
| §6.7 | OG images (static default) | 1F-seo |
|
||||
| §6.8 | Canonical/hreflang correctness | 1F-seo + 1F-layout |
|
||||
| §7.1, §7.2 | Logging | 1G-logger |
|
||||
| §7.3 | Metrics | 1G-metrics |
|
||||
| §7.4 | Analytics | 1G-analytics |
|
||||
| §7.5 | Error handling layers | 1F-layout + 1G-logger + 1G-metrics |
|
||||
| §7.6 | Correlation | 1G-metrics (trace IDs) + 1G-logger (propagation) |
|
||||
| §7.7 | Performance budgets | 1B (bundle gate) + 1G-metrics (web-vitals reporting) |
|
||||
| §7.8 | Dev-mode ergonomics | 1G-logger (console transport) |
|
||||
| §8.1 | Security / isolation | 1H |
|
||||
| §8.2 | Performance / 100 RPS | 1A-1 (Node 24 tuning) + 1B (bundle gate) + 1I (CDN headers) |
|
||||
| §8.3 | Reliability / geo-distribution | 1I |
|
||||
| §8.4 | Testing strategy | 1B (parity harnesses deferred to Phase 2) |
|
||||
| §8.5 | CI/CD | 1B + 1I |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 global exit gate — checklist
|
||||
|
||||
- [ ] **1A-1:** `pnpm typecheck` + `pnpm lint` green on baseline src tree; `src/env/` Zod validation passes; frozen-barrel rule documented; `HostContract` type in place.
|
||||
- [ ] **1A-2:** MF spike doc + pinned version matrix committed; `pnpm build:both` produces valid `dist/standalone/` + `dist/remote/` + `mf-manifest.json`; `loadRemoteModule` integration test green.
|
||||
- [ ] **1A-3:** Every ESLint boundary + restricted-import rule has a passing fabricated-violation test.
|
||||
- [ ] **1B:** Per-PR CI runs green under 20 min. Nightly pipeline runs successfully at least once.
|
||||
- [ ] **1C:** 9 locale JSON files in place; i18n SSR + hydration roundtrip test passes; zero missing-key warnings on the smoke route.
|
||||
- [ ] **1D:** `ApiClient` unit tests green (retry via `undici.RetryAgent`, timeout, `Retry-After`, circuit breaker, three caches including 100MB byte-cap LRU); isomorphic usage verified SSR + client.
|
||||
- [ ] **1E:** SignalR wrapper tests green (Strict Mode double-mount, grace-period close, SSR import absence); `useLiveFlights<TParams, TData>` generic signature verified by a typed test consumer.
|
||||
- [ ] **1F-layout:** `/ru/smoke` + `/en/smoke` render SSR in `testing` env with full `<head>`; `errorToResponse` mapper round-trips all five error shapes; **responsive baseline** passes at 320/768/1280/1920.
|
||||
- [ ] **1F-seo:** `buildHreflangSet` covers 9 langs + `x-default`; `JsonLdRenderer` round-trips a typed `Thing`.
|
||||
- [ ] **1G-logger:** Type-only file shipped first (consumed by 1A-1); batching + flush + redaction + `child()` tests green; A4-trigger task documented.
|
||||
- [ ] **1G-metrics:** OTel init + custom instruments emit through proxy meter; ESLint guard on `@opentelemetry/sdk-metrics` proven.
|
||||
- [ ] **1G-analytics:** Four stub adapters fan out to structured sink; consent short-circuit verified; smoke route emits one log + one metric + one analytics event, observed at the sink.
|
||||
- [ ] **1H:** CSP header on `testing` env contains per-request nonce; **every** `<script>` tag (inline + external) carries the nonce via the stream transform; security headers present on every response; storage schema validation verified.
|
||||
- [ ] **1I:** Merge to `main` auto-deploys to `testing` via canary; `/health` returns 200; SIGTERM drains within 31s; forced health failure auto-rolls back; **runbook** reviewed and 6-hour SLA walked through.
|
||||
- [ ] **Security scan:** `osv-scanner` + `npm audit` green on `main`.
|
||||
- [ ] **Bundle-size gate:** all Phase 1 budgets from design spec §8.2 met on the smoke route.
|
||||
- [ ] **Coverage:** ≥ 70% line coverage on `src/shared/`, `src/observability/`, `src/ui/`, `src/i18n/`.
|
||||
- [ ] **Documentation:** `docs/superpowers/phase-1/README.md` indexes what Phase 1 shipped + how to run each subsystem locally + how to debug failures + pointer to the runbook.
|
||||
|
||||
---
|
||||
|
||||
## Risks + open questions for Phase 1
|
||||
|
||||
1. **Customer template (A1) still unknown.** If it arrives during Phase 1, 1A-1's rename-pass rework task executes. Frozen-barrel rule keeps the rename bounded.
|
||||
|
||||
2. **Customer log format (A4) still unknown.** 1G-logger ships `JsonLinesHttpTransport`. A4-trigger task creates `CustomerFormatTransport` when the format arrives. No consumer changes.
|
||||
|
||||
3. **Analytics vendor credentials (A7) still unknown.** 1G-analytics ships structured stubs. Real scripts land in Phase 2A.
|
||||
|
||||
4. **CI provider (A3) is a hard blocker for 1A-1.** Assumed GitHub Actions in the master plan; if the customer uses GitLab CI or TeamCity, 1B's workflow YAML is rewritten but the underlying scripts are portable.
|
||||
|
||||
5. **Modern.js + MF 2.0 interaction (T1).** De-risked by the 1A-2 spike as its first task — escalation path defined if the spike fails.
|
||||
|
||||
6. **Node 24 availability on customer VMs (A9, new).** Hard blocker for 1A-1. Node 20 LTS ends April 2026; running Phase 1 on Node 20 would bake in an immediate upgrade debt.
|
||||
|
||||
7. **React issue #24883 (CSP nonce gap on bootstrapScripts).** Known React limitation. 1H ships a stream-transform workaround with an integration test; if React fixes this upstream before Phase 1 exits, the workaround can be removed in 1H's follow-up.
|
||||
|
||||
8. **`lru-cache@^10` byte-sizing cost.** `sizeCalculation` runs on every `set()`. For 100MB cap with `JSON.stringify(value).length` as the default sizer, this is O(n) per cache write. Mitigation: pass a custom `sizeCalculation` that uses pre-computed size hints from the API response (e.g., `content-length` header) where available.
|
||||
|
||||
9. **SSR + `<Suspense>` + streaming + React 18 concurrent mode** has known interop issues with some libraries. 1F-layout's smoke route uses `React.lazy()` + `<Suspense>` + a data loader as a deliberate stress test.
|
||||
|
||||
---
|
||||
|
||||
## How to write each sub-plan
|
||||
|
||||
When the user is ready to execute a sub-plan, re-invoke `superpowers:writing-plans` with a specific prompt like:
|
||||
|
||||
> "Write sub-plan 1A-1 (project skeleton) from `docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md`. Target file: `docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md`. Follow the contracts defined in the master plan §1A-1 exactly; reference the design spec §1.3, §2.1 as source material."
|
||||
|
||||
The sub-plan writer must:
|
||||
|
||||
1. Read this master plan in full for the dependency + contract context.
|
||||
2. Read the relevant design spec sections.
|
||||
3. Read any upstream sub-plans that have already been written (their exit gates lock in file/API shapes).
|
||||
4. Produce a fully TDD-granular plan at the shape of `docs/superpowers/plans/2026-04-14-phase-0-preflight.md`.
|
||||
5. Match the contracts in this master plan byte-for-byte on type signatures. Any contract change requires updating this master plan first.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Every design-spec section §1–§8 maps to at least one sub-plan in the spec-coverage matrix. §9 (Phase 2–6 migration) and parity harnesses (§3.5, §8.4) are explicitly deferred to Phase 2.
|
||||
|
||||
**Placeholder scan.** No `TBD` / `TODO` / `FIXME` outside of the "TBW" markers on sub-plan filenames (deliberate indicators of un-written sub-plans).
|
||||
|
||||
**Internal consistency.** Cross-checked: `Logger` extracted as type-only file in 1G-logger's first task → used by 1A-1, 1D, 1F-layout; `Language` in 1C → used by 1F-seo, 1F-layout; `ApiClient` + caches in 1D → used by 1F-layout, 1I; `ConnectionStatus` in 1E → Phase 2 consumer; `AnalyticsProviders` in 1G-analytics → used by `Env` in 1A-1 (via type-only import from `src/observability/analytics/types`).
|
||||
|
||||
**Plan-order cycle resolution.** The `HostContract` → `Logger` dependency between 1A-1 and 1G-logger is resolved by 1G-logger shipping `src/observability/logger/types.ts` as its first task. The `Env` → `AnalyticsProviders` dependency is resolved by 1G-analytics owning `src/observability/analytics/types.ts` as a type-only file that 1A-1 imports.
|
||||
|
||||
**Execution order correctness.** The dependency graph has no cycles after the type-only extractions. Critical path: 1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I (seven serial sub-plans with one engineer).
|
||||
|
||||
---
|
||||
|
||||
## Next step
|
||||
|
||||
- **If you approve this master plan:** say so, and I'll write sub-plan **1A-1** (project skeleton) fully in the next session. Then we iterate.
|
||||
- **If you want changes:** tell me, I revise.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
# Phase 1A-3 — ESLint Boundaries + Restricted Imports Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add `eslint-plugin-boundaries` layered dependency rules and `no-restricted-imports` guards to the existing ESLint flat config so that architectural violations are caught at lint time — preventing features from importing routes, UI from importing features, observability from leaking SDK internals, and storage from bypassing the `src/shared/storage.ts` wrapper.
|
||||
|
||||
**Architecture:** Extends `eslint.config.js` from 1A-1 with two new plugins/rules: `eslint-plugin-boundaries` (defines element types by directory pattern, enforces which layers can import which) and built-in `no-restricted-imports` (bans specific package imports outside their designated "owner" file). Each rule has a fabricated-violation test in `tests/eslint/` that asserts the rule fires.
|
||||
|
||||
**Tech Stack:** `eslint-plugin-boundaries@^5.0.0` (ESLint 9 flat-config compatible), ESLint built-in `no-restricted-imports`.
|
||||
|
||||
**Scope:** Config-only. No runtime code, no tests of production logic. Every task is "add a rule + add a probe test that proves it fires."
|
||||
|
||||
**Prerequisites (1A-1 + 1A-2 complete):**
|
||||
- `eslint.config.js` flat config with `typescript-eslint`, `unused-imports` already configured.
|
||||
- `src/` tree with `features/`, `ui/`, `shared/`, `observability/`, `mf/`, `routes/`, `env/` directories populated.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `eslint.config.js` | Modify: add boundaries plugin + no-restricted-imports rules | 1, 2 |
|
||||
| `tests/eslint/boundaries.test.ts` | Fabricated violation tests for boundary rules | 3 |
|
||||
| `tests/eslint/restricted-imports.test.ts` | Fabricated violation tests for restricted imports | 4 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install `eslint-plugin-boundaries` and configure element types
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (devDep)
|
||||
- Modify: `eslint.config.js`
|
||||
|
||||
- [ ] **Step 1: Install the plugin**
|
||||
|
||||
```bash
|
||||
pnpm add -D eslint-plugin-boundaries@^5.0.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add boundary element types and dependency rules to `eslint.config.js`**
|
||||
|
||||
Add the following to `eslint.config.js` — import at the top, new config object appended to the array:
|
||||
|
||||
At the top of the file, add the import:
|
||||
```javascript
|
||||
import boundaries from "eslint-plugin-boundaries";
|
||||
```
|
||||
|
||||
Then add a new config object to the exported array (after the existing `files: ["src/**/*.{ts,tsx}"]` block):
|
||||
|
||||
```javascript
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
boundaries,
|
||||
},
|
||||
settings: {
|
||||
"boundaries/elements": [
|
||||
{ type: "routes", pattern: "src/routes/*" },
|
||||
{ type: "mf", pattern: "src/mf/*" },
|
||||
{ type: "features", pattern: "src/features/*", capture: ["feature"] },
|
||||
{ type: "ui", pattern: "src/ui/*" },
|
||||
{ type: "shared", pattern: "src/shared/*" },
|
||||
{ type: "observability", pattern: "src/observability/*" },
|
||||
{ type: "i18n", pattern: "src/i18n/*" },
|
||||
{ type: "env", pattern: "src/env/*" },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
// Design spec §1.2 layered dependency direction:
|
||||
// features/ cannot import routes/ or mf/
|
||||
// ui/ cannot import features/
|
||||
// shared/ cannot import features/, routes/, mf/, observability/
|
||||
// observability/ cannot import features/, routes/, mf/
|
||||
"boundaries/element-types": [
|
||||
"error",
|
||||
{
|
||||
default: "allow",
|
||||
rules: [
|
||||
{
|
||||
from: "features",
|
||||
disallow: ["routes", "mf"],
|
||||
message: "Features must not import from routes/ or mf/. Use the HostContract or a shared module instead.",
|
||||
},
|
||||
{
|
||||
from: "ui",
|
||||
disallow: ["features", "routes", "mf"],
|
||||
message: "UI layer must not import from features/, routes/, or mf/. UI is consumed by features, not the other way around.",
|
||||
},
|
||||
{
|
||||
from: "shared",
|
||||
disallow: ["features", "routes", "mf", "observability"],
|
||||
message: "Shared modules must not import from features/, routes/, mf/, or observability/.",
|
||||
},
|
||||
{
|
||||
from: "observability",
|
||||
disallow: ["features", "routes", "mf"],
|
||||
message: "Observability modules must not import from features/, routes/, or mf/.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify lint still passes on existing code**
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
Expected: exit 0 — existing code doesn't violate the boundaries because no cross-layer imports exist yet.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml eslint.config.js
|
||||
git commit -m "Add eslint-plugin-boundaries with layered dependency rules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Add `no-restricted-imports` rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `eslint.config.js`
|
||||
|
||||
Four restricted-import rules per master plan §1A-3:
|
||||
1. `@opentelemetry/sdk-metrics` — only in `src/observability/metrics/otel.ts`
|
||||
2. `window.localStorage` / `window.sessionStorage` — only in `src/shared/storage.ts` (via `no-restricted-globals`)
|
||||
3. `@microsoft/signalr` — forbidden in SSR-bundle files (enforced by file-path pattern)
|
||||
4. `react-i18next` — only in `src/i18n/provider.tsx`
|
||||
|
||||
- [ ] **Step 1: Add restricted-imports rules to `eslint.config.js`**
|
||||
|
||||
Add a new config object to the array — these are file-path-scoped overrides:
|
||||
|
||||
```javascript
|
||||
// --- Restricted imports (master plan §1A-3) ---
|
||||
// OTel SDK internals: only src/observability/metrics/otel.ts may import them
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/observability/metrics/otel.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@opentelemetry/sdk-metrics",
|
||||
message: "Import from @opentelemetry/api or use getMeter() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
|
||||
},
|
||||
{
|
||||
name: "@opentelemetry/sdk-node",
|
||||
message: "Import from @opentelemetry/api or use getMeter()/getTracer() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// react-i18next: only src/i18n/provider.tsx may import it
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/i18n/provider.tsx"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "react-i18next",
|
||||
message: "Import useTranslation from @/i18n/provider instead. Direct react-i18next imports are restricted to src/i18n/provider.tsx.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// @microsoft/signalr: forbidden in files that run during SSR.
|
||||
// SSR-bundle = routes/ and server/ directories. Features + shared/hooks are client-side.
|
||||
{
|
||||
files: ["src/routes/**/*.{ts,tsx}", "src/server/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@microsoft/signalr",
|
||||
message: "SignalR must not be imported in SSR-bundle files (routes/, server/). Use dynamic import in a useEffect or a client-only wrapper.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// window.localStorage / window.sessionStorage: only src/shared/storage.ts
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/shared/storage.ts"],
|
||||
rules: {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
name: "localStorage",
|
||||
message: "Use the storage module from @/shared/storage instead. Direct localStorage access is restricted to src/shared/storage.ts.",
|
||||
},
|
||||
{
|
||||
name: "sessionStorage",
|
||||
message: "Use the storage module from @/shared/storage instead. Direct sessionStorage access is restricted to src/shared/storage.ts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify lint still passes**
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
Expected: exit 0 — no existing file imports these restricted packages.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add eslint.config.js
|
||||
git commit -m "Add no-restricted-imports for OTel SDK, react-i18next, SignalR SSR, localStorage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Fabricated violation tests for boundary rules
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/eslint/boundaries.test.ts`
|
||||
|
||||
These tests create temporary probe files, run `eslint` on them, and assert violations. This is NOT a vitest test — it's a shell-command test that invokes ESLint on fabricated files. But we wrap it in vitest for consistency with the test runner.
|
||||
|
||||
- [ ] **Step 1: Write `tests/eslint/boundaries.test.ts`**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
function lintString(filePath: string, content: string): string {
|
||||
const absPath = path.join(ROOT, filePath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, "utf8");
|
||||
try {
|
||||
execSync(`pnpm exec eslint "${absPath}" --no-eslintrc -c eslint.config.js --format json`, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return "PASS";
|
||||
} catch (err: unknown) {
|
||||
const error = err as { stdout?: string };
|
||||
return error.stdout ?? "UNKNOWN_ERROR";
|
||||
} finally {
|
||||
fs.unlinkSync(absPath);
|
||||
}
|
||||
}
|
||||
|
||||
describe("boundaries rules", () => {
|
||||
it("features/ cannot import from routes/", () => {
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'import { something } from "../../routes/page";\nexport const x = something;\n',
|
||||
);
|
||||
expect(result).toContain("boundaries/element-types");
|
||||
});
|
||||
|
||||
it("features/ cannot import from mf/", () => {
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'import { REMOTE_BUILD_MARKER } from "../../mf/host-entry";\nexport const x = REMOTE_BUILD_MARKER;\n',
|
||||
);
|
||||
expect(result).toContain("boundaries/element-types");
|
||||
});
|
||||
|
||||
it("ui/ cannot import from features/", () => {
|
||||
const result = lintString(
|
||||
"src/ui/__test_probe__.ts",
|
||||
'import { something } from "../features/online-board";\nexport const x = something;\n',
|
||||
);
|
||||
expect(result).toContain("boundaries/element-types");
|
||||
});
|
||||
|
||||
it("shared/ cannot import from features/", () => {
|
||||
const result = lintString(
|
||||
"src/shared/__test_probe__.ts",
|
||||
'import { something } from "../features/online-board";\nexport const x = something;\n',
|
||||
);
|
||||
expect(result).toContain("boundaries/element-types");
|
||||
});
|
||||
|
||||
it("observability/ cannot import from features/", () => {
|
||||
const result = lintString(
|
||||
"src/observability/__test_probe__.ts",
|
||||
'import { something } from "../features/online-board";\nexport const x = something;\n',
|
||||
);
|
||||
expect(result).toContain("boundaries/element-types");
|
||||
});
|
||||
|
||||
it("features/ CAN import from shared/ (allowed direction)", () => {
|
||||
// This should NOT trigger a boundary violation.
|
||||
// We can't fully test this without a real shared module, so just verify
|
||||
// the rule doesn't fire on a features→env import (which is allowed).
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'import { getEnv } from "../../env";\nexport const x = getEnv;\n',
|
||||
);
|
||||
expect(result).not.toContain("boundaries/element-types");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update vitest config to include `tests/` directory**
|
||||
|
||||
The current `vitest.config.ts` has `include: ["src/**/*.test.ts", "src/**/*.test.tsx"]`. Add `tests/` to the include:
|
||||
|
||||
Edit `vitest.config.ts` `include` to:
|
||||
|
||||
```typescript
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the boundary tests**
|
||||
|
||||
```bash
|
||||
pnpm test tests/eslint/boundaries
|
||||
```
|
||||
Expected: all 6 tests pass. The first 5 should show "boundaries/element-types" in the ESLint output; the last one should NOT.
|
||||
|
||||
If tests fail because `eslint-plugin-boundaries` doesn't detect imports via relative paths, the plugin may need the `boundaries/include` setting or file resolution config. Debug by running the lint command manually on a probe file and checking what ESLint reports.
|
||||
|
||||
If the probe tests are too slow (each spawns an ESLint process), that's acceptable — these are CI-only correctness checks, not hot-loop unit tests.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/eslint/boundaries.test.ts vitest.config.ts
|
||||
git commit -m "Add fabricated violation tests for boundary rules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Fabricated violation tests for restricted imports
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/eslint/restricted-imports.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write `tests/eslint/restricted-imports.test.ts`**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
function lintString(filePath: string, content: string): string {
|
||||
const absPath = path.join(ROOT, filePath);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, "utf8");
|
||||
try {
|
||||
execSync(`pnpm exec eslint "${absPath}" --no-eslintrc -c eslint.config.js --format json`, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return "PASS";
|
||||
} catch (err: unknown) {
|
||||
const error = err as { stdout?: string };
|
||||
return error.stdout ?? "UNKNOWN_ERROR";
|
||||
} finally {
|
||||
fs.unlinkSync(absPath);
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-restricted-imports rules", () => {
|
||||
it("blocks @opentelemetry/sdk-metrics outside otel.ts", () => {
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'import { MeterProvider } from "@opentelemetry/sdk-metrics";\nexport const x = MeterProvider;\n',
|
||||
);
|
||||
expect(result).toContain("no-restricted-imports");
|
||||
});
|
||||
|
||||
it("blocks react-i18next outside provider.tsx", () => {
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'import { useTranslation } from "react-i18next";\nexport const x = useTranslation;\n',
|
||||
);
|
||||
expect(result).toContain("no-restricted-imports");
|
||||
});
|
||||
|
||||
it("blocks @microsoft/signalr in routes/ (SSR bundle)", () => {
|
||||
const result = lintString(
|
||||
"src/routes/__test_probe__.ts",
|
||||
'import { HubConnectionBuilder } from "@microsoft/signalr";\nexport const x = HubConnectionBuilder;\n',
|
||||
);
|
||||
expect(result).toContain("no-restricted-imports");
|
||||
});
|
||||
|
||||
it("blocks localStorage outside storage.ts", () => {
|
||||
const result = lintString(
|
||||
"src/features/online-board/__test_probe__.ts",
|
||||
'const x = localStorage.getItem("key");\nexport default x;\n',
|
||||
);
|
||||
expect(result).toContain("no-restricted-globals");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the restricted-import tests**
|
||||
|
||||
```bash
|
||||
pnpm test tests/eslint/restricted-imports
|
||||
```
|
||||
Expected: all 4 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/eslint/restricted-imports.test.ts
|
||||
git commit -m "Add fabricated violation tests for restricted import rules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Full exit-gate verification
|
||||
|
||||
- [ ] **Step 1: Quality gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
Expected: typecheck exit 0, lint exit 0, all tests pass (11 existing + 10 new = 21 total).
|
||||
|
||||
- [ ] **Step 2: Verify git status**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
Expected: clean working tree.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1A-3 exports:
|
||||
- `eslint-plugin-boundaries` rules for layered deps → Task 1
|
||||
- `no-restricted-imports`: OTel SDK, react-i18next, @microsoft/signalr SSR, localStorage → Task 2
|
||||
- Fabricated violation tests → Tasks 3, 4
|
||||
|
||||
**Placeholder scan.** No TBD/TODO. All rule configs are concrete.
|
||||
|
||||
**Type consistency.** Element type names (`routes`, `mf`, `features`, `ui`, `shared`, `observability`, `i18n`, `env`) match the `src/` directory names.
|
||||
@@ -0,0 +1,678 @@
|
||||
# Phase 1C — i18n Runtime + Locale Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the i18n runtime — `i18next` configured with ICU support, a URL locale resolver, 9 ported locale JSON files, SSR↔client hydration helpers, and a React context provider — so that downstream sub-plans (1F-layout, Phase 2 features) can render localized text with `t("BOARD.DEPARTURE")` in both SSR and client contexts without re-fetching locale data on hydration.
|
||||
|
||||
**Architecture:** `i18next` with `i18next-icu` as the ICU MessageFormat backend. One namespace (`common`) per language. `src/i18n/resolver.ts` determines the locale from the URL prefix. `src/i18n/config.ts` creates a request-scoped `i18next` instance loaded with one locale's JSON. `src/i18n/serializer.ts` serializes the loaded bundle into the SSR HTML under `window.__I18N__` and rehydrates it client-side. `src/i18n/provider.tsx` wraps React context and re-exports `useTranslation` (the only approved way for feature code to access translations, enforced by 1A-3's `no-restricted-imports` rule on `react-i18next`).
|
||||
|
||||
**Tech Stack:** `i18next@^23`, `react-i18next@^15`, `i18next-icu@^2`, `i18next-resources-to-backend@^1`.
|
||||
|
||||
**Scope boundaries:**
|
||||
- No feature-specific translation namespaces (single `common` namespace for Phase 1).
|
||||
- No date-fns/tz porting — design spec §6.4 date formatting is Phase 2 work.
|
||||
- No `<SeoHead>` — that's 1F-seo.
|
||||
|
||||
**Prerequisites:** 1A-1 + 1A-2 + 1A-3 complete. `src/env/`, `src/host-contract.ts`, ESLint boundaries all in place.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/i18n/resolver.ts` | `Language` type, locale resolution from URL prefix | 2 |
|
||||
| `src/i18n/resolver.test.ts` | Tests for resolver | 2 |
|
||||
| `src/i18n/config.ts` | `createI18nInstance` factory | 3 |
|
||||
| `src/i18n/config.test.ts` | Tests for factory | 3 |
|
||||
| `src/i18n/serializer.ts` | SSR→client hydration helpers | 4 |
|
||||
| `src/i18n/serializer.test.ts` | Roundtrip tests | 4 |
|
||||
| `src/i18n/provider.tsx` | React context + `useTranslation` re-export | 5 |
|
||||
| `src/i18n/locales/{lang}/common.json` (×9) | Ported locale bundles | 1 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install i18n deps and port locale JSON files
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Create: `src/i18n/locales/ru/common.json` (×9 languages)
|
||||
|
||||
- [ ] **Step 1: Install i18n packages**
|
||||
|
||||
```bash
|
||||
pnpm add i18next@^23.0.0 react-i18next@^15.0.0 i18next-icu@^2.0.0 i18next-resources-to-backend@^1.0.0 intl-messageformat@^10.0.0
|
||||
```
|
||||
|
||||
`intl-messageformat` is a peer dep of `i18next-icu`.
|
||||
|
||||
- [ ] **Step 2: Port locale JSON files**
|
||||
|
||||
Copy each Angular locale file to the new i18n directory. The file structure changes from flat (`ClientApp/src/assets/i18n/ru.json`) to nested (`src/i18n/locales/ru/common.json`) to support future per-feature namespaces.
|
||||
|
||||
```bash
|
||||
mkdir -p src/i18n/locales/{ru,en,es,fr,it,ja,ko,zh,de}
|
||||
for lang in ru en es fr it ja ko zh de; do
|
||||
cp "ClientApp/src/assets/i18n/${lang}.json" "src/i18n/locales/${lang}/common.json"
|
||||
done
|
||||
```
|
||||
|
||||
Verify all 9 files copied:
|
||||
|
||||
```bash
|
||||
ls -la src/i18n/locales/*/common.json | wc -l
|
||||
```
|
||||
Expected: 9.
|
||||
|
||||
**BOM removal.** The Angular files may have a UTF-8 BOM marker (`\xEF\xBB\xBF`). Strip it to avoid JSON parse issues:
|
||||
|
||||
```bash
|
||||
for f in src/i18n/locales/*/common.json; do
|
||||
sed -i '' '1s/^\xef\xbb\xbf//' "$f" 2>/dev/null || sed -i '1s/^\xef\xbb\xbf//' "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Verify they parse:
|
||||
|
||||
```bash
|
||||
for f in src/i18n/locales/*/common.json; do
|
||||
node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" && echo "OK: $f"
|
||||
done
|
||||
```
|
||||
Expected: 9 "OK" lines.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml src/i18n/locales/
|
||||
git commit -m "Install i18n deps and port 9 locale JSON files from Angular"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD `src/i18n/resolver.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/resolver.test.ts`
|
||||
- Create: `src/i18n/resolver.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de";
|
||||
export const LANGUAGES: readonly Language[];
|
||||
export function isLanguage(x: string): x is Language;
|
||||
export function resolveLocaleFromPath(pathname: string): Language | null;
|
||||
export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/resolver.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type Language,
|
||||
LANGUAGES,
|
||||
isLanguage,
|
||||
resolveLocaleFromPath,
|
||||
stripLocaleFromPath,
|
||||
} from "./resolver.js";
|
||||
|
||||
describe("LANGUAGES", () => {
|
||||
it("contains exactly 9 supported languages", () => {
|
||||
expect(LANGUAGES).toHaveLength(9);
|
||||
expect(LANGUAGES).toContain("ru");
|
||||
expect(LANGUAGES).toContain("en");
|
||||
expect(LANGUAGES).toContain("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLanguage", () => {
|
||||
it("returns true for valid languages", () => {
|
||||
expect(isLanguage("ru")).toBe(true);
|
||||
expect(isLanguage("en")).toBe(true);
|
||||
expect(isLanguage("zh")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid strings", () => {
|
||||
expect(isLanguage("xx")).toBe(false);
|
||||
expect(isLanguage("")).toBe(false);
|
||||
expect(isLanguage("RU")).toBe(false);
|
||||
expect(isLanguage("russian")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLocaleFromPath", () => {
|
||||
it("extracts locale from the first path segment", () => {
|
||||
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru");
|
||||
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en");
|
||||
expect(resolveLocaleFromPath("/de/schedule")).toBe("de");
|
||||
});
|
||||
|
||||
it("returns null for paths without a valid locale prefix", () => {
|
||||
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
|
||||
expect(resolveLocaleFromPath("/")).toBeNull();
|
||||
expect(resolveLocaleFromPath("")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles bare locale path (e.g., /ru)", () => {
|
||||
expect(resolveLocaleFromPath("/ru")).toBe("ru");
|
||||
expect(resolveLocaleFromPath("/ru/")).toBe("ru");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLocaleFromPath", () => {
|
||||
it("strips locale and returns the rest", () => {
|
||||
expect(stripLocaleFromPath("/ru/onlineboard")).toEqual({
|
||||
locale: "ru",
|
||||
rest: "/onlineboard",
|
||||
});
|
||||
expect(stripLocaleFromPath("/en/onlineboard/flight/SU100")).toEqual({
|
||||
locale: "en",
|
||||
rest: "/onlineboard/flight/SU100",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns / as rest for bare locale path", () => {
|
||||
expect(stripLocaleFromPath("/ru")).toEqual({ locale: "ru", rest: "/" });
|
||||
expect(stripLocaleFromPath("/ru/")).toEqual({ locale: "ru", rest: "/" });
|
||||
});
|
||||
|
||||
it("returns null for paths without a valid locale prefix", () => {
|
||||
expect(stripLocaleFromPath("/onlineboard")).toBeNull();
|
||||
expect(stripLocaleFromPath("/xx/schedule")).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/resolver
|
||||
```
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/resolver.ts`:
|
||||
|
||||
```typescript
|
||||
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
|
||||
|
||||
export const LANGUAGES: readonly Language[] = [
|
||||
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
|
||||
] as const;
|
||||
|
||||
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
|
||||
|
||||
export function isLanguage(x: string): x is Language {
|
||||
return languageSet.has(x);
|
||||
}
|
||||
|
||||
export function resolveLocaleFromPath(pathname: string): Language | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
if (first !== undefined && isLanguage(first)) {
|
||||
return first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stripLocaleFromPath(
|
||||
pathname: string,
|
||||
): { locale: Language; rest: string } | null {
|
||||
const locale = resolveLocaleFromPath(pathname);
|
||||
if (locale === null) return null;
|
||||
|
||||
const rest = pathname.slice(`/${locale}`.length);
|
||||
return {
|
||||
locale,
|
||||
rest: rest === "" || rest === "/" ? "/" : rest,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/resolver
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/resolver.ts src/i18n/resolver.test.ts
|
||||
git commit -m "Add locale resolver with Language type and URL prefix parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD `src/i18n/config.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/config.test.ts`
|
||||
- Create: `src/i18n/config.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export function createI18nInstance(options: {
|
||||
locale: Language;
|
||||
initialResources?: Record<string, Record<string, unknown>>;
|
||||
}): Promise<i18n>;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/config.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createI18nInstance } from "./config.js";
|
||||
|
||||
describe("createI18nInstance", () => {
|
||||
it("creates an initialized i18next instance for the given locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
expect(i18n.language).toBe("ru");
|
||||
expect(i18n.isInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("can translate a known key from the Russian locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
const value = i18n.t("BOARD.DEPARTURE");
|
||||
// The Russian file has "BOARD.DEPARTURE" = "Вылет"
|
||||
expect(value).toBe("Вылет");
|
||||
});
|
||||
|
||||
it("can translate a known key from the English locale", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "en" });
|
||||
const value = i18n.t("BOARD.DEPARTURE");
|
||||
expect(value).toBe("Departure");
|
||||
});
|
||||
|
||||
it("uses initialResources instead of loading from filesystem when provided", async () => {
|
||||
const i18n = await createI18nInstance({
|
||||
locale: "ru",
|
||||
initialResources: {
|
||||
common: { TEST_KEY: "Тестовое значение" },
|
||||
},
|
||||
});
|
||||
expect(i18n.t("TEST_KEY")).toBe("Тестовое значение");
|
||||
});
|
||||
|
||||
it("returns the key path if a key is missing (no fallback to other locale)", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
expect(i18n.t("NONEXISTENT.KEY")).toBe("NONEXISTENT.KEY");
|
||||
});
|
||||
|
||||
it("each call returns a fresh instance (request-scoped)", async () => {
|
||||
const a = await createI18nInstance({ locale: "ru" });
|
||||
const b = await createI18nInstance({ locale: "en" });
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.language).toBe("ru");
|
||||
expect(b.language).toBe("en");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/config
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/config.ts`:
|
||||
|
||||
```typescript
|
||||
import i18next from "i18next";
|
||||
import ICU from "i18next-icu";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import type { Language } from "./resolver.js";
|
||||
|
||||
export async function createI18nInstance(options: {
|
||||
locale: Language;
|
||||
initialResources?: Record<string, Record<string, unknown>>;
|
||||
}): Promise<typeof i18next> {
|
||||
const instance = i18next.createInstance();
|
||||
|
||||
const plugins = [ICU];
|
||||
|
||||
// If no pre-loaded resources, load from the locale JSON files on the filesystem.
|
||||
if (!options.initialResources) {
|
||||
plugins.push(
|
||||
resourcesToBackend(
|
||||
(language: string, namespace: string) =>
|
||||
import(`./locales/${language}/${namespace}.json`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
instance.use(plugin);
|
||||
}
|
||||
|
||||
await instance.init({
|
||||
lng: options.locale,
|
||||
ns: ["common"],
|
||||
defaultNS: "common",
|
||||
fallbackLng: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
keySeparator: ".",
|
||||
...(options.initialResources
|
||||
? {
|
||||
resources: {
|
||||
[options.locale]: options.initialResources,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/config
|
||||
```
|
||||
|
||||
If tests fail because dynamic `import()` of `.json` files doesn't work in vitest's node environment, try setting `vitest.config.ts` to `environment: "node"` (already set) and ensure `resolveJsonModule: true` is in `tsconfig.json` (already set in 1A-1). If it still fails, use `fs.readFileSync` with `JSON.parse` as a fallback for the backend loader:
|
||||
|
||||
```typescript
|
||||
resourcesToBackend(
|
||||
(language: string, namespace: string) => {
|
||||
const data = require(`./locales/${language}/${namespace}.json`);
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/config.ts src/i18n/config.test.ts
|
||||
git commit -m "Add createI18nInstance factory with ICU and resource backend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — TDD `src/i18n/serializer.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/serializer.test.ts`
|
||||
- Create: `src/i18n/serializer.ts`
|
||||
|
||||
**Contract (from master plan §1C):**
|
||||
```ts
|
||||
export function serializeI18nForHydration(i18n: i18n): string; // emits JSON string
|
||||
export function hydrateI18nFromWindow(): Promise<i18n>; // reads window.__I18N__
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/i18n/serializer.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nInstance } from "./config.js";
|
||||
import { serializeI18nForHydration, hydrateI18nFromWindow } from "./serializer.js";
|
||||
|
||||
describe("serializeI18nForHydration", () => {
|
||||
it("returns a JSON string containing locale and resources", async () => {
|
||||
const i18n = await createI18nInstance({ locale: "ru" });
|
||||
const json = serializeI18nForHydration(i18n);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.locale).toBe("ru");
|
||||
expect(parsed.resources).toBeDefined();
|
||||
expect(parsed.resources.common).toBeDefined();
|
||||
expect(parsed.resources.common["BOARD"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateI18nFromWindow", () => {
|
||||
afterEach(() => {
|
||||
// Clean up the global
|
||||
if (typeof globalThis !== "undefined") {
|
||||
delete (globalThis as Record<string, unknown>).__I18N__;
|
||||
}
|
||||
});
|
||||
|
||||
it("roundtrips: serialize → inject into globalThis.__I18N__ → hydrate", async () => {
|
||||
const original = await createI18nInstance({ locale: "ru" });
|
||||
const json = serializeI18nForHydration(original);
|
||||
const payload = JSON.parse(json);
|
||||
|
||||
// Simulate the SSR injection into the global scope
|
||||
(globalThis as Record<string, unknown>).__I18N__ = payload;
|
||||
|
||||
const hydrated = await hydrateI18nFromWindow();
|
||||
expect(hydrated.language).toBe("ru");
|
||||
expect(hydrated.t("BOARD.DEPARTURE")).toBe(original.t("BOARD.DEPARTURE"));
|
||||
});
|
||||
|
||||
it("throws if __I18N__ is not set", async () => {
|
||||
await expect(hydrateI18nFromWindow()).rejects.toThrow(/__I18N__/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/serializer
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/i18n/serializer.ts`:
|
||||
|
||||
```typescript
|
||||
import type i18next from "i18next";
|
||||
import { createI18nInstance } from "./config.js";
|
||||
import { isLanguage } from "./resolver.js";
|
||||
|
||||
interface I18nPayload {
|
||||
locale: string;
|
||||
resources: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the loaded locale bundle from an i18next instance into a JSON
|
||||
* string suitable for injection into the SSR HTML under `window.__I18N__`.
|
||||
*/
|
||||
export function serializeI18nForHydration(i18n: typeof i18next): string {
|
||||
const locale = i18n.language;
|
||||
const resources: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const ns of i18n.options.ns as string[]) {
|
||||
const bundle = i18n.getResourceBundle(locale, ns) as Record<string, unknown> | undefined;
|
||||
if (bundle) {
|
||||
resources[ns] = bundle;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: I18nPayload = { locale, resources };
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrates an i18next instance from the SSR-injected `window.__I18N__` payload.
|
||||
* Called once on the client before React hydration begins.
|
||||
*/
|
||||
export async function hydrateI18nFromWindow(): Promise<typeof i18next> {
|
||||
const raw = (globalThis as Record<string, unknown>).__I18N__ as I18nPayload | undefined;
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
"Cannot hydrate i18n: globalThis.__I18N__ is not set. " +
|
||||
"Ensure the SSR payload includes the serialized i18n data.",
|
||||
);
|
||||
}
|
||||
|
||||
const locale = raw.locale;
|
||||
if (!isLanguage(locale)) {
|
||||
throw new Error(`Cannot hydrate i18n: invalid locale "${locale}" in __I18N__ payload.`);
|
||||
}
|
||||
|
||||
return createI18nInstance({
|
||||
locale,
|
||||
initialResources: raw.resources,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/i18n/serializer
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/serializer.ts src/i18n/serializer.test.ts
|
||||
git commit -m "Add SSR↔client i18n hydration serializer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Create `src/i18n/provider.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/provider.tsx`
|
||||
|
||||
**Contract (from master plan §1C):** React Context provider + `<I18nProvider>` component + `useI18n()` accessor. Re-exports `useTranslation` from `react-i18next` so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule).
|
||||
|
||||
This file does NOT get TDD'd — it's a thin React wrapper with no logic, and testing it requires a render environment that 1F-layout will exercise.
|
||||
|
||||
- [ ] **Step 1: Write `src/i18n/provider.tsx`**
|
||||
|
||||
```tsx
|
||||
import { I18nextProvider, useTranslation as useTranslationOriginal } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import type i18next from "i18next";
|
||||
|
||||
/**
|
||||
* Wraps the i18next provider. All downstream code accesses translations
|
||||
* through this provider and the re-exported hooks below.
|
||||
*/
|
||||
export function I18nProvider({
|
||||
i18n,
|
||||
children,
|
||||
}: {
|
||||
i18n: typeof i18next;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export of react-i18next's useTranslation. Feature code MUST import
|
||||
* from "@/i18n/provider", never from "react-i18next" directly (enforced
|
||||
* by the no-restricted-imports ESLint rule in 1A-3).
|
||||
*/
|
||||
export const useTranslation = useTranslationOriginal;
|
||||
|
||||
/**
|
||||
* Convenience alias for accessing the i18next instance from context.
|
||||
* Same as useTranslation().i18n.
|
||||
*/
|
||||
export function useI18n(): typeof i18next {
|
||||
const { i18n } = useTranslation();
|
||||
return i18n;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
Expected: both exit 0. The `no-restricted-imports` rule allows `react-i18next` in `src/i18n/provider.tsx` (it's in the ignores list set up in 1A-3).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/provider.tsx
|
||||
git commit -m "Add I18nProvider with useTranslation re-export for feature code"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Full exit-gate verification
|
||||
|
||||
- [ ] **Step 1: Quality gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. Test count should be 21 (from 1A) + resolver tests + config tests + serializer tests = ~33+ total.
|
||||
|
||||
- [ ] **Step 2: Verify resolver→config→serializer roundtrip in one shot**
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
(async () => {
|
||||
const { createI18nInstance } = await import('./src/i18n/config.js');
|
||||
const { serializeI18nForHydration } = await import('./src/i18n/serializer.js');
|
||||
const i18n = await createI18nInstance({ locale: 'ru' });
|
||||
console.log('t(BOARD.DEPARTURE):', i18n.t('BOARD.DEPARTURE'));
|
||||
const json = serializeI18nForHydration(i18n);
|
||||
const payload = JSON.parse(json);
|
||||
console.log('Serialized locale:', payload.locale);
|
||||
console.log('Serialized keys sample:', Object.keys(payload.resources.common).slice(0,5));
|
||||
console.log('ROUNDTRIP OK');
|
||||
})();
|
||||
" 2>&1 || echo "Node roundtrip failed — check module resolution"
|
||||
```
|
||||
|
||||
Expected: prints `t(BOARD.DEPARTURE): Вылет`, locale `ru`, some key names, and `ROUNDTRIP OK`.
|
||||
|
||||
- [ ] **Step 3: Verify git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1C exports:
|
||||
- `createI18nInstance` factory → Task 3
|
||||
- `Language` type + resolver functions → Task 2
|
||||
- 9 locale JSON files → Task 1
|
||||
- `serializeI18nForHydration` + `hydrateI18nFromWindow` → Task 4
|
||||
- `I18nProvider` + `useTranslation` re-export + `useI18n` → Task 5
|
||||
|
||||
**Placeholder scan.** No TBD/TODO. All code blocks are complete.
|
||||
|
||||
**Type consistency.**
|
||||
- `Language` type defined in resolver.ts, imported by config.ts, serializer.ts. Same shape everywhere.
|
||||
- `createI18nInstance` returns `Promise<typeof i18next>`. Consistent in config.ts, serializer.ts, and tests.
|
||||
- `i18next-icu` plugin loaded in config.ts — matches master plan 1C dependency list.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
# Phase 1E -- SignalR wrapper contracts
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver a reference-counted SignalR connection wrapper and a generic SSR-safe React hook (`useLiveFlights`) that subscribes to live flight data channels. All SignalR imports are dynamic so they never enter the SSR bundle.
|
||||
|
||||
## Deliverables
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/shared/signalr/connection.ts` | `SignalRConnection` class, `getSharedConnection`, `ConnectionStatus` type, `HubOptions` interface |
|
||||
| `src/shared/signalr/connection.test.ts` | Vitest tests for connection lifecycle, ref-counting, grace period, status changes |
|
||||
| `src/shared/hooks/useLiveFlights.ts` | Generic live-data hook wrapping SignalR subscription |
|
||||
| `src/shared/hooks/useLiveFlights.test.ts` | Vitest tests for hook behavior including SSR path |
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1 -- Install `@microsoft/signalr`
|
||||
|
||||
Add `@microsoft/signalr` to the root `package.json` dependencies.
|
||||
|
||||
**Commit:** "Add @microsoft/signalr dependency"
|
||||
|
||||
### Task 2 -- `SignalRConnection` class + tests
|
||||
|
||||
Create `src/shared/signalr/connection.ts` with:
|
||||
|
||||
- `HubOptions` interface: `hubUrl`, `reconnectDelaysMs` (default `[0, 2000, 10000, 30000]`), `gracePeriodMs` (default `5000`)
|
||||
- `ConnectionStatus` type: `"idle" | "connecting" | "live" | "reconnecting" | "offline"`
|
||||
- `SignalRConnection` class:
|
||||
- Constructor takes `HubOptions`, uses dynamic `import("@microsoft/signalr")` to build connection
|
||||
- `subscribe(channel, handler)` returns unsubscribe fn; increments ref count; starts connection on first subscriber
|
||||
- `onStatusChange(handler)` returns unsubscribe fn
|
||||
- `get status()` returns current `ConnectionStatus`
|
||||
- Reference counting: connection closes after last unsubscribe + grace period elapses
|
||||
- `getSharedConnection(options)` -- singleton map keyed by `hubUrl`
|
||||
|
||||
Create `src/shared/signalr/connection.test.ts` with tests:
|
||||
|
||||
1. Two rapid subscribes produce exactly one `HubConnection.start()` call
|
||||
2. Unmount + remount within grace period reuses connection (no second `start()`)
|
||||
3. Unmount + remount after grace period creates fresh connection
|
||||
4. Status transitions fire `onStatusChange` handlers
|
||||
5. `getSharedConnection` returns same instance for same `hubUrl`
|
||||
|
||||
**Commit:** "Add SignalRConnection ref-counted wrapper with tests"
|
||||
|
||||
### Task 3 -- `useLiveFlights` hook + tests
|
||||
|
||||
Create `src/shared/hooks/useLiveFlights.ts`:
|
||||
|
||||
- Generic `useLiveFlights<TParams, TData>(params, initialData, config)` hook
|
||||
- SSR-safe: checks `typeof window !== "undefined"` before subscribing
|
||||
- Uses `useEffect` for subscribe/unsubscribe lifecycle
|
||||
- Returns `{ data: TData[]; connectionStatus: ConnectionStatus }`
|
||||
|
||||
Create `src/shared/hooks/useLiveFlights.test.ts`:
|
||||
|
||||
1. SSR path returns `initialData` without importing `@microsoft/signalr`
|
||||
2. Client path subscribes to the correct channel
|
||||
3. Data updates when messages arrive
|
||||
4. Cleanup unsubscribes on unmount
|
||||
|
||||
**Commit:** "Add useLiveFlights SSR-safe hook with tests"
|
||||
|
||||
### Task 4 -- Verification
|
||||
|
||||
- `pnpm typecheck && pnpm lint && pnpm test`
|
||||
- Confirm ESLint SSR-bundle guard still blocks `@microsoft/signalr` in `src/routes/`
|
||||
|
||||
**Commit:** none (verification only)
|
||||
|
||||
## Exit gate
|
||||
|
||||
- Two rapid `useEffect` mounts (Strict Mode simulation) result in exactly one `HubConnection.start()` call
|
||||
- Unmount + remount within grace period reuses connection
|
||||
- Unmount + remount after grace period creates fresh connection
|
||||
- SSR render path does not import `@microsoft/signalr`
|
||||
- `pnpm typecheck && pnpm lint && pnpm test` all green
|
||||
@@ -0,0 +1,262 @@
|
||||
# Phase 1F-layout — Root layout + routes + error mapper contracts
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the root layout provider stack, locale-scoped layout, error boundary, error-to-HTTP mapper, error pages, and smoke route — so that all downstream feature routes render inside a fully-wired provider tree (`LoggerProvider` > `ApiClientProvider` > `I18nProvider` > `ErrorBoundary`) and error handling works end-to-end.
|
||||
|
||||
**Architecture:** Modern.js file-based routing. `src/routes/layout.tsx` wraps children with the global providers (Logger, ApiClient, ErrorBoundary). `src/routes/[lang]/layout.tsx` validates the `lang` param, creates a request-scoped i18n instance, and wraps children with `<I18nProvider>`. Error pages live at `src/routes/error/[code]/page.tsx`. The smoke route at `src/routes/[lang]/smoke/page.tsx` exercises logger, i18n, and locale display.
|
||||
|
||||
**Tech Stack:** React 18, Modern.js SSR, i18next, Vitest.
|
||||
|
||||
**Prerequisites:** 1A-1 (skeleton), 1A-2 (MF builds), 1C (i18n), 1D (API client), 1G-logger (logger).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/ui/errors/ErrorBoundary.tsx` | React error boundary with retry | 1 |
|
||||
| `src/routes/error/map.ts` | `errorToResponse()` mapper | 2 |
|
||||
| `src/routes/error/map.test.ts` | TDD tests for mapper | 2 |
|
||||
| `src/routes/layout.tsx` | Root layout with provider stack | 3 |
|
||||
| `src/routes/[lang]/layout.tsx` | Locale-scoped layout | 3 |
|
||||
| `src/routes/error/[code]/page.tsx` | Error page (404, 500, 503) | 4 |
|
||||
| `src/i18n/locales/en/common.json` | Add SMOKE keys | 5 |
|
||||
| `src/i18n/locales/ru/common.json` | Add SMOKE keys | 5 |
|
||||
| `src/routes/[lang]/smoke/page.tsx` | Smoke route | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — ErrorBoundary component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ui/errors/ErrorBoundary.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the ErrorBoundary class component**
|
||||
|
||||
The ErrorBoundary must be a class component (React requirement for `componentDidCatch`). It catches errors in its subtree, renders a fallback UI with a "Retry" button that resets the boundary state.
|
||||
|
||||
```tsx
|
||||
// src/ui/errors/ErrorBoundary.tsx
|
||||
import { Component } from "react";
|
||||
import type { ReactNode, ErrorInfo } from "react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("[ErrorBoundary]", error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div role="alert">
|
||||
<h2>Something went wrong</h2>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button type="button" onClick={this.handleRetry}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ui/errors/ErrorBoundary.tsx
|
||||
git commit -m "Add ErrorBoundary class component with retry support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD errorToResponse mapper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/error/map.ts`
|
||||
- Create: `src/routes/error/map.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Tests cover all four mapping rules:
|
||||
1. `ApiHttpError(404)` -> `{ status: 404, errorCode: "not_found" }`
|
||||
2. `ApiHttpError(502)` -> `{ status: 500, errorCode: "internal" }`
|
||||
3. `ApiTimeoutError` -> `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }`
|
||||
4. Unknown error -> `{ status: 500, errorCode: "internal" }`
|
||||
|
||||
- [ ] **Step 2: Write the implementation**
|
||||
|
||||
```ts
|
||||
export interface ErrorResponse {
|
||||
status: 404 | 500 | 503;
|
||||
headers?: Record<string, string>;
|
||||
errorCode: "not_found" | "internal" | "unavailable";
|
||||
}
|
||||
|
||||
export function errorToResponse(error: unknown): ErrorResponse;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests — all must pass**
|
||||
|
||||
```bash
|
||||
pnpm test -- src/routes/error/map.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/error/map.ts src/routes/error/map.test.ts
|
||||
git commit -m "Add errorToResponse mapper with TDD tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Root layout + locale-scoped layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/layout.tsx` (replace 1A-2 stub)
|
||||
- Create: `src/routes/[lang]/layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Update root layout**
|
||||
|
||||
Replace the stub with the real provider stack:
|
||||
- `<LoggerProvider>` wrapping everything (logger from `createRootLogger()`)
|
||||
- `<ApiClientProvider>` with a default-locale ApiClient
|
||||
- `<ErrorBoundary>` wrapping children
|
||||
|
||||
- [ ] **Step 2: Create locale-scoped layout**
|
||||
|
||||
`src/routes/[lang]/layout.tsx`:
|
||||
- Validate `params.lang` using `isLanguage()` from `@/i18n/resolver`
|
||||
- If invalid lang, redirect to `/ru/` (or render 404)
|
||||
- Create i18n instance via `createI18nInstance({ locale: params.lang })`
|
||||
- Wrap children with `<I18nProvider>`
|
||||
|
||||
- [ ] **Step 3: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/layout.tsx src/routes/\[lang\]/layout.tsx
|
||||
git commit -m "Wire root layout provider stack and locale-scoped layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Error pages
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/error/[code]/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Create error page component**
|
||||
|
||||
Simple text-based UI for codes 404, 500, 503. Renders heading, description, and a link back to home. No design system dependency.
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/error/\[code\]/page.tsx
|
||||
git commit -m "Add error pages for 404, 500, 503 codes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Smoke route + i18n keys
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/locales/en/common.json` (add SMOKE keys)
|
||||
- Modify: `src/i18n/locales/ru/common.json` (add SMOKE keys)
|
||||
- Create: `src/routes/[lang]/smoke/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add SMOKE i18n keys**
|
||||
|
||||
Add to `en/common.json`:
|
||||
```json
|
||||
"SMOKE": {
|
||||
"HEADING": "Smoke test page"
|
||||
}
|
||||
```
|
||||
|
||||
Add to `ru/common.json`:
|
||||
```json
|
||||
"SMOKE": {
|
||||
"HEADING": "Страница проверки"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create smoke page**
|
||||
|
||||
`src/routes/[lang]/smoke/page.tsx`:
|
||||
- Uses `useTranslation()` to render `t("SMOKE.HEADING")`
|
||||
- Uses `useLogger()` to emit an info log on mount (via `useEffect`)
|
||||
- Displays the current locale from the URL params
|
||||
|
||||
- [ ] **Step 3: Verify typecheck + lint + test**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
pnpm build:standalone
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/locales/en/common.json src/i18n/locales/ru/common.json src/routes/\[lang\]/smoke/page.tsx
|
||||
git commit -m "Add smoke route exercising logger, i18n, and locale display"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] `pnpm typecheck && pnpm lint && pnpm test` — all pass
|
||||
- [ ] `pnpm build:standalone` — succeeds
|
||||
- [ ] `src/routes/[lang]/smoke/page.tsx` exists
|
||||
- [ ] `src/ui/errors/ErrorBoundary.tsx` exists
|
||||
- [ ] `src/routes/error/map.ts` exists with `errorToResponse()` exported
|
||||
- [ ] `src/routes/error/[code]/page.tsx` exists
|
||||
- [ ] `src/routes/layout.tsx` wraps children with LoggerProvider, ApiClientProvider, ErrorBoundary
|
||||
- [ ] `src/routes/[lang]/layout.tsx` validates lang and provides I18nProvider
|
||||
@@ -0,0 +1,493 @@
|
||||
# Phase 1F-seo — SeoHead + hreflang + JsonLdRenderer Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the SEO infrastructure — `buildHreflangSet` for 9 languages + x-default, `JsonLdRenderer` with `schema-dts` typing, and `SeoHead` component emitting the full `<head>` shape (title, meta, canonical, hreflang, OG, Twitter, JSON-LD) — so that 1F-layout and all downstream features can render SEO-complete pages with `<SeoHead title={...} hreflang={buildHreflangSet(...)} jsonLd={data} />`.
|
||||
|
||||
**Architecture:** Pure functions and thin React components with no runtime dependencies on 1C/1D/1G. `hreflang.ts` builds the 9-language + x-default link set. `json-ld.tsx` serializes `schema-dts` `Thing` objects into safe `<script type="application/ld+json">` blocks. `SeoHead.tsx` composes both into a single `<head>` fragment. The `Language` type is imported from `@/i18n/resolver` (seeded in 1C).
|
||||
|
||||
**Tech Stack:** `schema-dts` (Google's Schema.org TypeScript definitions).
|
||||
|
||||
**Prerequisites:** 1A-1 (skeleton), 1A-3 (ESLint boundaries), 1C (Language type from `@/i18n/resolver`).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/shared/seo/hreflang.ts` | `buildHreflangSet` function | 2 |
|
||||
| `src/shared/seo/hreflang.test.ts` | Tests | 2 |
|
||||
| `src/shared/seo/json-ld.tsx` | `JsonLdRenderer` + `serializeJsonLd` | 3 |
|
||||
| `src/shared/seo/json-ld.test.ts` | Tests | 3 |
|
||||
| `src/ui/seo/SeoHead.tsx` | `<SeoHead>` component | 4 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install `schema-dts` dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Install**
|
||||
|
||||
```bash
|
||||
pnpm add schema-dts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml
|
||||
git commit -m "Add schema-dts dependency for typed JSON-LD generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD `hreflang.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/seo/hreflang.ts`
|
||||
- Create: `src/shared/seo/hreflang.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/shared/seo/hreflang.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHreflangSet } from "./hreflang.js";
|
||||
|
||||
describe("buildHreflangSet", () => {
|
||||
const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const;
|
||||
|
||||
it("returns entries for all 9 languages plus x-default", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(10); // 9 languages + x-default
|
||||
});
|
||||
|
||||
it("includes all 9 languages", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "/smoke",
|
||||
});
|
||||
|
||||
const langs = result.map((entry) => entry.lang);
|
||||
for (const lang of LANGUAGES) {
|
||||
expect(langs).toContain(lang);
|
||||
}
|
||||
});
|
||||
|
||||
it("x-default points to the ru variant", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "/smoke",
|
||||
});
|
||||
|
||||
const xDefault = result.find((entry) => entry.lang === "x-default");
|
||||
expect(xDefault).toBeDefined();
|
||||
expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke");
|
||||
});
|
||||
|
||||
it("builds correct href for each language", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "/onlineboard",
|
||||
});
|
||||
|
||||
const en = result.find((entry) => entry.lang === "en");
|
||||
expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard");
|
||||
|
||||
const ja = result.find((entry) => entry.lang === "ja");
|
||||
expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard");
|
||||
});
|
||||
|
||||
it("preserves paths with nested segments", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
|
||||
});
|
||||
|
||||
const fr = result.find((entry) => entry.lang === "fr");
|
||||
expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15");
|
||||
});
|
||||
|
||||
it("handles root path", () => {
|
||||
const result = buildHreflangSet({
|
||||
canonicalOrigin: "https://www.aeroflot.ru",
|
||||
pathWithoutLocale: "",
|
||||
});
|
||||
|
||||
const ru = result.find((entry) => entry.lang === "ru");
|
||||
expect(ru?.href).toBe("https://www.aeroflot.ru/ru");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/shared/seo/hreflang
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/shared/seo/hreflang.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Language } from "@/i18n/resolver";
|
||||
|
||||
const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"];
|
||||
const X_DEFAULT_LANGUAGE: Language = "ru";
|
||||
|
||||
export interface HreflangEntry {
|
||||
lang: Language | "x-default";
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full set of reciprocal hreflang links for a given path.
|
||||
* Returns 9 language entries + 1 x-default entry (pointing to ru).
|
||||
*/
|
||||
export function buildHreflangSet(args: {
|
||||
canonicalOrigin: string;
|
||||
pathWithoutLocale: string;
|
||||
}): HreflangEntry[] {
|
||||
const { canonicalOrigin, pathWithoutLocale } = args;
|
||||
|
||||
const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({
|
||||
lang,
|
||||
href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`,
|
||||
}));
|
||||
|
||||
entries.push({
|
||||
lang: "x-default",
|
||||
href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`,
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/shared/seo/hreflang
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/shared/seo/hreflang.ts src/shared/seo/hreflang.test.ts
|
||||
git commit -m "Add buildHreflangSet for 9 languages + x-default"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD `json-ld.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/seo/json-ld.tsx`
|
||||
- Create: `src/shared/seo/json-ld.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/shared/seo/json-ld.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createElement } from "react";
|
||||
import type { Thing } from "schema-dts";
|
||||
import { JsonLdRenderer, serializeJsonLd } from "./json-ld.js";
|
||||
|
||||
describe("serializeJsonLd", () => {
|
||||
it("serializes a single Thing to a JSON-LD string", () => {
|
||||
const data: Thing = {
|
||||
"@type": "WebSite",
|
||||
name: "Aeroflot",
|
||||
url: "https://www.aeroflot.ru",
|
||||
};
|
||||
|
||||
const result = serializeJsonLd(data);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed["@context"]).toBe("https://schema.org");
|
||||
expect(parsed["@type"]).toBe("WebSite");
|
||||
expect(parsed.name).toBe("Aeroflot");
|
||||
});
|
||||
|
||||
it("serializes an array of Things with @context on each", () => {
|
||||
const data: Thing[] = [
|
||||
{ "@type": "WebSite", name: "Aeroflot" } as Thing,
|
||||
{ "@type": "Organization", name: "Aeroflot PJSC" } as Thing,
|
||||
];
|
||||
|
||||
const result = serializeJsonLd(data);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0]["@context"]).toBe("https://schema.org");
|
||||
expect(parsed[1]["@context"]).toBe("https://schema.org");
|
||||
});
|
||||
|
||||
it("escapes </script> to prevent injection", () => {
|
||||
const data: Thing = {
|
||||
"@type": "WebSite",
|
||||
name: '</script><script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
const result = serializeJsonLd(data);
|
||||
expect(result).not.toContain("</script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonLdRenderer", () => {
|
||||
it("renders a <script type=application/ld+json> tag", () => {
|
||||
const data: Thing = {
|
||||
"@type": "WebSite",
|
||||
name: "Aeroflot",
|
||||
url: "https://www.aeroflot.ru",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
|
||||
|
||||
expect(html).toContain('<script type="application/ld+json">');
|
||||
expect(html).toContain("</script>");
|
||||
expect(html).toContain('"@context":"https://schema.org"');
|
||||
expect(html).toContain('"@type":"WebSite"');
|
||||
});
|
||||
|
||||
it("round-trips: serialize → DOM string contains valid JSON-LD", () => {
|
||||
const data: Thing = {
|
||||
"@type": "Organization",
|
||||
name: "Aeroflot PJSC",
|
||||
url: "https://www.aeroflot.ru",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
|
||||
|
||||
// Extract JSON from the script tag
|
||||
const match = html.match(/<script[^>]*>([\s\S]*?)<\/script>/);
|
||||
expect(match).not.toBeNull();
|
||||
|
||||
const json = match![1]!.replace(/\\u003c/g, "<");
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed["@context"]).toBe("https://schema.org");
|
||||
expect(parsed["@type"]).toBe("Organization");
|
||||
expect(parsed.name).toBe("Aeroflot PJSC");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/shared/seo/json-ld
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/shared/seo/json-ld.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { Thing } from "schema-dts";
|
||||
|
||||
export interface JsonLdRendererProps {
|
||||
data: Thing | Thing[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a schema-dts Thing (or array of Things) to a JSON-LD string.
|
||||
* Adds "@context": "https://schema.org" to each item.
|
||||
* Escapes </script> sequences to prevent XSS.
|
||||
*/
|
||||
export function serializeJsonLd(data: Thing | Thing[]): string {
|
||||
const withContext = Array.isArray(data)
|
||||
? data.map((item) => ({ "@context": "https://schema.org" as const, ...item }))
|
||||
: { "@context": "https://schema.org" as const, ...data };
|
||||
|
||||
return JSON.stringify(withContext).replace(/<\//g, "\\u003c/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a <script type="application/ld+json"> block with the serialized
|
||||
* JSON-LD data. Safe for SSR — the content is escaped against script injection.
|
||||
*/
|
||||
export function JsonLdRenderer({ data }: JsonLdRendererProps): JSX.Element {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/shared/seo/json-ld
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/shared/seo/json-ld.tsx src/shared/seo/json-ld.test.ts
|
||||
git commit -m "Add JsonLdRenderer and serializeJsonLd with schema-dts typing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Create `SeoHead.tsx` (no TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ui/seo/SeoHead.tsx`
|
||||
|
||||
No TDD — thin React component assembling head tags. Tested by 1F-layout integration.
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/ui/seo/SeoHead.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { Language } from "@/i18n/resolver";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import type { Thing } from "schema-dts";
|
||||
|
||||
export interface SeoHeadProps {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical: string;
|
||||
hreflang: Array<{ lang: Language | "x-default"; href: string }>;
|
||||
og: {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
image: string;
|
||||
type: "website" | "article";
|
||||
locale: string;
|
||||
siteName: string;
|
||||
};
|
||||
twitter?: {
|
||||
card: "summary" | "summary_large_image";
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
};
|
||||
jsonLd?: Thing | Thing[];
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the full SEO <head> fragment: title, meta description, canonical,
|
||||
* hreflang alternates, Open Graph tags, Twitter Card tags, and JSON-LD.
|
||||
*/
|
||||
export function SeoHead({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflang,
|
||||
og,
|
||||
twitter,
|
||||
jsonLd,
|
||||
noindex,
|
||||
}: SeoHeadProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{noindex && <meta name="robots" content="noindex,nofollow" />}
|
||||
|
||||
{/* Hreflang alternates */}
|
||||
{hreflang.map((entry) => (
|
||||
<link
|
||||
key={entry.lang}
|
||||
rel="alternate"
|
||||
hrefLang={entry.lang}
|
||||
href={entry.href}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={og.title} />
|
||||
<meta property="og:description" content={og.description} />
|
||||
<meta property="og:url" content={og.url} />
|
||||
<meta property="og:image" content={og.image} />
|
||||
<meta property="og:type" content={og.type} />
|
||||
<meta property="og:locale" content={og.locale} />
|
||||
<meta property="og:site_name" content={og.siteName} />
|
||||
|
||||
{/* Twitter Card */}
|
||||
{twitter && (
|
||||
<>
|
||||
<meta name="twitter:card" content={twitter.card} />
|
||||
{twitter.title && <meta name="twitter:title" content={twitter.title} />}
|
||||
{twitter.description && <meta name="twitter:description" content={twitter.description} />}
|
||||
{twitter.image && <meta name="twitter:image" content={twitter.image} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* JSON-LD */}
|
||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/ui/seo/SeoHead.tsx
|
||||
git commit -m "Add SeoHead component with canonical, hreflang, OG, Twitter, and JSON-LD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Exit-gate verification
|
||||
|
||||
- [ ] **Step 1: All gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. hreflang tests cover 9 languages + x-default. JsonLdRenderer round-trips through serialize to DOM string.
|
||||
|
||||
- [ ] **Step 2: Git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1F-seo:
|
||||
- `buildHreflangSet` with 9 languages + `x-default` (x-default → ru) → Task 2
|
||||
- `JsonLdRenderer` + `serializeJsonLd` with `schema-dts` `Thing` type → Task 3
|
||||
- `SeoHead` with title, meta description, canonical, hreflang links, OG tags, Twitter Card, JSON-LD, noindex → Task 4
|
||||
- XSS protection via `</script>` escaping in `serializeJsonLd` → Task 3
|
||||
|
||||
**Exit gate alignment:**
|
||||
- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests
|
||||
- "SeoHead emits the full <head> shape" — Task 4 component (tested by 1F-layout integration)
|
||||
- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests
|
||||
|
||||
**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly.
|
||||
@@ -0,0 +1,600 @@
|
||||
# Phase 1G-analytics — Analytics Facade + Stub Adapters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the analytics facade — a test-observable event sink, four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace), a `createAnalytics()` factory that fans out `track`/`page` calls to enabled adapters with consent gating, plus the `<AnalyticsLoader>` component and `useAnalytics()` hook — so that 1F-layout and all downstream features can emit analytics events with `analytics.track("search.submit", { query })`.
|
||||
|
||||
**Architecture:** `types.ts` is already seeded (1A-1) with `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, and `AnalyticsAdapter`. Each stub adapter emits `AnalyticsEvent` records to a shared sink (`sink.ts`) for test observability. `facade.ts` accepts enabled providers + consent flags and fans out to adapters. In production, the sink is a no-op ring buffer; in test, `getRecordedEvents()` / `resetEvents()` allow assertions. Real vendor scripts replace the stubs in Phase 2A after A7 resolves.
|
||||
|
||||
**Tech Stack:** No new dependencies. Stubs use no vendor SDKs.
|
||||
|
||||
**Prerequisites:** 1A-1 (skeleton + types.ts seeded), 1A-3 (ESLint boundaries), 1G-logger (Logger types for facade logging).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/observability/analytics/sink.ts` | Test-observable event sink | 1 |
|
||||
| `src/observability/analytics/sink.test.ts` | Tests | 1 |
|
||||
| `src/observability/analytics/adapters/metrica.ts` | Yandex.Metrica stub adapter | 2 |
|
||||
| `src/observability/analytics/adapters/ctm.ts` | CTM stub adapter | 2 |
|
||||
| `src/observability/analytics/adapters/variocube.ts` | Variocube stub adapter | 2 |
|
||||
| `src/observability/analytics/adapters/dynatrace.ts` | Dynatrace stub adapter | 2 |
|
||||
| `src/observability/analytics/facade.ts` | `createAnalytics()` factory | 3 |
|
||||
| `src/observability/analytics/facade.test.ts` | Tests | 3 |
|
||||
| `src/observability/analytics/loader.tsx` | `<AnalyticsLoader>` component | 4 |
|
||||
| `src/observability/analytics/provider.tsx` | `useAnalytics()` hook | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — TDD `sink.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/analytics/sink.ts`
|
||||
- Create: `src/observability/analytics/sink.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/analytics/sink.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js";
|
||||
import type { AnalyticsEvent } from "./types.js";
|
||||
|
||||
describe("analytics sink", () => {
|
||||
beforeEach(() => {
|
||||
resetEvents();
|
||||
});
|
||||
|
||||
it("records emitted events", () => {
|
||||
const event: AnalyticsEvent = {
|
||||
kind: "track",
|
||||
name: "test.click",
|
||||
props: { button: "cta" },
|
||||
provider: "metrica",
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
|
||||
emitEvent(event);
|
||||
expect(getRecordedEvents()).toHaveLength(1);
|
||||
expect(getRecordedEvents()[0]).toEqual(event);
|
||||
});
|
||||
|
||||
it("records multiple events in order", () => {
|
||||
emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" });
|
||||
emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" });
|
||||
|
||||
const events = getRecordedEvents();
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]?.name).toBe("a");
|
||||
expect(events[1]?.name).toBe("/home");
|
||||
});
|
||||
|
||||
it("resetEvents clears all recorded events", () => {
|
||||
emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" });
|
||||
expect(getRecordedEvents()).toHaveLength(1);
|
||||
resetEvents();
|
||||
expect(getRecordedEvents()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/analytics/sink
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/analytics/sink.ts`:
|
||||
|
||||
```typescript
|
||||
import type { AnalyticsEvent } from "./types.js";
|
||||
|
||||
let events: AnalyticsEvent[] = [];
|
||||
|
||||
/**
|
||||
* Emit an analytics event to the test-observable sink.
|
||||
* In production, this is a no-op ring buffer (capped to prevent memory leaks).
|
||||
* In test, events are retained for assertion via getRecordedEvents().
|
||||
*/
|
||||
export function emitEvent(event: AnalyticsEvent): void {
|
||||
events.push(event);
|
||||
|
||||
// Ring buffer: cap at 1000 events to prevent unbounded growth
|
||||
if (events.length > 1000) {
|
||||
events = events.slice(-500);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all recorded events (for test assertions). */
|
||||
export function getRecordedEvents(): readonly AnalyticsEvent[] {
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Clears all recorded events (for test teardown). */
|
||||
export function resetEvents(): void {
|
||||
events = [];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/analytics/sink
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/analytics/sink.ts src/observability/analytics/sink.test.ts
|
||||
git commit -m "Add test-observable analytics event sink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Create 4 stub adapters (no TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/analytics/adapters/metrica.ts`
|
||||
- Create: `src/observability/analytics/adapters/ctm.ts`
|
||||
- Create: `src/observability/analytics/adapters/variocube.ts`
|
||||
- Create: `src/observability/analytics/adapters/dynatrace.ts`
|
||||
|
||||
- [ ] **Step 1: Write all four adapters**
|
||||
|
||||
Each adapter follows the same pattern. Create `src/observability/analytics/adapters/metrica.ts`:
|
||||
|
||||
```typescript
|
||||
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
|
||||
import { emitEvent } from "../sink.js";
|
||||
|
||||
export class MetricaAdapter implements AnalyticsAdapter {
|
||||
readonly name = "metrica";
|
||||
|
||||
async load(): Promise<void> {
|
||||
// Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves)
|
||||
}
|
||||
|
||||
track(event: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
|
||||
page(url: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/observability/analytics/adapters/ctm.ts`:
|
||||
|
||||
```typescript
|
||||
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
|
||||
import { emitEvent } from "../sink.js";
|
||||
|
||||
export class CtmAdapter implements AnalyticsAdapter {
|
||||
readonly name = "ctm";
|
||||
|
||||
async load(): Promise<void> {
|
||||
// Stub: real CTM script loads in Phase 2A
|
||||
}
|
||||
|
||||
track(event: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
|
||||
page(url: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/observability/analytics/adapters/variocube.ts`:
|
||||
|
||||
```typescript
|
||||
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
|
||||
import { emitEvent } from "../sink.js";
|
||||
|
||||
export class VariocubeAdapter implements AnalyticsAdapter {
|
||||
readonly name = "variocube";
|
||||
|
||||
async load(): Promise<void> {
|
||||
// Stub: real Variocube script loads in Phase 2A
|
||||
}
|
||||
|
||||
track(event: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
|
||||
page(url: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/observability/analytics/adapters/dynatrace.ts`:
|
||||
|
||||
```typescript
|
||||
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
|
||||
import { emitEvent } from "../sink.js";
|
||||
|
||||
export class DynatraceAdapter implements AnalyticsAdapter {
|
||||
readonly name = "dynatrace";
|
||||
|
||||
async load(): Promise<void> {
|
||||
// Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A
|
||||
}
|
||||
|
||||
track(event: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
|
||||
page(url: string, props: AnalyticsProps = {}): void {
|
||||
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/analytics/adapters/
|
||||
git commit -m "Add four stub analytics adapters (metrica, ctm, variocube, dynatrace)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD `facade.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/analytics/facade.ts`
|
||||
- Create: `src/observability/analytics/facade.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/analytics/facade.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { createAnalytics } from "./facade.js";
|
||||
import { getRecordedEvents, resetEvents } from "./sink.js";
|
||||
import type { Logger } from "@/observability/logger/types";
|
||||
|
||||
function mockLogger(): Logger {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn(() => mockLogger()),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAnalytics", () => {
|
||||
beforeEach(() => {
|
||||
resetEvents();
|
||||
});
|
||||
|
||||
it("fans out track() to all 4 enabled adapters", () => {
|
||||
const analytics = createAnalytics({
|
||||
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
|
||||
consent: { analytics: true, telemetry: true },
|
||||
logger: mockLogger(),
|
||||
});
|
||||
|
||||
analytics.track("test.event", { key: "value" });
|
||||
|
||||
const events = getRecordedEvents();
|
||||
expect(events).toHaveLength(4);
|
||||
|
||||
const providers = events.map((e) => e.provider).sort();
|
||||
expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]);
|
||||
|
||||
for (const event of events) {
|
||||
expect(event.kind).toBe("track");
|
||||
expect(event.name).toBe("test.event");
|
||||
expect(event.props).toEqual({ key: "value" });
|
||||
}
|
||||
});
|
||||
|
||||
it("fans out page() to all 4 enabled adapters", () => {
|
||||
const analytics = createAnalytics({
|
||||
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
|
||||
consent: { analytics: true, telemetry: true },
|
||||
logger: mockLogger(),
|
||||
});
|
||||
|
||||
analytics.page("/ru/online-board");
|
||||
|
||||
const events = getRecordedEvents();
|
||||
expect(events).toHaveLength(4);
|
||||
|
||||
for (const event of events) {
|
||||
expect(event.kind).toBe("page");
|
||||
expect(event.name).toBe("/ru/online-board");
|
||||
}
|
||||
});
|
||||
|
||||
it("consent.analytics = false short-circuits before any adapter is invoked", () => {
|
||||
const analytics = createAnalytics({
|
||||
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
|
||||
consent: { analytics: false, telemetry: true },
|
||||
logger: mockLogger(),
|
||||
});
|
||||
|
||||
analytics.track("should.not.emit");
|
||||
analytics.page("/should/not/emit");
|
||||
|
||||
expect(getRecordedEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("disabled adapter is not invoked", () => {
|
||||
const analytics = createAnalytics({
|
||||
enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true },
|
||||
consent: { analytics: true, telemetry: true },
|
||||
logger: mockLogger(),
|
||||
});
|
||||
|
||||
analytics.track("partial.event");
|
||||
|
||||
const events = getRecordedEvents();
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
const providers = events.map((e) => e.provider).sort();
|
||||
expect(providers).toEqual(["dynatrace", "metrica"]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/analytics/facade
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/analytics/facade.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js";
|
||||
import type { Logger } from "@/observability/logger/types";
|
||||
import { MetricaAdapter } from "./adapters/metrica.js";
|
||||
import { CtmAdapter } from "./adapters/ctm.js";
|
||||
import { VariocubeAdapter } from "./adapters/variocube.js";
|
||||
import { DynatraceAdapter } from "./adapters/dynatrace.js";
|
||||
|
||||
export interface CreateAnalyticsOptions {
|
||||
enabled: AnalyticsProviders;
|
||||
consent: { analytics: boolean; telemetry: boolean };
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
const NOOP_ANALYTICS: Analytics = {
|
||||
track() {},
|
||||
page() {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an Analytics instance that fans out track/page calls to enabled adapters.
|
||||
* If consent.analytics is false, returns a no-op (short-circuit before any adapter).
|
||||
*/
|
||||
export function createAnalytics(options: CreateAnalyticsOptions): Analytics {
|
||||
const { enabled, consent, logger } = options;
|
||||
|
||||
if (!consent.analytics) {
|
||||
logger.debug("analytics consent denied, returning no-op");
|
||||
return NOOP_ANALYTICS;
|
||||
}
|
||||
|
||||
const adapters: AnalyticsAdapter[] = [];
|
||||
if (enabled.metrica) adapters.push(new MetricaAdapter());
|
||||
if (enabled.ctm) adapters.push(new CtmAdapter());
|
||||
if (enabled.variocube) adapters.push(new VariocubeAdapter());
|
||||
if (enabled.dynatrace) adapters.push(new DynatraceAdapter());
|
||||
|
||||
if (adapters.length === 0) {
|
||||
logger.debug("no analytics adapters enabled, returning no-op");
|
||||
return NOOP_ANALYTICS;
|
||||
}
|
||||
|
||||
return {
|
||||
track(event: string, props: AnalyticsProps = {}): void {
|
||||
for (const adapter of adapters) {
|
||||
try {
|
||||
adapter.track(event, props);
|
||||
} catch (err) {
|
||||
logger.error("analytics adapter track failed", { provider: adapter.name, err: err as Error });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
page(url: string, props: AnalyticsProps = {}): void {
|
||||
for (const adapter of adapters) {
|
||||
try {
|
||||
adapter.page(url, props);
|
||||
} catch (err) {
|
||||
logger.error("analytics adapter page failed", { provider: adapter.name, err: err as Error });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/analytics/facade
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/analytics/facade.ts src/observability/analytics/facade.test.ts
|
||||
git commit -m "Add analytics facade with adapter fan-out and consent gating"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Create `loader.tsx` (no TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/analytics/loader.tsx`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/observability/analytics/loader.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Analytics, AnalyticsProviders } from "./types.js";
|
||||
import type { Logger } from "@/observability/logger/types";
|
||||
import { createAnalytics } from "./facade.js";
|
||||
import { AnalyticsContext } from "./provider.js";
|
||||
|
||||
export interface AnalyticsLoaderProps {
|
||||
enabled: AnalyticsProviders;
|
||||
consent: { analytics: boolean; telemetry: boolean };
|
||||
logger: Logger;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts in the root layout. Waits for idle callback, then initializes
|
||||
* analytics adapters and provides the Analytics instance to the tree.
|
||||
*/
|
||||
export function AnalyticsLoader({
|
||||
enabled,
|
||||
consent,
|
||||
logger,
|
||||
children,
|
||||
}: AnalyticsLoaderProps): JSX.Element {
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
const init = () => {
|
||||
const instance = createAnalytics({ enabled, consent, logger });
|
||||
setAnalytics(instance);
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||
(window as any).requestIdleCallback(init);
|
||||
} else {
|
||||
// Fallback for environments without requestIdleCallback
|
||||
setTimeout(init, 1);
|
||||
}
|
||||
}, [enabled, consent, logger]);
|
||||
|
||||
return (
|
||||
<AnalyticsContext.Provider value={analytics}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/analytics/loader.tsx
|
||||
git commit -m "Add AnalyticsLoader component with idle-callback initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Create `provider.tsx` (no TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/analytics/provider.tsx`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/observability/analytics/provider.tsx`:
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Analytics } from "./types.js";
|
||||
|
||||
const NOOP_ANALYTICS: Analytics = {
|
||||
track() {},
|
||||
page() {},
|
||||
};
|
||||
|
||||
/**
|
||||
* React context for the Analytics instance.
|
||||
* Exported for use by AnalyticsLoader (which sets the provider value).
|
||||
*/
|
||||
export const AnalyticsContext = createContext<Analytics | null>(null);
|
||||
|
||||
/**
|
||||
* Returns the Analytics instance from context.
|
||||
* Server-side and before AnalyticsLoader initializes: returns NoopAnalytics.
|
||||
* Client-side after init: returns the real facade instance.
|
||||
*/
|
||||
export function useAnalytics(): Analytics {
|
||||
const analytics = useContext(AnalyticsContext);
|
||||
return analytics ?? NOOP_ANALYTICS;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/analytics/provider.tsx
|
||||
git commit -m "Add useAnalytics hook with server-safe NoopAnalytics fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Exit-gate verification
|
||||
|
||||
- [ ] **Step 1: All gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. Sink + facade tests verify adapter fan-out, consent short-circuit, and disabled-adapter exclusion.
|
||||
|
||||
- [ ] **Step 2: Git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1G-analytics:
|
||||
- `types.ts` (already seeded in 1A-1) — `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, `AnalyticsAdapter` ✓
|
||||
- `sink.ts` with `emitEvent`, `getRecordedEvents`, `resetEvents` → Task 1
|
||||
- Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2
|
||||
- `createAnalytics()` with consent gating and adapter fan-out → Task 3
|
||||
- `<AnalyticsLoader>` with `requestIdleCallback` → Task 4
|
||||
- `useAnalytics()` with NoopAnalytics server fallback → Task 5
|
||||
- Consent short-circuit verified in facade tests → Task 3
|
||||
|
||||
**Exit gate alignment:**
|
||||
- "all four stub adapters emit exactly one AnalyticsEvent to the sink" — facade test, Task 3
|
||||
- "consent.analytics = false short-circuits" — facade test, Task 3
|
||||
- "adapter load failure emits flights.analytics.load_failed counter" — deferred to 1F-layout integration (loader wraps load() in try/catch and increments the metric from 1G-metrics)
|
||||
|
||||
**No new dependencies.** Stubs are pure TypeScript with no vendor SDKs.
|
||||
@@ -0,0 +1,791 @@
|
||||
# Phase 1G-logger — Logger Runtime Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the runtime logger — console transport (dev), JSON-lines HTTP transport (production), a `createRootLogger()` factory, and a React context provider with `useLogger()` — so that 1F-layout and all downstream features can log structured events with `logger.info("msg", { field: "value" })` in both SSR and client contexts.
|
||||
|
||||
**Architecture:** `src/observability/logger/types.ts` already exists (seeded in 1A-1 with `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). This plan adds the runtime implementation: a `LoggerImpl` class that dispatches to pluggable transports, two built-in transports (console for dev, JSON-lines HTTP for production), and a factory + React provider. The logger is designed for request-scoped child loggers on the server (each request gets a child with `{ traceId, locale }` fields) and a single root logger shared on the client.
|
||||
|
||||
**Tech Stack:** No new dependencies. The JSON-lines transport uses `fetch` / `navigator.sendBeacon` (browser) or `globalThis.fetch` (Node) — both built-in on Node 24.
|
||||
|
||||
**Prerequisites:** 1A-1 (types.ts already shipped), 1A-3 (ESLint boundaries).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/observability/logger/logger-impl.ts` | `LoggerImpl` class implementing `Logger` | 1 |
|
||||
| `src/observability/logger/logger-impl.test.ts` | Tests | 1 |
|
||||
| `src/observability/logger/console-transport.ts` | Dev-mode console transport | 2 |
|
||||
| `src/observability/logger/console-transport.test.ts` | Tests | 2 |
|
||||
| `src/observability/logger/json-lines-transport.ts` | Production HTTP transport with batching | 3 |
|
||||
| `src/observability/logger/json-lines-transport.test.ts` | Tests | 3 |
|
||||
| `src/observability/logger/root.ts` | `createRootLogger()` factory | 4 |
|
||||
| `src/observability/logger/root.test.ts` | Tests | 4 |
|
||||
| `src/observability/logger/provider.tsx` | React context + `useLogger()` | 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — TDD `LoggerImpl`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/logger-impl.ts`
|
||||
- Create: `src/observability/logger/logger-impl.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/logger-impl.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LogRecord, LogTransport } from "./types.js";
|
||||
import { LoggerImpl } from "./logger-impl.js";
|
||||
|
||||
function mockTransport(): LogTransport & { records: LogRecord[] } {
|
||||
const records: LogRecord[] = [];
|
||||
return {
|
||||
records,
|
||||
write(record: LogRecord) { records.push(record); },
|
||||
flush: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("LoggerImpl", () => {
|
||||
it("writes a record at each log level", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
|
||||
logger.debug("d");
|
||||
logger.info("i");
|
||||
logger.warn("w");
|
||||
logger.error("e");
|
||||
|
||||
expect(t.records).toHaveLength(4);
|
||||
expect(t.records.map(r => r.level)).toEqual(["debug", "info", "warn", "error"]);
|
||||
expect(t.records.map(r => r.msg)).toEqual(["d", "i", "w", "e"]);
|
||||
});
|
||||
|
||||
it("includes fields in the record", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
logger.info("msg", { userId: 123, action: "click" });
|
||||
expect(t.records[0]?.fields).toEqual({ userId: 123, action: "click" });
|
||||
});
|
||||
|
||||
it("includes an ISO timestamp", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
logger.info("msg");
|
||||
expect(t.records[0]?.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("child() propagates context fields to all records", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
const child = logger.child({ traceId: "abc", locale: "ru" });
|
||||
child.info("hello", { extra: true });
|
||||
|
||||
expect(t.records[0]?.fields).toEqual({
|
||||
traceId: "abc",
|
||||
locale: "ru",
|
||||
extra: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("child of child merges contexts", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
const child1 = logger.child({ traceId: "abc" });
|
||||
const child2 = child1.child({ requestId: "123" });
|
||||
child2.info("deep");
|
||||
|
||||
expect(t.records[0]?.fields).toEqual({
|
||||
traceId: "abc",
|
||||
requestId: "123",
|
||||
});
|
||||
});
|
||||
|
||||
it("error level includes err field if provided", () => {
|
||||
const t = mockTransport();
|
||||
const logger = new LoggerImpl(t);
|
||||
const err = new Error("boom");
|
||||
logger.error("failed", { err, op: "fetch" });
|
||||
|
||||
const record = t.records[0];
|
||||
expect(record?.fields?.["op"]).toBe("fetch");
|
||||
// The err field should be serialized — at minimum the message
|
||||
expect(record?.fields?.["err"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/logger/logger-impl
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/logger-impl.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Logger, LogFields, LogLevel, LogRecord, LogTransport } from "./types.js";
|
||||
|
||||
export class LoggerImpl implements Logger {
|
||||
private readonly transport: LogTransport;
|
||||
private readonly context: LogFields;
|
||||
|
||||
constructor(transport: LogTransport, context: LogFields = {}) {
|
||||
this.transport = transport;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
debug(msg: string, fields?: LogFields): void {
|
||||
this.write("debug", msg, fields);
|
||||
}
|
||||
|
||||
info(msg: string, fields?: LogFields): void {
|
||||
this.write("info", msg, fields);
|
||||
}
|
||||
|
||||
warn(msg: string, fields?: LogFields): void {
|
||||
this.write("warn", msg, fields);
|
||||
}
|
||||
|
||||
error(msg: string, fields?: LogFields & { err?: Error }): void {
|
||||
const { err, ...rest } = fields ?? {};
|
||||
const serialized: LogFields = { ...rest };
|
||||
if (err) {
|
||||
serialized["err"] = `${err.name}: ${err.message}`;
|
||||
}
|
||||
this.write("error", msg, serialized);
|
||||
}
|
||||
|
||||
child(context: LogFields): Logger {
|
||||
return new LoggerImpl(this.transport, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
private write(level: LogLevel, msg: string, fields?: LogFields): void {
|
||||
const record: LogRecord = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
msg,
|
||||
fields: { ...this.context, ...fields },
|
||||
};
|
||||
this.transport.write(record);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/logger/logger-impl
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/logger-impl.ts src/observability/logger/logger-impl.test.ts
|
||||
git commit -m "Add LoggerImpl with transport dispatch and child context propagation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD console transport
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/console-transport.ts`
|
||||
- Create: `src/observability/logger/console-transport.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/console-transport.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LogRecord } from "./types.js";
|
||||
import { ConsoleTransport } from "./console-transport.js";
|
||||
|
||||
describe("ConsoleTransport", () => {
|
||||
it("pipes debug records to console.debug", () => {
|
||||
const spy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
const record: LogRecord = { ts: "2025-01-01T00:00:00Z", level: "debug", msg: "hello", fields: {} };
|
||||
transport.write(record);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("hello");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes info records to console.info", () => {
|
||||
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "info", msg: "info msg", fields: { key: "val" } });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("info msg");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes warn records to console.warn", () => {
|
||||
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "warn", msg: "w", fields: {} });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("pipes error records to console.error", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const transport = new ConsoleTransport();
|
||||
transport.write({ ts: "2025-01-01T00:00:00Z", level: "error", msg: "e", fields: {} });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("flush is a no-op that resolves immediately", async () => {
|
||||
const transport = new ConsoleTransport();
|
||||
await expect(transport.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/console-transport.ts`:
|
||||
|
||||
```typescript
|
||||
import type { LogRecord, LogTransport } from "./types.js";
|
||||
|
||||
/**
|
||||
* Dev-mode transport that pipes log records to the browser/Node console.
|
||||
* Each record is printed as `[LEVEL] ts msg {fields}`.
|
||||
*/
|
||||
export class ConsoleTransport implements LogTransport {
|
||||
write(record: LogRecord): void {
|
||||
const prefix = `[${record.level.toUpperCase()}] ${record.ts}`;
|
||||
const hasFields = Object.keys(record.fields).length > 0;
|
||||
const msg = hasFields
|
||||
? `${prefix} ${record.msg} ${JSON.stringify(record.fields)}`
|
||||
: `${prefix} ${record.msg}`;
|
||||
|
||||
switch (record.level) {
|
||||
case "debug":
|
||||
console.debug(msg);
|
||||
break;
|
||||
case "info":
|
||||
console.info(msg);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(msg);
|
||||
break;
|
||||
case "error":
|
||||
console.error(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Console output is synchronous — nothing to flush.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/console-transport.ts src/observability/logger/console-transport.test.ts
|
||||
git commit -m "Add dev-mode ConsoleTransport for logger"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — TDD JSON-lines HTTP transport
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/json-lines-transport.ts`
|
||||
- Create: `src/observability/logger/json-lines-transport.test.ts`
|
||||
|
||||
Features: batching (collect N records or wait M ms, whichever comes first), backpressure drop (if buffer exceeds max, drop oldest), redaction of sensitive field names, `sendBeacon` on page unload / `flush()`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/json-lines-transport.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { LogRecord } from "./types.js";
|
||||
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
|
||||
|
||||
function record(overrides?: Partial<LogRecord>): LogRecord {
|
||||
return {
|
||||
ts: "2025-01-01T00:00:00.000Z",
|
||||
level: "info",
|
||||
msg: "test",
|
||||
fields: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("JsonLinesHttpTransport", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchSpy = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("flushes batch after batchSize records", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 2,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "one" }));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
transport.write(record({ msg: "two" }));
|
||||
// Should have flushed after 2nd record
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const lines = body.trim().split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("flushes after flushIntervalMs even if batch is not full", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 1000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops oldest records when buffer exceeds maxBufferSize", () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 3,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "1" }));
|
||||
transport.write(record({ msg: "2" }));
|
||||
transport.write(record({ msg: "3" }));
|
||||
transport.write(record({ msg: "4" }));
|
||||
transport.write(record({ msg: "5" }));
|
||||
|
||||
// Force flush to see what's in the buffer
|
||||
transport.flush();
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const lines = body.trim().split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
// Should contain the 3 most recent
|
||||
expect(lines[0]).toContain('"3"');
|
||||
expect(lines[1]).toContain('"4"');
|
||||
expect(lines[2]).toContain('"5"');
|
||||
});
|
||||
|
||||
it("redacts sensitive field names", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
redactFields: ["password", "token", "secret"],
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({
|
||||
fields: { password: "hunter2", token: "abc123", safe: "visible" },
|
||||
}));
|
||||
|
||||
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
|
||||
const parsed = JSON.parse(body.trim());
|
||||
expect(parsed.fields.password).toBe("[REDACTED]");
|
||||
expect(parsed.fields.token).toBe("[REDACTED]");
|
||||
expect(parsed.fields.safe).toBe("visible");
|
||||
});
|
||||
|
||||
it("flush() sends all buffered records and clears the buffer", async () => {
|
||||
const transport = new JsonLinesHttpTransport({
|
||||
endpoint: "https://logs.example/ingest",
|
||||
batchSize: 100,
|
||||
flushIntervalMs: 60000,
|
||||
maxBufferSize: 100,
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
|
||||
transport.write(record({ msg: "a" }));
|
||||
transport.write(record({ msg: "b" }));
|
||||
await transport.flush();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Flush again — nothing to send
|
||||
await transport.flush();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/json-lines-transport.ts`:
|
||||
|
||||
```typescript
|
||||
import type { LogRecord, LogTransport } from "./types.js";
|
||||
|
||||
export interface JsonLinesHttpTransportOptions {
|
||||
endpoint: string;
|
||||
batchSize?: number;
|
||||
flushIntervalMs?: number;
|
||||
maxBufferSize?: number;
|
||||
redactFields?: string[];
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class JsonLinesHttpTransport implements LogTransport {
|
||||
private readonly endpoint: string;
|
||||
private readonly batchSize: number;
|
||||
private readonly maxBufferSize: number;
|
||||
private readonly redactFields: Set<string>;
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private buffer: LogRecord[] = [];
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: JsonLinesHttpTransportOptions) {
|
||||
this.endpoint = options.endpoint;
|
||||
this.batchSize = options.batchSize ?? 50;
|
||||
this.maxBufferSize = options.maxBufferSize ?? 500;
|
||||
this.redactFields = new Set(options.redactFields ?? ["password", "token", "secret", "authorization"]);
|
||||
this.fetchFn = options.fetchImpl ?? globalThis.fetch;
|
||||
|
||||
const intervalMs = options.flushIntervalMs ?? 5000;
|
||||
this.timer = setInterval(() => {
|
||||
if (this.buffer.length > 0) {
|
||||
void this.sendBatch();
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Unref so the timer doesn't keep the Node process alive
|
||||
if (typeof this.timer === "object" && "unref" in this.timer) {
|
||||
this.timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
write(record: LogRecord): void {
|
||||
const redacted = this.redact(record);
|
||||
this.buffer.push(redacted);
|
||||
|
||||
// Backpressure: drop oldest if buffer exceeds max
|
||||
while (this.buffer.length > this.maxBufferSize) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
|
||||
// Flush if batch is full
|
||||
if (this.buffer.length >= this.batchSize) {
|
||||
void this.sendBatch();
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
await this.sendBatch();
|
||||
}
|
||||
|
||||
private async sendBatch(): Promise<void> {
|
||||
const batch = this.buffer.splice(0, this.buffer.length);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
const body = batch.map((r) => JSON.stringify(r)).join("\n");
|
||||
|
||||
try {
|
||||
await this.fetchFn(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
body,
|
||||
});
|
||||
} catch {
|
||||
// Silently drop failed sends — logging a log failure causes recursion.
|
||||
// In production, a metrics counter would track send failures.
|
||||
}
|
||||
}
|
||||
|
||||
private redact(record: LogRecord): LogRecord {
|
||||
if (this.redactFields.size === 0) return record;
|
||||
|
||||
const fields = { ...record.fields };
|
||||
for (const key of Object.keys(fields)) {
|
||||
if (this.redactFields.has(key.toLowerCase())) {
|
||||
fields[key] = "[REDACTED]";
|
||||
}
|
||||
}
|
||||
return { ...record, fields };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/json-lines-transport.ts src/observability/logger/json-lines-transport.test.ts
|
||||
git commit -m "Add JsonLinesHttpTransport with batching, backpressure, and redaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — TDD `createRootLogger()` factory
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/root.ts`
|
||||
- Create: `src/observability/logger/root.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/logger/root.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import type { Logger } from "./types.js";
|
||||
|
||||
describe("createRootLogger", () => {
|
||||
afterEach(async () => {
|
||||
const mod = await import("./root.js");
|
||||
mod.__resetRootLoggerForTests();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns a Logger with console transport in development", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.info).toBe("function");
|
||||
expect(typeof logger.child).toBe("function");
|
||||
});
|
||||
|
||||
it("returns a Logger with JSON-lines transport in production", async () => {
|
||||
process.env["NODE_ENV"] = "production";
|
||||
process.env["LOGS_ENDPOINT"] = "https://logs.example/ingest";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.info).toBe("function");
|
||||
});
|
||||
|
||||
it("caches the logger instance (returns same object on repeated calls)", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const a = createRootLogger();
|
||||
const b = createRootLogger();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("child() produces a Logger with merged context", async () => {
|
||||
process.env["NODE_ENV"] = "development";
|
||||
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
|
||||
__resetRootLoggerForTests();
|
||||
const logger = createRootLogger();
|
||||
const child = logger.child({ traceId: "test-123" });
|
||||
child.info("hello");
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy.mock.calls[0]?.[0]).toContain("test-123");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/logger/root.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Logger, LogTransport } from "./types.js";
|
||||
import { LoggerImpl } from "./logger-impl.js";
|
||||
import { ConsoleTransport } from "./console-transport.js";
|
||||
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
|
||||
|
||||
let cached: Logger | undefined;
|
||||
|
||||
/**
|
||||
* Creates or returns the cached root logger. In development, uses
|
||||
* ConsoleTransport. In other envs, uses JsonLinesHttpTransport if
|
||||
* LOGS_ENDPOINT is set, otherwise falls back to console.
|
||||
*/
|
||||
export function createRootLogger(): Logger {
|
||||
if (cached) return cached;
|
||||
|
||||
const env = process.env["NODE_ENV"] ?? "development";
|
||||
const logsEndpoint = process.env["LOGS_ENDPOINT"];
|
||||
|
||||
let transport: LogTransport;
|
||||
|
||||
if (env === "development" || !logsEndpoint) {
|
||||
transport = new ConsoleTransport();
|
||||
} else {
|
||||
transport = new JsonLinesHttpTransport({
|
||||
endpoint: logsEndpoint,
|
||||
batchSize: 50,
|
||||
flushIntervalMs: 5000,
|
||||
maxBufferSize: 500,
|
||||
});
|
||||
}
|
||||
|
||||
cached = new LoggerImpl(transport);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Test-only: clears the cached root logger. */
|
||||
export function __resetRootLoggerForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/logger/root.ts src/observability/logger/root.test.ts
|
||||
git commit -m "Add createRootLogger factory with transport selection by env"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Create `src/observability/logger/provider.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/logger/provider.tsx`
|
||||
|
||||
No TDD — thin React wrapper, exercised by 1F-layout.
|
||||
|
||||
- [ ] **Step 1: Write `src/observability/logger/provider.tsx`**
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Logger } from "./types.js";
|
||||
|
||||
const LoggerContext = createContext<Logger | null>(null);
|
||||
|
||||
export interface LoggerProviderProps {
|
||||
logger: Logger;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the Logger instance to the React tree. On the server,
|
||||
* use a request-scoped child logger (with traceId, locale). On the
|
||||
* client, use the shared root logger from createRootLogger().
|
||||
*/
|
||||
export function LoggerProvider({
|
||||
logger,
|
||||
children,
|
||||
}: LoggerProviderProps): JSX.Element {
|
||||
return (
|
||||
<LoggerContext.Provider value={logger}>
|
||||
{children}
|
||||
</LoggerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Logger from context. Throws if used outside
|
||||
* <LoggerProvider>.
|
||||
*/
|
||||
export function useLogger(): Logger {
|
||||
const logger = useContext(LoggerContext);
|
||||
if (!logger) {
|
||||
throw new Error(
|
||||
"useLogger() must be used within a <LoggerProvider>. " +
|
||||
"Ensure the root layout wraps the tree with <LoggerProvider>.",
|
||||
);
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/observability/logger/provider.tsx
|
||||
git commit -m "Add LoggerProvider React context with useLogger hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Exit-gate verification
|
||||
|
||||
- [ ] **Step 1: All gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. Test count: 83 (prior) + LoggerImpl + ConsoleTransport + JsonLines + RootLogger = ~100+ total.
|
||||
|
||||
- [ ] **Step 2: Git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1G-logger:
|
||||
- types.ts (already shipped in 1A-1) ✓
|
||||
- `JsonLinesHttpTransport` with batching, backpressure, redaction, flush → Task 3
|
||||
- `ConsoleTransport` for dev → Task 2
|
||||
- `createRootLogger()` factory → Task 4
|
||||
- React context + `useLogger()` → Task 5
|
||||
- A4-trigger task → documented in master plan, not implemented here (fires on A4 resolution)
|
||||
- Exit gate tests: batching+flush, redaction, backpressure, console transport, child() → Tasks 1-4
|
||||
|
||||
**Type consistency.** `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport` all from `./types.js` (seeded in 1A-1).
|
||||
@@ -0,0 +1,366 @@
|
||||
# Phase 1G-metrics — OpenTelemetry + Custom Instruments Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the OpenTelemetry runtime — server and browser initializers, `getMeter`/`getTracer` accessors, and the 8 custom metric instruments — so that 1F-layout, 1D, 1E, and all downstream features can emit structured metrics with `flightsApiError.add(1, { route })` in both SSR and client contexts.
|
||||
|
||||
**Architecture:** `otel.ts` is the **only** file allowed to import from `@opentelemetry/sdk-metrics` and `@opentelemetry/sdk-node` (enforced by 1A-3 ESLint boundaries). It exports `initServerOtel(env)` and `initBrowserOtel(env)` which wire the real `MeterProvider`/`TracerProvider`. `custom.ts` uses `@opentelemetry/api`'s proxy meter to declare instruments at module level — safe because the proxy lazy-resolves after init runs. `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`.
|
||||
|
||||
**Tech Stack:** `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals`.
|
||||
|
||||
**Prerequisites:** 1A-1 (skeleton + `Env` type), 1A-3 (ESLint boundaries), 1G-logger (Logger types).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Task |
|
||||
|---|---|---|
|
||||
| `src/observability/metrics/otel.ts` | OTel init + getMeter/getTracer | 2 |
|
||||
| `src/observability/metrics/otel.test.ts` | Tests | 2 |
|
||||
| `src/observability/metrics/custom.ts` | 8 custom metric instruments | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Install OTel dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http web-vitals
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify installation**
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json pnpm-lock.yaml
|
||||
git commit -m "Add OpenTelemetry and web-vitals dependencies for metrics pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — TDD `otel.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/metrics/otel.ts`
|
||||
- Create: `src/observability/metrics/otel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `src/observability/metrics/otel.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
AggregationTemporality,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
describe("otel", () => {
|
||||
beforeEach(() => {
|
||||
// Reset global providers between tests
|
||||
metrics.disable();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
metrics.disable();
|
||||
trace.disable();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("initServerOtel registers a MeterProvider and TracerProvider", async () => {
|
||||
const { initServerOtel } = await import("./otel.js");
|
||||
|
||||
initServerOtel({
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
|
||||
OTEL_SERVICE_NAME: "flights-test",
|
||||
NODE_ENV: "test",
|
||||
} as any);
|
||||
|
||||
// After init, getMeter should return a working meter
|
||||
const { getMeter, getTracer } = await import("./otel.js");
|
||||
const meter = getMeter("test");
|
||||
const tracer = getTracer("test");
|
||||
|
||||
expect(meter).toBeDefined();
|
||||
expect(tracer).toBeDefined();
|
||||
});
|
||||
|
||||
it("counter incremented via proxy meter is observable by test reader", async () => {
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 100,
|
||||
});
|
||||
|
||||
const { initServerOtelWithReader } = await import("./otel.js");
|
||||
initServerOtelWithReader({
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
|
||||
OTEL_SERVICE_NAME: "flights-test",
|
||||
NODE_ENV: "test",
|
||||
} as any, reader);
|
||||
|
||||
const counter = metrics.getMeter("flights").createCounter("test.counter");
|
||||
counter.add(1, { route: "/smoke" });
|
||||
|
||||
// Force a collection cycle
|
||||
await reader.forceFlush();
|
||||
|
||||
const exported = exporter.getMetrics();
|
||||
expect(exported.length).toBeGreaterThan(0);
|
||||
|
||||
const testMetric = exported
|
||||
.flatMap((rm) => rm.scopeMetrics)
|
||||
.flatMap((sm) => sm.metrics)
|
||||
.find((m) => m.descriptor.name === "test.counter");
|
||||
|
||||
expect(testMetric).toBeDefined();
|
||||
|
||||
await reader.shutdown();
|
||||
});
|
||||
|
||||
it("getMeter returns a meter from @opentelemetry/api", async () => {
|
||||
const { getMeter } = await import("./otel.js");
|
||||
const meter = getMeter("my-component");
|
||||
expect(meter).toBeDefined();
|
||||
expect(typeof meter.createCounter).toBe("function");
|
||||
expect(typeof meter.createHistogram).toBe("function");
|
||||
});
|
||||
|
||||
it("getTracer returns a tracer from @opentelemetry/api", async () => {
|
||||
const { getTracer } = await import("./otel.js");
|
||||
const tracer = getTracer("my-component");
|
||||
expect(tracer).toBeDefined();
|
||||
expect(typeof tracer.startSpan).toBe("function");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — MUST FAIL**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/metrics/otel
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/observability/metrics/otel.ts`:
|
||||
|
||||
```typescript
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import type { Meter, Tracer } from "@opentelemetry/api";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import type { MetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import type { Env } from "@/env";
|
||||
import type { Logger } from "@/observability/logger/types";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize OpenTelemetry for the server (Node) process.
|
||||
* Called once per process at startup.
|
||||
*/
|
||||
export function initServerOtel(env: Env): void {
|
||||
if (initialized) return;
|
||||
|
||||
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
const serviceName = env.OTEL_SERVICE_NAME ?? "flights-web";
|
||||
|
||||
const metricReader = new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
|
||||
exportIntervalMillis: 15_000,
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
serviceName,
|
||||
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
|
||||
metricReader,
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only variant that accepts a custom MetricReader for in-memory assertions.
|
||||
*/
|
||||
export function initServerOtelWithReader(env: Env, reader: MetricReader): void {
|
||||
if (initialized) return;
|
||||
|
||||
const serviceName = (env as Record<string, string>).OTEL_SERVICE_NAME ?? "flights-test";
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [reader],
|
||||
});
|
||||
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OpenTelemetry for the browser.
|
||||
* Called once per tab via useEffect in the root layout.
|
||||
* Browser-side uses web-vitals to report CWV as histograms.
|
||||
*/
|
||||
export function initBrowserOtel(env: Env): void {
|
||||
if (initialized) return;
|
||||
|
||||
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
if (!endpoint) return;
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
|
||||
exportIntervalMillis: 30_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
|
||||
// Report web-vitals as OTel histograms
|
||||
const cwvMeter = meterProvider.getMeter("web-vitals");
|
||||
void import("web-vitals").then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => {
|
||||
const cls = cwvMeter.createHistogram("web_vitals.cls");
|
||||
const fid = cwvMeter.createHistogram("web_vitals.fid");
|
||||
const lcp = cwvMeter.createHistogram("web_vitals.lcp");
|
||||
const fcp = cwvMeter.createHistogram("web_vitals.fcp");
|
||||
const ttfb = cwvMeter.createHistogram("web_vitals.ttfb");
|
||||
|
||||
onCLS((m) => cls.record(m.value));
|
||||
onFID((m) => fid.record(m.value));
|
||||
onLCP((m) => lcp.record(m.value));
|
||||
onFCP((m) => fcp.record(m.value));
|
||||
onTTFB((m) => ttfb.record(m.value));
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/** Returns a named Meter from the global MeterProvider. */
|
||||
export function getMeter(name: string): Meter {
|
||||
return metrics.getMeter(name);
|
||||
}
|
||||
|
||||
/** Returns a named Tracer from the global TracerProvider. */
|
||||
export function getTracer(name: string): Tracer {
|
||||
return trace.getTracer(name);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — ALL MUST PASS**
|
||||
|
||||
```bash
|
||||
pnpm test src/observability/metrics/otel
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/metrics/otel.ts src/observability/metrics/otel.test.ts
|
||||
git commit -m "Add OTel server/browser initializers with getMeter/getTracer accessors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Create `custom.ts` (declarative, no TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/observability/metrics/custom.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/observability/metrics/custom.ts`:
|
||||
|
||||
```typescript
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Module-level metric instruments for the flights remote component.
|
||||
* Safe to declare at module scope — @opentelemetry/api's proxy meter
|
||||
* lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs.
|
||||
*/
|
||||
const meter = metrics.getMeter("flights");
|
||||
|
||||
/** SSR request duration histogram (seconds). */
|
||||
export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration");
|
||||
|
||||
/** Upstream API request duration histogram (seconds). */
|
||||
export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration");
|
||||
|
||||
/** Upstream API error counter (by route, status). */
|
||||
export const flightsApiError = meter.createCounter("flights.api.error");
|
||||
|
||||
/** SignalR active connections gauge. */
|
||||
export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected");
|
||||
|
||||
/** SignalR messages received counter. */
|
||||
export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received");
|
||||
|
||||
/** SignalR disconnection counter (by reason). */
|
||||
export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect");
|
||||
|
||||
/** Feature component render counter (by feature name). */
|
||||
export const flightsFeatureRender = meter.createCounter("flights.feature.render");
|
||||
|
||||
/** Unhandled React error counter (caught by ErrorBoundary). */
|
||||
export const flightsReactError = meter.createCounter("flights.react.error");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck + lint, commit**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint
|
||||
git add src/observability/metrics/custom.ts
|
||||
git commit -m "Add 8 custom metric instruments using OTel proxy meter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Exit-gate verification
|
||||
|
||||
- [ ] **Step 1: All gates**
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Expected: all pass. OTel init test proves counter is observable via test reader.
|
||||
|
||||
- [ ] **Step 2: Git status clean**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Master plan §1G-metrics:
|
||||
- `initServerOtel(env)` / `initBrowserOtel(env)` — Task 2
|
||||
- `getMeter(name)` / `getTracer(name)` — Task 2
|
||||
- 8 custom instruments (`flights.ssr.request.duration`, `flights.api.request.duration`, `flights.api.error`, `flights.signalr.connected`, `flights.signalr.message.received`, `flights.signalr.disconnect`, `flights.feature.render`, `flights.react.error`) — Task 3
|
||||
- web-vitals histograms created inside `initBrowserOtel` — Task 2
|
||||
- `otel.ts` is the only file importing `@opentelemetry/sdk-metrics` / `@opentelemetry/sdk-node` — enforced by 1A-3 ESLint rule, verified at exit gate
|
||||
|
||||
**Import boundary.** `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. `custom.ts` imports only from `@opentelemetry/api` (public API, no SDK).
|
||||
|
||||
**Type consistency.** `Meter`, `Tracer` from `@opentelemetry/api`. `Env` from `@/env` (seeded in 1A-1).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Phase 1H — Security Hardening
|
||||
|
||||
**Parent:** Phase 1 Foundation Master Plan
|
||||
**Branch:** `plan/react-rewrite`
|
||||
|
||||
## Overview
|
||||
|
||||
Implements security hardening contracts: CSP nonce middleware, SSR stream nonce injection, standard security headers, and safe storage abstraction with Zod schema validation.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/`
|
||||
- Do NOT modify `modern.config.ts` (1I wires middleware registrations)
|
||||
- `zod` already installed (from 1A-1)
|
||||
- Middleware exported as factory functions, not auto-registered
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: `src/shared/storage.ts` + tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- `src/shared/storage.ts`
|
||||
- `src/shared/storage.test.ts`
|
||||
|
||||
**Contract:**
|
||||
```ts
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
export const storage: {
|
||||
get<T>(key: string, schema: ZodSchema<T>): T | null;
|
||||
set<T>(key: string, value: T, schema: ZodSchema<T>): void;
|
||||
delete(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- All keys namespaced with `afl_` prefix
|
||||
- `get` returns `null` when key missing, JSON parse fails, or Zod validation fails (never throws)
|
||||
- `set` validates against schema before writing (throws on validation failure)
|
||||
- `delete` removes the namespaced key
|
||||
- `clear` removes only `afl_`-prefixed keys (not all storage)
|
||||
|
||||
**Tests:**
|
||||
- get/set round-trip with valid schema
|
||||
- get returns null for missing key
|
||||
- get returns null when stored value fails schema validation
|
||||
- set throws when value doesn't match schema
|
||||
- delete removes the key
|
||||
- clear removes only namespaced keys
|
||||
- keys are stored with `afl_` prefix
|
||||
|
||||
### Task 2: `src/server/middleware/csp.ts` + tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- `src/server/middleware/csp.ts`
|
||||
- `src/server/middleware/csp.test.ts`
|
||||
|
||||
**Contract:**
|
||||
```ts
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface CspMiddlewareOptions {
|
||||
reportOnly?: boolean;
|
||||
}
|
||||
|
||||
export function cspMiddleware(options?: CspMiddlewareOptions): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
|
||||
|
||||
export const CspNonceContext: React.Context<string>; // default ""
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Generates per-request nonce using `crypto.randomUUID()`
|
||||
- Sets `Content-Security-Policy` header with `script-src 'nonce-{nonce}'`
|
||||
- When `reportOnly: true`, uses `Content-Security-Policy-Report-Only` header
|
||||
- Exposes nonce via `CspNonceContext` (default `""` on client)
|
||||
- Nonce attached to request object for downstream middleware access
|
||||
|
||||
**Tests:**
|
||||
- Generates unique nonce per call
|
||||
- Sets CSP header with nonce
|
||||
- reportOnly option uses report-only header
|
||||
- Each invocation produces a different nonce
|
||||
- CspNonceContext has default value of ""
|
||||
|
||||
### Task 3: `src/server/middleware/nonce-stream-transform.ts` + tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- `src/server/middleware/nonce-stream-transform.ts`
|
||||
- `src/server/middleware/nonce-stream-transform.test.ts`
|
||||
|
||||
**Contract:**
|
||||
```ts
|
||||
export function wrapSsrStreamWithNonce(
|
||||
stream: NodeJS.ReadableStream,
|
||||
nonce: string,
|
||||
): NodeJS.ReadableStream;
|
||||
```
|
||||
|
||||
**Details:**
|
||||
- Processes SSR HTML stream to inject `nonce="..."` on `<script>` tags without a nonce
|
||||
- Does NOT double-inject on `<script nonce="...">` tags
|
||||
- Handles `<script>`, `<script src="...">`, `<script type="module">` etc.
|
||||
- Must handle chunks that split across tag boundaries
|
||||
|
||||
**Tests:**
|
||||
- Injects nonce on bare `<script>` tags
|
||||
- Injects nonce on `<script src="...">` tags
|
||||
- Does not double-inject on `<script nonce="existing">` tags
|
||||
- Handles multiple script tags in one chunk
|
||||
- Handles chunks split mid-tag
|
||||
|
||||
### Task 4: `src/server/middleware/security-headers.ts` (no TDD)
|
||||
|
||||
**Files:**
|
||||
- `src/server/middleware/security-headers.ts`
|
||||
|
||||
**Contract:**
|
||||
```ts
|
||||
export function securityHeadersMiddleware(): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
|
||||
```
|
||||
|
||||
**Headers set:**
|
||||
- `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`
|
||||
|
||||
## Exit Gate
|
||||
|
||||
- `pnpm typecheck` passes
|
||||
- `pnpm lint` passes
|
||||
- `pnpm test` passes (all new + existing tests)
|
||||
- `storage.get` with mismatching schema returns `null`
|
||||
- CSP middleware generates unique nonce per request
|
||||
- Every `<script>` in nonce-stream-transform output carries the nonce
|
||||
@@ -0,0 +1,106 @@
|
||||
# Phase 1I — Deploy pipeline + runbook contracts
|
||||
|
||||
**Parent plan:** `2026-04-14-phase-1-foundation-master.md`, section "1I — Deploy pipeline + runbook contracts"
|
||||
**Date:** 2026-04-14
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This sub-plan delivers the deploy pipeline contracts: Docker images for both build targets (standalone SSR and remote static), health endpoint, graceful shutdown handler, CI workflow template, and an operational runbook.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do NOT modify `modern.config.ts` (middleware registration is a future integration step)
|
||||
- Health and shutdown modules export factory functions, not auto-registered middleware
|
||||
- Coexist with the existing ASP.NET `Dockerfile` and `Dockerfile.local` (write `Dockerfile.react` and `Dockerfile.remote`)
|
||||
- Do NOT touch `ClientApp/`, ASP.NET files, or `wwwroot/`
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1 — `Dockerfile.react` (standalone SSR image)
|
||||
|
||||
**File:** `Dockerfile.react` (repo root)
|
||||
|
||||
Multi-stage build:
|
||||
1. Stage 1 (`deps`): Node 24 base, enable corepack pnpm, copy `package.json` + `pnpm-lock.yaml`, run `pnpm install --frozen-lockfile`
|
||||
2. Stage 2 (`build`): copy `src/`, config files, run `pnpm build:standalone`
|
||||
3. Stage 3 (`runtime`): Node 24 slim, copy `dist/standalone/`, entrypoint `node dist/standalone/index.js`
|
||||
|
||||
**Exit gate:** File exists, valid Dockerfile syntax.
|
||||
|
||||
### Task 2 — `Dockerfile.remote` (nginx static image)
|
||||
|
||||
**File:** `Dockerfile.remote` (repo root)
|
||||
|
||||
Multi-stage build:
|
||||
1. Stage 1 (`deps`): same as Task 1
|
||||
2. Stage 2 (`build`): copy source, run `pnpm build:remote`
|
||||
3. Stage 3 (`runtime`): `nginx:alpine`, copy `dist/remote/` to `/usr/share/nginx/html`, expose port 80
|
||||
|
||||
**Exit gate:** File exists, valid Dockerfile syntax.
|
||||
|
||||
### Task 3 — Health endpoint (`src/server/routes/health.ts`)
|
||||
|
||||
**Files:** `src/server/routes/health.ts`, `src/server/routes/health.test.ts`
|
||||
|
||||
Export `healthMiddleware(options)` factory function that returns an Express-style `(req, res, next)` handler. The middleware:
|
||||
- Pings the upstream API client on each request (with configurable timeout, default 5000ms)
|
||||
- Tracks last successful ping timestamp
|
||||
- Returns 200 `{ status: "ok" }` if last success < 60s ago
|
||||
- Returns 503 `{ status: "degraded", reason: "upstream_unreachable" }` otherwise
|
||||
|
||||
**TDD:** Tests mock ApiClient, verify 200/503 responses, verify timeout behavior.
|
||||
|
||||
**Exit gate:** `pnpm test` passes health tests.
|
||||
|
||||
### Task 4 — Graceful shutdown (`src/server/shutdown.ts`)
|
||||
|
||||
**Files:** `src/server/shutdown.ts`, `src/server/shutdown.test.ts`
|
||||
|
||||
Export `registerGracefulShutdown(options)` factory function that:
|
||||
- Registers a SIGTERM handler
|
||||
- On SIGTERM: calls `server.close()`, waits up to `drainTimeoutMs` (default 30000), flushes logger transport, exits with code 0
|
||||
- If drain times out, force-exits with code 1
|
||||
|
||||
**TDD:** Tests mock `process.on`, `server.close`, `logger` flush; verify shutdown sequence.
|
||||
|
||||
**Exit gate:** `pnpm test` passes shutdown tests.
|
||||
|
||||
### Task 5 — CI deploy workflow (`.github/workflows/deploy.yml`)
|
||||
|
||||
**File:** `.github/workflows/deploy.yml`
|
||||
|
||||
Template workflow triggered on push to `main`:
|
||||
- Checkout, setup Node 24, install pnpm, `pnpm install --frozen-lockfile`
|
||||
- Build both targets (`pnpm build:both`)
|
||||
- Build Docker images (`Dockerfile.react`, `Dockerfile.remote`)
|
||||
- Push to registry (placeholder)
|
||||
- Deploy to testing environment (placeholder)
|
||||
|
||||
**Exit gate:** File exists, valid YAML syntax.
|
||||
|
||||
### Task 6 — Operational runbook (`docs/superpowers/phase-1/runbook.md`)
|
||||
|
||||
**File:** `docs/superpowers/phase-1/runbook.md`
|
||||
|
||||
Covers:
|
||||
1. Incident response decision tree
|
||||
2. Canary rollout procedure
|
||||
3. Rollback procedure (auto + manual)
|
||||
4. Health-check interpretation
|
||||
5. Log query cookbook
|
||||
6. Known-failure playbooks (6 scenarios)
|
||||
|
||||
**Exit gate:** File exists, covers all required sections.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks:
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test
|
||||
```
|
||||
@@ -0,0 +1,896 @@
|
||||
# Phase 2 — Online Board MASTER Plan
|
||||
|
||||
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 2 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
|
||||
>
|
||||
> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt.
|
||||
|
||||
**Goal of Phase 2:** Port the Online Board feature from Angular to React, achieving URL parity (100% against the Phase 0 corpus), SEO parity (enhanced with JSON-LD `Flight` + `ItemList` schemas), and live SignalR updates via the `TrackerHub`. Online Board is the hardest feature (SignalR live data + deep-linked search + flight details + real-time UI + SEO) and is deliberately first so its risk surfaces early.
|
||||
|
||||
**Phase 2 exit gate** (must pass before Phase 3 starts):
|
||||
|
||||
- URL parity 100% verified against the Phase 0 prod-access-log corpus — every `onlineboard/*` URL shape round-trips through `parseOnlineBoardUrl` / `buildOnlineBoardUrl` identically to Angular.
|
||||
- SEO parity: canonical, hreflang (9 langs + `x-default`), OG tags, JSON-LD (`Flight` for details, `ItemList` of `Flight` for search results) — validated by SSR render + `cheerio` parse + `schema-dts` type check.
|
||||
- Playwright integration tests passing (4 ported Cypress scenarios + SignalR mock server + error cases).
|
||||
- VRT within threshold for all online-board routes x 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en).
|
||||
- Load test at 150 RPS passes on online-board routes (50% headroom above the 100 RPS requirement).
|
||||
- All Phase 1 exit gates still green (regression gate).
|
||||
- WCAG AA violations block (upgraded from Phase 1's warn-only).
|
||||
- Real analytics vendors (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging` environments.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 2 implements §9.2 (Phase 2 scope), §3.3-3.5 (routing + URL parity), §4.4 (SignalR), §5 (UI adapter), §6.5-6.8 (SEO/JSON-LD/OG/hreflang).
|
||||
|
||||
**Phase 1 prerequisite:** All Phase 1 exit gates must be green before Phase 2 starts. Phase 2 consumes the following Phase 1 contracts: `ApiClient` + `CachedApiClient` (1D), `SignalRConnection` + `useLiveFlights` (1E), `SeoHead` + `buildHreflangSet` + `JsonLdRenderer` (1F-seo), `createI18nInstance` + `useTranslation` (1C), `Logger` + `useLogger` (1G-logger), `Analytics` + `useAnalytics` (1G-analytics), `ErrorBoundary` + `errorToResponse` (1F-layout), `getEnv` (1A-1), root + locale layouts (1F-layout).
|
||||
|
||||
---
|
||||
|
||||
## Sub-plan inventory
|
||||
|
||||
| ID | Sub-plan | Estimated size | File |
|
||||
|---|---|---|---|
|
||||
| **2A** | UI adapter layer (`src/ui/flights/`) | Large (20-30 tasks) | `2026-04-14-phase-2a-ui-flights.md` (TBW) |
|
||||
| **2B** | URL serializer/parser (`src/features/online-board/url.ts`) | Small (5-10 tasks) | `2026-04-14-phase-2b-url-serializer.md` (TBW) |
|
||||
| **2C** | API client + hooks (`src/features/online-board/api.ts`, hooks) | Medium (10-20 tasks) | `2026-04-14-phase-2c-api-hooks.md` (TBW) |
|
||||
| **2D** | SignalR wiring | Medium (10-15 tasks) | `2026-04-14-phase-2d-signalr-wiring.md` (TBW) |
|
||||
| **2E** | Routes + pages | Medium (15-20 tasks) | `2026-04-14-phase-2e-routes-pages.md` (TBW) |
|
||||
| **2F** | SEO + JSON-LD | Small (8-12 tasks) | `2026-04-14-phase-2f-seo-jsonld.md` (TBW) |
|
||||
| **2G** | Parity harnesses | Medium (10-15 tasks) | `2026-04-14-phase-2g-parity-harnesses.md` (TBW) |
|
||||
| **2H** | Integration tests | Medium (10-15 tasks) | `2026-04-14-phase-2h-integration-tests.md` (TBW) |
|
||||
|
||||
Sizes: **Small** = 5-12 tasks, **Medium** = 10-20 tasks, **Large** = 20-30 tasks.
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
┌───────────────────┐
|
||||
│ 2A UI adapter │ (flight-display components needed by pages)
|
||||
│ src/ui/flights/ │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
┌──────────┐ ┌───────────┐ │
|
||||
│ 2B URL │ │ 2C API │ │
|
||||
│ serializer│ │ client + │ │
|
||||
│ (indepen-│ │ hooks │ │
|
||||
│ dent) │ │ (uses 2A │ │
|
||||
│ │ │ for types)│ │
|
||||
└────┬─────┘ └─────┬─────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────┐ │
|
||||
│ │ 2D SignalR │ │
|
||||
│ │ wiring │ │
|
||||
│ │ (consumes │ │
|
||||
│ │ 2C hooks) │ │
|
||||
│ └─────┬──────┘ │
|
||||
│ │ │
|
||||
└──────────────┼───────────────┘
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 2E Routes + │
|
||||
│ pages │
|
||||
│ (consumes 2A + │
|
||||
│ 2B + 2C + 2D) │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 2F SEO + │
|
||||
│ JSON-LD │
|
||||
│ (consumes 2E │
|
||||
│ route context) │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 2G Parity │
|
||||
│ harnesses │
|
||||
│ (consumes 2E + │
|
||||
│ 2F for baselines)│
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 2H Integration │
|
||||
│ tests │
|
||||
│ (consumes all) │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### Execution order
|
||||
|
||||
**Serial (1 engineer):** 2A → 2B → 2C → 2D → 2E → 2F → 2G → 2H.
|
||||
|
||||
Rationale:
|
||||
- 2A must come first: the UI flight-display components are needed by every page. This is the `src/ui/flights/` population step from design spec §5.
|
||||
- 2B (URL serializer) is logically independent of 2A but ordered after it in serial because 2E needs both and 2A is larger / higher risk.
|
||||
- 2C depends on 2A for data model types (flight types used in hooks).
|
||||
- 2D depends on 2C (wires SignalR push events into the same hooks/state that 2C creates).
|
||||
- 2E depends on 2A + 2B + 2C + 2D — it's the integration point (pages import UI components, use URL parsing, call API hooks, wire live data).
|
||||
- 2F depends on 2E (SEO builders need the route context and data shapes that pages define).
|
||||
- 2G depends on 2E + 2F (parity harnesses test the rendered pages + SEO output).
|
||||
- 2H depends on everything (Playwright integration tests exercise the full feature).
|
||||
|
||||
**Parallel (2+ engineers):** After 2A ships:
|
||||
- **Engineer 1:** 2B (URL serializer, fully independent)
|
||||
- **Engineer 2:** 2C (API hooks, needs 2A types)
|
||||
- Then 2D follows 2C; 2E follows 2B + 2C + 2D; rest is serial.
|
||||
|
||||
### Critical path
|
||||
|
||||
**2A → 2C → 2D → 2E → 2F → 2G → 2H** is the critical path. 2B sits off the critical path (it's small and independent) and can be slotted alongside 2C.
|
||||
|
||||
---
|
||||
|
||||
## Contracts — what each sub-plan exports
|
||||
|
||||
### 2A — UI adapter layer contracts
|
||||
|
||||
**Scope:** Port the subset of Angular `FlightsModule` shared components that the Online Board feature uses. These land in `src/ui/flights/` per the design spec §5 location rule ("does more than one feature use it?"). Feature-specific components land in `src/features/online-board/components/` in sub-plan 2E.
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`src/ui/flights/FlightCard.tsx`** — single flight row in search results. Displays carrier logo, flight number, departure/arrival airports and times, status badge, aircraft type. Props-driven, no data fetching.
|
||||
- **`src/ui/flights/FlightList.tsx`** — scrollable list of `FlightCard` items with empty-state and loading skeleton.
|
||||
- **`src/ui/flights/FlightDetails.tsx`** — expanded flight details view: route map placeholder, status timeline, departure/arrival info, aircraft info, codeshare info.
|
||||
- **`src/ui/flights/StatusBadge.tsx`** — flight status indicator (on time, delayed, cancelled, landed, etc.) with color-coded styling.
|
||||
- **`src/ui/flights/AirportDisplay.tsx`** — airport name + IATA code display with optional city name.
|
||||
- **`src/ui/flights/TimeDisplay.tsx`** — time formatting component (scheduled vs actual, with delay indicator).
|
||||
- **`src/ui/flights/SearchForm.tsx`** — online board search form with flight number input, airport autocomplete (PrimeReact Autocomplete), date picker (PrimeReact Calendar), and search type selector.
|
||||
- **`src/ui/flights/CalendarStrip.tsx`** — horizontal date selector showing available search dates from the calendar API.
|
||||
- **`src/ui/flights/FlightDetailsSkeleton.tsx`** — Suspense fallback skeleton for the details page.
|
||||
- **`src/ui/flights/FlightListSkeleton.tsx`** — Suspense fallback skeleton for search result pages.
|
||||
- **`src/ui/flights/ConnectionStatusBadge.tsx`** — SignalR connection status indicator ("live", "reconnecting", "offline").
|
||||
- **`src/ui/flights/Breadcrumbs.tsx`** — breadcrumb navigation for online board routes.
|
||||
|
||||
**Porting workflow:** per design spec §5.4 — read Angular source, translate template to JSX preserving DOM + class names, translate logic to hooks/props, port SCSS to `.module.scss`, write Vitest test, capture VRT baseline.
|
||||
|
||||
**SCSS files:** Each component has a co-located `.module.scss` file ported from the Angular component's SCSS. Class names preserved for VRT pixel parity.
|
||||
|
||||
**TypeScript contracts (data model types in `src/ui/flights/types.ts`):**
|
||||
|
||||
```ts
|
||||
/** Simplified flight record for list/card display */
|
||||
export interface ISimpleFlight {
|
||||
id: string; // unique flight identifier
|
||||
flightNumber: string; // e.g. "SU 100"
|
||||
carrier: string; // IATA carrier code, e.g. "SU"
|
||||
carrierName: string; // localized carrier name
|
||||
departure: IAirportTime;
|
||||
arrival: IAirportTime;
|
||||
status: FlightStatus;
|
||||
aircraftType?: string;
|
||||
codeshares?: string[];
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export interface IAirportTime {
|
||||
airport: string; // IATA code
|
||||
airportName: string; // localized name
|
||||
cityName: string; // localized city name
|
||||
scheduled: string; // ISO 8601 datetime
|
||||
actual?: string; // ISO 8601 datetime (if available)
|
||||
terminal?: string;
|
||||
gate?: string;
|
||||
}
|
||||
|
||||
export type FlightStatus =
|
||||
| "scheduled"
|
||||
| "delayed"
|
||||
| "departed"
|
||||
| "in_flight"
|
||||
| "landed"
|
||||
| "arrived"
|
||||
| "cancelled"
|
||||
| "diverted"
|
||||
| "unknown";
|
||||
|
||||
/** Parsed flight identifier from URL */
|
||||
export interface IParsedFlightId {
|
||||
carrier: string; // e.g. "SU"
|
||||
flightNumber: string; // e.g. "100"
|
||||
suffix?: string; // optional flight suffix
|
||||
date: string; // yyyyMMdd
|
||||
}
|
||||
|
||||
/** Request type discriminator for online board search */
|
||||
export type FlightRequestType =
|
||||
| "flight" // search by flight number
|
||||
| "departure" // search by departure airport
|
||||
| "arrival" // search by arrival airport
|
||||
| "route"; // search by departure + arrival airports
|
||||
```
|
||||
|
||||
**Package additions (2A):** `primereact` (PrimeReact component library — specific components: Calendar, Autocomplete, Tooltip), `clsx` (conditional class names).
|
||||
|
||||
**Exit gate for 2A:**
|
||||
- Every UI component has a Vitest test rendering it with representative props and asserting key DOM structure.
|
||||
- SCSS modules compile with no errors.
|
||||
- No direct `primereact/*` imports outside `src/ui/` (enforced by 1A-3 ESLint boundary rules).
|
||||
- VRT baselines captured for each component at 375px, 768px, 1440px viewports.
|
||||
- `pnpm typecheck` and `pnpm lint` green.
|
||||
|
||||
---
|
||||
|
||||
### 2B — URL serializer/parser contracts
|
||||
|
||||
**Scope:** TDD port of the Angular URL builder/parser for all 6 online board route shapes. Byte-exact parity with Angular's `OnlineBoardFlightNumberUrlParamsResolver`, `OnlineBoardDepartureUrlParamsResolver`, `OnlineBoardArrivalUrlParamsResolver`, `OnlineBoardRouteUrlParamsResolver`. The Phase 0 URL corpus fixtures are the test oracle.
|
||||
|
||||
**Exports (`src/features/online-board/url.ts`):**
|
||||
|
||||
```ts
|
||||
import type { FlightRequestType, IParsedFlightId } from "@/ui/flights/types";
|
||||
|
||||
/** Discriminated union of all parsed online board URL parameter shapes */
|
||||
export type OnlineBoardParams =
|
||||
| { type: "start" }
|
||||
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
|
||||
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
|
||||
|
||||
/**
|
||||
* Parse a raw URL path segment into typed online board params.
|
||||
* Returns null if the path does not match any known online board URL shape.
|
||||
*
|
||||
* @param routeType - the route prefix ("flight", "departure", "arrival", "route", or "" for details)
|
||||
* @param params - the raw URL parameter string (e.g. "SU100-20250115")
|
||||
*/
|
||||
export function parseOnlineBoardUrl(routeType: string, params: string): OnlineBoardParams | null;
|
||||
|
||||
/**
|
||||
* Build a URL path segment from typed online board params.
|
||||
* Output is byte-exact match with Angular's URL builder.
|
||||
*
|
||||
* @returns the path segment without leading slash (e.g. "flight/SU100-20250115")
|
||||
*/
|
||||
export function buildOnlineBoardUrl(params: OnlineBoardParams): string;
|
||||
|
||||
/**
|
||||
* Parse a flight URL parameter string into its constituent parts.
|
||||
* Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
|
||||
*/
|
||||
export function parseFlightUrlParams(raw: string): IParsedFlightId | null;
|
||||
|
||||
/**
|
||||
* Build a flight URL parameter string from parts.
|
||||
* Output format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
|
||||
*/
|
||||
export function buildFlightUrlParams(id: IParsedFlightId): string;
|
||||
|
||||
/**
|
||||
* Parse a station URL parameter string.
|
||||
* Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseStationUrlParams(raw: string): {
|
||||
station: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Parse a route URL parameter string.
|
||||
* Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseRouteUrlParams(raw: string): {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} | null;
|
||||
```
|
||||
|
||||
**Test strategy:**
|
||||
1. Table-driven tests against the Phase 0 URL corpus (every real URL from prod access logs).
|
||||
2. Round-trip property tests: `buildOnlineBoardUrl(parseOnlineBoardUrl(type, params)) === originalParams` for all valid inputs.
|
||||
3. `fast-check` fuzz tests: random carrier codes (2 chars), flight numbers (1-4 digits), IATA codes (3 chars), dates (valid yyyyMMdd range), optional suffixes.
|
||||
4. Edge cases: missing optional time range, malformed dates, unknown carriers, extra hyphens.
|
||||
|
||||
**Exit gate for 2B:**
|
||||
- 100% of Phase 0 URL corpus fixtures pass round-trip parity.
|
||||
- Fuzz tests with `fast-check` find no serialization asymmetry.
|
||||
- `parseOnlineBoardUrl` returns `null` for invalid inputs (never throws).
|
||||
- Zero `any` types in the module.
|
||||
|
||||
---
|
||||
|
||||
### 2C — API client + hooks contracts
|
||||
|
||||
**Scope:** Online board REST endpoints wrapped in typed functions + React hooks. Consumes `ApiClient` / `CachedApiClient` from Phase 1's 1D.
|
||||
|
||||
**Exports (`src/features/online-board/api.ts`):**
|
||||
|
||||
```ts
|
||||
import type { ISimpleFlight, IParsedFlightId, FlightRequestType } from "@/ui/flights/types";
|
||||
|
||||
/** Response shape from GET /board */
|
||||
export interface BoardResponse {
|
||||
flights: ISimpleFlight[];
|
||||
totalCount: number;
|
||||
date: string;
|
||||
requestType: FlightRequestType;
|
||||
}
|
||||
|
||||
/** Full flight details response from GET /onlineboard/details */
|
||||
export interface FlightDetailsResponse {
|
||||
flight: ISimpleFlight;
|
||||
route: IRoutePoint[];
|
||||
codeshares: ICodeshare[];
|
||||
statusHistory: IStatusHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface IRoutePoint {
|
||||
airport: string;
|
||||
airportName: string;
|
||||
cityName: string;
|
||||
scheduledTime: string;
|
||||
actualTime?: string;
|
||||
terminal?: string;
|
||||
gate?: string;
|
||||
}
|
||||
|
||||
export interface ICodeshare {
|
||||
carrier: string;
|
||||
carrierName: string;
|
||||
flightNumber: string;
|
||||
}
|
||||
|
||||
export interface IStatusHistoryEntry {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Calendar days response from GET /v1/days/.../board/ */
|
||||
export type CalendarDaysResponse = string[]; // array of "yyyy-MM-dd" date strings
|
||||
|
||||
/**
|
||||
* Search flights on the online board.
|
||||
* Maps to: GET /board?type={type}&station={station}&date={date}&...
|
||||
*/
|
||||
export function searchFlights(
|
||||
client: ApiClient,
|
||||
params: {
|
||||
type: FlightRequestType;
|
||||
date: string;
|
||||
station?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
carrier?: string;
|
||||
flightNumber?: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
},
|
||||
): Promise<BoardResponse>;
|
||||
|
||||
/**
|
||||
* Get flight details.
|
||||
* Maps to: GET /onlineboard/details?flightId={carrier}{number}&date={date}
|
||||
*/
|
||||
export function getFlightDetails(
|
||||
client: ApiClient,
|
||||
id: IParsedFlightId,
|
||||
): Promise<FlightDetailsResponse>;
|
||||
|
||||
/**
|
||||
* Get available calendar days for a given search context.
|
||||
* Maps to: GET /v1/days/{station|route}/board/
|
||||
*/
|
||||
export function getCalendarDays(
|
||||
client: ApiClient,
|
||||
params: {
|
||||
type: FlightRequestType;
|
||||
station?: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
},
|
||||
): Promise<CalendarDaysResponse>;
|
||||
```
|
||||
|
||||
**Exports (`src/features/online-board/hooks/useOnlineBoard.ts`):**
|
||||
|
||||
```ts
|
||||
import type { BoardResponse } from "../api";
|
||||
import type { OnlineBoardParams } from "../url";
|
||||
|
||||
export interface UseOnlineBoardResult {
|
||||
data: BoardResponse;
|
||||
calendarDays: string[];
|
||||
isRefetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for online board search pages.
|
||||
* SSR: receives initialData from the loader.
|
||||
* Client: re-fetches on param change, receives live updates from SignalR (via 2D).
|
||||
*/
|
||||
export function useOnlineBoard(
|
||||
params: OnlineBoardParams,
|
||||
initialData: BoardResponse,
|
||||
initialCalendarDays: string[],
|
||||
): UseOnlineBoardResult;
|
||||
```
|
||||
|
||||
**Exports (`src/features/online-board/hooks/useFlightDetails.ts`):**
|
||||
|
||||
```ts
|
||||
import type { FlightDetailsResponse } from "../api";
|
||||
import type { IParsedFlightId } from "@/ui/flights/types";
|
||||
|
||||
export interface UseFlightDetailsResult {
|
||||
data: FlightDetailsResponse;
|
||||
isRefetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the flight details page.
|
||||
* SSR: receives initialData from the loader.
|
||||
* Client: re-fetches on param change, receives live updates from SignalR (via 2D).
|
||||
*/
|
||||
export function useFlightDetails(
|
||||
id: IParsedFlightId,
|
||||
initialData: FlightDetailsResponse,
|
||||
): UseFlightDetailsResult;
|
||||
```
|
||||
|
||||
**Caching strategy (per design spec §4.2):**
|
||||
- `searchFlights`: 30s TTL (live data), server LRU + client memory cache via `CachedApiClient`.
|
||||
- `getFlightDetails`: 30s TTL (live data), same caching layers.
|
||||
- `getCalendarDays`: 5 min TTL (static reference data).
|
||||
|
||||
**Exit gate for 2C:**
|
||||
- Vitest tests cover: successful API call + response deserialization for all three endpoints; error mapping (404 → `ApiHttpError`, timeout → `ApiTimeoutError`); cache hit for repeated identical queries; hooks render with initial data and update on refetch.
|
||||
- `useOnlineBoard` and `useFlightDetails` hooks tested with `@testing-library/react-hooks` for SSR initial data pass-through and client-side refetch behavior.
|
||||
- Zero `any` types.
|
||||
|
||||
---
|
||||
|
||||
### 2D — SignalR wiring contracts
|
||||
|
||||
**Scope:** Connect the generic `useLiveFlights` hook from Phase 1's 1E to the real TrackerHub channels used by Online Board. Wire into the search and details hooks from 2C.
|
||||
|
||||
**Exports (`src/features/online-board/hooks/useLiveBoard.ts`):**
|
||||
|
||||
```ts
|
||||
import type { ConnectionStatus } from "@/shared/signalr/connection";
|
||||
import type { BoardResponse } from "../api";
|
||||
|
||||
export interface UseLiveBoardResult {
|
||||
data: BoardResponse;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps useLiveFlights with Online Board-specific channel configuration.
|
||||
* Channel: SubscribeDate(date, departure?, arrival?)
|
||||
* On RefreshDate push: triggers silent re-fetch of searchFlights().
|
||||
*/
|
||||
export function useLiveBoard(
|
||||
params: { date: string; departure?: string; arrival?: string },
|
||||
initialData: BoardResponse,
|
||||
): UseLiveBoardResult;
|
||||
```
|
||||
|
||||
**Exports (`src/features/online-board/hooks/useLiveFlightDetails.ts`):**
|
||||
|
||||
```ts
|
||||
import type { ConnectionStatus } from "@/shared/signalr/connection";
|
||||
import type { FlightDetailsResponse } from "../api";
|
||||
import type { IParsedFlightId } from "@/ui/flights/types";
|
||||
|
||||
export interface UseLiveFlightDetailsResult {
|
||||
data: FlightDetailsResponse;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps useLiveFlights with flight details-specific channel configuration.
|
||||
* Channel: Subscribe(flightId@date)
|
||||
* On push: triggers silent re-fetch of getFlightDetails().
|
||||
*/
|
||||
export function useLiveFlightDetails(
|
||||
id: IParsedFlightId,
|
||||
initialData: FlightDetailsResponse,
|
||||
): UseLiveFlightDetailsResult;
|
||||
```
|
||||
|
||||
**TrackerHub channel mapping:**
|
||||
- Search pages: `SubscribeDate(date, departure?, arrival?)` — server pushes `RefreshDate` when any flight matching the query updates.
|
||||
- Details page: `Subscribe({carrier}{flightNumber}@{date})` — server pushes updates when the specific flight changes.
|
||||
- On push: the hook triggers a silent re-fetch of the corresponding REST endpoint (no full-page reload, no flash). The re-fetched data replaces the current state atomically.
|
||||
|
||||
**Integration with 2C hooks:** `useOnlineBoard` (2C) internally delegates to `useLiveBoard` (2D) for its live data; `useFlightDetails` (2C) internally delegates to `useLiveFlightDetails` (2D). The 2C hooks are the public API; 2D hooks are internal wiring.
|
||||
|
||||
**Exit gate for 2D:**
|
||||
- Vitest: mock SignalR hub pushes `RefreshDate` → `useLiveBoard` triggers re-fetch and returns updated data.
|
||||
- Vitest: mock SignalR hub pushes flight update → `useLiveFlightDetails` triggers re-fetch.
|
||||
- Strict Mode double-mount: exactly one `HubConnection.start()` call (inherits from 1E's guarantee).
|
||||
- Disconnect scenario: `connectionStatus` transitions to `"offline"`, data remains last-known-good.
|
||||
- SSR: returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr`.
|
||||
|
||||
---
|
||||
|
||||
### 2E — Routes + pages contracts
|
||||
|
||||
**Scope:** All `src/routes/[lang]/onlineboard/*` route files with loaders, Suspense, `React.lazy`. Also includes feature-specific components in `src/features/online-board/components/` that are not shared across features.
|
||||
|
||||
**Route files:**
|
||||
|
||||
| Route file | URL pattern | Angular equivalent |
|
||||
|---|---|---|
|
||||
| `src/routes/[lang]/onlineboard/page.tsx` | `/{lang}/onlineboard` | Start page (search form) |
|
||||
| `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` | `/{lang}/onlineboard/flight/SU100-20250115` | Flight number search |
|
||||
| `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` | `/{lang}/onlineboard/departure/SVO-20250115` | Departure station search |
|
||||
| `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` | `/{lang}/onlineboard/arrival/JFK-20250115` | Arrival station search |
|
||||
| `src/routes/[lang]/onlineboard/route/[params]/page.tsx` | `/{lang}/onlineboard/route/SVO-JFK-20250115` | Route search |
|
||||
| `src/routes/[lang]/onlineboard/[params]/page.tsx` | `/{lang}/onlineboard/SU100-20250115` | Flight details |
|
||||
|
||||
**Feature-specific components (`src/features/online-board/components/`):**
|
||||
|
||||
- **`OnlineBoardStart.tsx`** — start page with search form, search history, and popular routes.
|
||||
- **`OnlineBoardSearch.tsx`** — search results page (shared by flight/departure/arrival/route searches) with flight list, calendar strip, connection status badge, and filter controls.
|
||||
- **`OnlineBoardDetails.tsx`** — flight details page with full flight info, status timeline, route points, codeshare info, and connection status badge.
|
||||
|
||||
**Loader pattern (per design spec §3.4):**
|
||||
|
||||
```tsx
|
||||
// Example: routes/[lang]/onlineboard/departure/[params]/page.tsx
|
||||
import { lazy, Suspense } from "react";
|
||||
const OnlineBoardSearch = lazy(() =>
|
||||
import("@/features/online-board").then(m => ({ default: m.OnlineBoardSearch }))
|
||||
);
|
||||
|
||||
export async function loader({ params }: { params: { lang: string; params: string } }) {
|
||||
const parsed = parseOnlineBoardUrl("departure", params.params);
|
||||
if (!parsed || parsed.type !== "departure") throw new ApiHttpError("Not found", 404);
|
||||
const [data, calendarDays] = await Promise.all([
|
||||
searchFlights(apiClient, { type: "departure", station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }),
|
||||
getCalendarDays(apiClient, { type: "departure", station: parsed.station }),
|
||||
]);
|
||||
const seo = buildOnlineBoardSeo(parsed, cityNames);
|
||||
return { data, calendarDays, seo, parsed };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { data, calendarDays, seo, parsed } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<>
|
||||
<SeoHead {...seo} />
|
||||
<Suspense fallback={<FlightListSkeleton />}>
|
||||
<OnlineBoardSearch initialData={data} initialCalendarDays={calendarDays} params={parsed} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Feature barrel (`src/features/online-board/index.ts`) — populated in 2E:**
|
||||
|
||||
```ts
|
||||
// Public surface of the online-board feature
|
||||
export { OnlineBoardStart } from "./components/OnlineBoardStart";
|
||||
export { OnlineBoardSearch } from "./components/OnlineBoardSearch";
|
||||
export { OnlineBoardDetails } from "./components/OnlineBoardDetails";
|
||||
export { parseOnlineBoardUrl, buildOnlineBoardUrl } from "./url";
|
||||
export { searchFlights, getFlightDetails, getCalendarDays } from "./api";
|
||||
export { buildOnlineBoardSeo } from "./seo";
|
||||
export type { BoardResponse, FlightDetailsResponse, CalendarDaysResponse } from "./api";
|
||||
export type { OnlineBoardParams } from "./url";
|
||||
```
|
||||
|
||||
**MF expose (`src/mf/expose/OnlineBoard.tsx`) — updated from stub to real in 2E:**
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { HostContract } from "@/host-contract";
|
||||
|
||||
const OnlineBoardFeature = lazy(() =>
|
||||
import("@/features/online-board").then(m => ({ default: m.OnlineBoardRoot }))
|
||||
);
|
||||
|
||||
export default function OnlineBoard({ hostContract }: { hostContract: HostContract }) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OnlineBoardFeature hostContract={hostContract} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This requires an `OnlineBoardRoot` component exported from the feature barrel that wraps the internal router for embedded MF usage.
|
||||
|
||||
**Exit gate for 2E:**
|
||||
- All 6 routes render via SSR with correct loader data.
|
||||
- URL parameters parsed correctly for all route shapes.
|
||||
- Invalid URL parameters return 404 (via `errorToResponse` from 1F-layout).
|
||||
- `React.lazy()` + `<Suspense>` pattern verified on every page.
|
||||
- Feature barrel exports only the public surface (no internal component leakage).
|
||||
- MF expose wrapper renders the feature root in a test host.
|
||||
- `pnpm typecheck` and `pnpm lint` green.
|
||||
|
||||
---
|
||||
|
||||
### 2F — SEO + JSON-LD contracts
|
||||
|
||||
**Scope:** Build the `buildOnlineBoardSeo()` function for each online board route type. Produce JSON-LD schemas (`Flight` for details, `ItemList` of `Flight` for search results). Consume `SeoHead`, `buildHreflangSet`, and `JsonLdRenderer` from Phase 1's 1F-seo.
|
||||
|
||||
**Exports (`src/features/online-board/seo.ts`):**
|
||||
|
||||
```ts
|
||||
import type { SeoHeadProps } from "@/ui/seo/SeoHead";
|
||||
import type { OnlineBoardParams } from "./url";
|
||||
import type { ISimpleFlight, FlightDetailsResponse } from "./api";
|
||||
|
||||
/**
|
||||
* Build SeoHead props for any online board route.
|
||||
* Produces: title, description, canonical, hreflang, OG tags, JSON-LD.
|
||||
*
|
||||
* JSON-LD schemas:
|
||||
* - Flight details → schema.org/Flight
|
||||
* - Search results → schema.org/ItemList containing Flight items
|
||||
* - Start page → schema.org/WebPage with SearchAction
|
||||
*/
|
||||
export function buildOnlineBoardSeo(
|
||||
params: OnlineBoardParams,
|
||||
context: {
|
||||
cityNames: Record<string, string>; // IATA code → localized city name
|
||||
locale: string;
|
||||
canonicalOrigin: string;
|
||||
flights?: ISimpleFlight[];
|
||||
flightDetails?: FlightDetailsResponse;
|
||||
},
|
||||
): SeoHeadProps;
|
||||
|
||||
/**
|
||||
* Build a JSON-LD Flight object from flight details data.
|
||||
* Uses schema-dts types for type safety.
|
||||
*/
|
||||
export function buildFlightJsonLd(
|
||||
flight: FlightDetailsResponse,
|
||||
locale: string,
|
||||
): import("schema-dts").Flight;
|
||||
|
||||
/**
|
||||
* Build a JSON-LD ItemList of Flight objects from search results.
|
||||
*/
|
||||
export function buildFlightSearchResultsJsonLd(
|
||||
flights: ISimpleFlight[],
|
||||
params: OnlineBoardParams,
|
||||
locale: string,
|
||||
): import("schema-dts").ItemList;
|
||||
```
|
||||
|
||||
**Title/description strategy:** Ported from Angular's translation keys. Pattern:
|
||||
- Start page: `t("onlineboard.seo.start.title")` / `t("onlineboard.seo.start.description")`
|
||||
- Flight search: `t("onlineboard.seo.flight.title", { flightNumber })` / `t("onlineboard.seo.flight.description", { flightNumber, date })`
|
||||
- Departure: `t("onlineboard.seo.departure.title", { cityName })` / `t("onlineboard.seo.departure.description", { cityName, date })`
|
||||
- Arrival: `t("onlineboard.seo.arrival.title", { cityName })` / etc.
|
||||
- Route: `t("onlineboard.seo.route.title", { departureCity, arrivalCity })` / etc.
|
||||
- Details: `t("onlineboard.seo.details.title", { flightNumber, carrier })` / etc.
|
||||
|
||||
**JSON-LD schema details:**
|
||||
|
||||
Flight details page emits `schema.org/Flight`:
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Flight",
|
||||
"flightNumber": "SU 100",
|
||||
"provider": { "@type": "Airline", "iataCode": "SU", "name": "Aeroflot" },
|
||||
"departureAirport": { "@type": "Airport", "iataCode": "SVO", "name": "Sheremetyevo", "address": { "@type": "PostalAddress", "addressCountry": "RU" } },
|
||||
"arrivalAirport": { "@type": "Airport", "iataCode": "JFK", ... },
|
||||
"departureTime": "2025-01-15T10:00:00+03:00",
|
||||
"arrivalTime": "2025-01-15T14:30:00-05:00",
|
||||
"flightDistance": { "@type": "Distance", "value": "9200 km" }
|
||||
}
|
||||
```
|
||||
|
||||
Search results pages emit `schema.org/ItemList` containing `Flight` items.
|
||||
|
||||
**OG images:** Phase 2 enhancement per design spec §6.7 — dynamic per-flight OG images via Satori, served from `routes/og/flight/[params]/image.tsx`, cached `s-maxage=86400`. Falls back to the static default OG image from Phase 1 if Satori generation fails.
|
||||
|
||||
**Exit gate for 2F:**
|
||||
- `buildOnlineBoardSeo` produces valid `SeoHeadProps` for all 6 route types.
|
||||
- JSON-LD output validates against `schema-dts` types at compile time.
|
||||
- CI JSON-LD validation job passes (structured data validator against fixture renders).
|
||||
- `buildHreflangSet` produces correct 9-language + `x-default` set for each route.
|
||||
- OG tags present and correct for each route type.
|
||||
- SSR render + `cheerio` parse asserts `<title>`, `<meta name="description">`, `<link rel="canonical">`, `<link rel="alternate" hreflang>`, `<meta property="og:*">`, `<script type="application/ld+json">`.
|
||||
|
||||
---
|
||||
|
||||
### 2G — Parity harnesses contracts
|
||||
|
||||
**Scope:** Build the URL parity harness and SEO parity harness that were deferred from Phase 1 (the "deferred 1J" mentioned in the Phase 1 master plan). These harnesses test against the real Online Board feature, not the synthetic smoke route. Also establish VRT baselines for all online board routes.
|
||||
|
||||
**Exports:**
|
||||
|
||||
- **`tests/parity/url-parity.test.ts`** — table-driven Vitest test that reads the Phase 0 URL corpus from `tests/fixtures/phase-0/url-corpus-onlineboard.json` and asserts that `parseOnlineBoardUrl` + `buildOnlineBoardUrl` produce byte-exact matches. Merge-blocking CI gate.
|
||||
|
||||
- **`tests/parity/seo-parity.test.ts`** — Vitest test that SSR-renders each online board route type, parses the HTML with `cheerio`, and compares the SEO elements against Phase 0 baselines from `tests/fixtures/phase-0/seo-baselines-onlineboard.json`. Checks: `<title>`, `<meta name="description">`, `<link rel="canonical">`, hreflang set, OG tags. JSON-LD presence and schema type checked (content differs from Angular since Angular had no JSON-LD — this is an enhancement, not parity).
|
||||
|
||||
- **`tests/vrt/onlineboard/`** — Playwright VRT baseline screenshots for all 6 route types at 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en). Total: 36 baseline images. Threshold: 0.1% pixel diff (configurable). Committed under `tests/fixtures/phase-2/vrt/`.
|
||||
|
||||
- **`scripts/parity/run-url-parity.ts`** — standalone script to run URL parity checks outside of CI (for local development). Reads the corpus, runs both parse and build, reports mismatches with diffs.
|
||||
|
||||
**Exit gate for 2G:**
|
||||
- URL parity: 100% of the Phase 0 corpus passes.
|
||||
- SEO parity: all baseline comparisons pass (with documented exceptions for JSON-LD enhancement).
|
||||
- VRT baselines committed and CI gate functional (diff threshold violation blocks merge).
|
||||
- Parity harness documentation in `tests/parity/README.md` explains how to add new test URLs and update baselines.
|
||||
|
||||
---
|
||||
|
||||
### 2H — Integration tests contracts
|
||||
|
||||
**Scope:** Playwright end-to-end tests covering the full Online Board feature in standalone SSR mode. Ported from the 4 passing Cypress scenarios + new tests for SignalR, error cases, and edge cases.
|
||||
|
||||
**Test inventory:**
|
||||
|
||||
1. **Start page** — navigates to `/{lang}/onlineboard`, verifies search form renders, submits a flight number search, arrives at the correct search results URL.
|
||||
2. **Departure search** — navigates to a departure URL, verifies flight list renders with correct airport, verifies calendar strip shows available dates, clicks a different date and verifies URL update.
|
||||
3. **Flight details** — navigates to a details URL, verifies full flight info renders (status, route, codeshares), verifies breadcrumbs.
|
||||
4. **Language switch** — navigates to `/ru/onlineboard/...`, switches language to `en`, verifies URL updates to `/en/onlineboard/...` with same parameters, verifies content is in English.
|
||||
5. **SignalR live update** — mocked SignalR hub pushes a `RefreshDate` event, verifies the flight list updates without page reload, verifies connection status badge shows "live".
|
||||
6. **SignalR disconnect** — mocked SignalR hub disconnects, verifies "offline" badge appears, verifies last-known data remains displayed.
|
||||
7. **404 handling** — navigates to an invalid onlineboard URL, verifies 404 page renders with correct HTTP status.
|
||||
8. **API error handling** — mocked API returns 500, verifies error UI renders (not a white screen), verifies "Retry" button triggers re-fetch.
|
||||
9. **Empty results** — search returns zero flights, verifies empty-state UI renders.
|
||||
10. **Responsive** — verifies all pages render correctly at 375px, 768px, 1440px without horizontal scroll.
|
||||
|
||||
**SignalR mock server:**
|
||||
|
||||
```ts
|
||||
// tests/mocks/signalr-mock-server.ts
|
||||
export class MockSignalRServer {
|
||||
constructor(port: number);
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
pushRefreshDate(date: string, departure?: string, arrival?: string): void;
|
||||
pushFlightUpdate(flightId: string, date: string): void;
|
||||
getSubscriptions(): { channel: string; args: unknown[] }[];
|
||||
}
|
||||
```
|
||||
|
||||
The mock server implements the TrackerHub protocol: accepts `SubscribeDate` and `Subscribe` invocations, records them, and allows tests to programmatically push events.
|
||||
|
||||
**Exit gate for 2H:**
|
||||
- All 10 Playwright test scenarios pass.
|
||||
- SignalR mock server correctly simulates push events and disconnect.
|
||||
- Tests run in CI in under 5 minutes.
|
||||
- No flaky tests (3 consecutive CI runs green).
|
||||
|
||||
---
|
||||
|
||||
## Shared files — cross-sub-plan modification table
|
||||
|
||||
| File | Primary owner | Also modified by | What the modifiers add |
|
||||
|---|---|---|---|
|
||||
| `src/features/online-board/index.ts` | 1A-1 (empty barrel) | 2E | Populated with all public exports (components, URL functions, API functions, SEO builders, types) |
|
||||
| `src/mf/expose/OnlineBoard.tsx` | 1A-2 (stub) | 2E | Updated from stub to real: imports `OnlineBoardRoot` from feature barrel, renders with `React.lazy` + `Suspense` |
|
||||
| `src/ui/flights/types.ts` | 2A | 2B, 2C, 2D, 2E, 2F | Consumed (read-only) by all downstream sub-plans for type imports |
|
||||
| `src/observability/analytics/adapters/*.ts` | 1G-analytics (stubs) | 2E | Real vendor script loading wired (Yandex.Metrica, CTM, Variocube, Dynatrace) — replaces structured stubs with real implementations when A7 is resolved |
|
||||
| `package.json` | 1A-1 | 2A | `primereact`, `clsx` added |
|
||||
| | | 2B | `fast-check` (dev dep) |
|
||||
| | | 2H | `@playwright/test` (if not already from Phase 1) |
|
||||
|
||||
**Modification protocol** — same as Phase 1: downstream sub-plan tasks explicitly reference the primary owner, quote the pre-modification state, show the full post-modification file, and re-run the owner's exit-gate tests.
|
||||
|
||||
---
|
||||
|
||||
## Spec-coverage matrix
|
||||
|
||||
| Spec section | Topic | Sub-plan(s) |
|
||||
|---|---|---|
|
||||
| §3.3 | Routing — file-based, precedence | 2E |
|
||||
| §3.4 | Loaders, Suspense, `React.lazy` | 2E |
|
||||
| §3.5 | URL parity — ported serializers | 2B |
|
||||
| §3.6 | Canonical, hreflang, redirects | 2F |
|
||||
| §4.4 | SignalR wrapper + hook | 2D |
|
||||
| §4.5 | State management (hooks, reducers) | 2C, 2D |
|
||||
| §4.6 | Error handling path | 2C, 2E |
|
||||
| §5.1 | UI adapter boundary | 2A |
|
||||
| §5.2 | SCSS Modules | 2A |
|
||||
| §5.3 | PrimeReact theming | 2A |
|
||||
| §5.4 | Per-component porting workflow | 2A |
|
||||
| §5.7 | Responsiveness | 2A, 2H |
|
||||
| §6.5 | `<SeoHead>` usage | 2F |
|
||||
| §6.6 | JSON-LD schema coverage (Flight, ItemList) | 2F |
|
||||
| §6.7 | OG images (dynamic per-flight) | 2F |
|
||||
| §6.8 | Canonical + hreflang correctness | 2F, 2G |
|
||||
| §8.4 | Testing strategy (URL parity, VRT, Playwright) | 2G, 2H |
|
||||
| §9.2 | Phase 2 scope + exit gate | All |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 global exit gate — checklist
|
||||
|
||||
- [ ] **2A:** All UI flight-display components have Vitest tests + VRT baselines; no direct `primereact/*` imports outside `src/ui/`; SCSS compiles clean.
|
||||
- [ ] **2B:** 100% of Phase 0 URL corpus passes round-trip parity; `fast-check` fuzz tests green; `parseOnlineBoardUrl` returns `null` (never throws) on invalid input.
|
||||
- [ ] **2C:** API client functions tested for all three endpoints; hooks tested with initial data + refetch; cache behavior verified; zero `any` types.
|
||||
- [ ] **2D:** SignalR wiring tested: push → re-fetch → updated data; Strict Mode safe; disconnect → offline badge + last-known data; SSR returns idle.
|
||||
- [ ] **2E:** All 6 routes render SSR with correct loaders; invalid params → 404; `React.lazy` + `Suspense` on every page; feature barrel exports only public surface; MF expose wrapper functional.
|
||||
- [ ] **2F:** `buildOnlineBoardSeo` produces valid output for all 6 route types; JSON-LD validates against `schema-dts`; hreflang reciprocal across 9 languages; OG tags correct; CI JSON-LD validator passes.
|
||||
- [ ] **2G:** URL parity 100% against Phase 0 corpus; SEO parity baselines pass; 36 VRT baselines committed and CI gate functional.
|
||||
- [ ] **2H:** All 10 Playwright scenarios pass; SignalR mock server functional; 3 consecutive CI runs green (no flakes); tests complete in under 5 minutes.
|
||||
- [ ] **Load test:** Online board routes sustain 150 RPS with p95 latency under 500ms.
|
||||
- [ ] **WCAG AA:** `@axe-core/playwright` violations block (upgraded from Phase 1 warn-only).
|
||||
- [ ] **Analytics:** Real vendor scripts (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging`.
|
||||
- [ ] **Phase 1 regression:** All Phase 1 exit gates still green on `main`.
|
||||
- [ ] **Security scan:** `osv-scanner` + `npm audit` green after Phase 2 dependency additions.
|
||||
- [ ] **Bundle size:** Online board feature chunk within budget (budget TBD in 2A based on Angular bundle analysis).
|
||||
|
||||
---
|
||||
|
||||
## Risks + open questions for Phase 2
|
||||
|
||||
1. **PrimeReact vs PrimeNG DOM differences (T2).** Design spec §5.3 identifies Calendar, Autocomplete, DataTable, and Toast as likely failure cases. 2A absorbs this risk in `src/ui/primitives/` wrappers + compensating CSS. Unresolved diffs go to `docs/visual-parity-exceptions.md`.
|
||||
|
||||
2. **SignalR TrackerHub protocol undocumented.** The Angular source is the only specification. 2D must reverse-engineer the exact channel names and message shapes. Risk: Angular uses implicit conventions that aren't obvious from source reading alone. Mitigation: run the Angular app against a test hub and capture wire traffic during 2D.
|
||||
|
||||
3. **Analytics vendor credentials (A7) still unknown.** If A7 is unresolved when 2E starts, the analytics adapters stay as structured stubs and the "real vendors in testing/staging" exit gate defers to Phase 3. The rest of Phase 2 is not blocked.
|
||||
|
||||
4. **Phase 0 URL corpus coverage.** The parity harness is only as good as the corpus. If prod access logs are incomplete (e.g., rare URL shapes not in logs), parity gaps may surface post-cutover. Mitigation: `fast-check` fuzz tests in 2B supplement the corpus with randomized inputs.
|
||||
|
||||
5. **Dynamic OG images via Satori.** Satori is a runtime dependency for generating per-flight OG images. If Satori proves unreliable or slow, Phase 2 falls back to the static default OG image and defers dynamic images to a follow-up.
|
||||
|
||||
6. **Load test at 150 RPS.** Phase 1's smoke route may not be representative of Online Board's SSR cost (which includes API calls, SignalR connection setup, and JSON-LD generation). Load test infrastructure must mock upstream APIs at realistic latencies.
|
||||
|
||||
---
|
||||
|
||||
## Cutover plan (from design spec §9.2)
|
||||
|
||||
1. Deploy to `staging`; run full test suite + load test + SEO audit.
|
||||
2. Canary 5% of `/{lang}/onlineboard/*` prod traffic for 24h (request-id hash bucket behind proxy); rest stays on Angular.
|
||||
3. Monitor: error rate, p95 latency, `flights.react.error`, `flights.api.error`, SignalR health, Web Vitals, Search Console crawl errors.
|
||||
4. If clean: 25% → 50% → 100% over 72h, always reversible.
|
||||
5. Hold at 100% for 1 week. Then retire (not delete) Angular online-board code.
|
||||
|
||||
**Cutover is NOT part of the sub-plans** — it executes after 2H passes and is tracked as an operational procedure, not a development task.
|
||||
|
||||
---
|
||||
|
||||
## How to write each sub-plan
|
||||
|
||||
When the user is ready to execute a sub-plan, re-invoke `superpowers:writing-plans` with a specific prompt like:
|
||||
|
||||
> "Write sub-plan 2A (UI adapter layer) from `docs/superpowers/plans/2026-04-14-phase-2-online-board-master.md`. Target file: `docs/superpowers/plans/2026-04-14-phase-2a-ui-flights.md`. Follow the contracts defined in the master plan §2A exactly; reference the design spec §5 as source material and the Angular component inventory from Phase 0."
|
||||
|
||||
The sub-plan writer must:
|
||||
|
||||
1. Read this master plan in full for the dependency + contract context.
|
||||
2. Read the Phase 1 master plan for the contracts Phase 2 consumes.
|
||||
3. Read the relevant design spec sections.
|
||||
4. Read any upstream sub-plans that have already been written (their exit gates lock in file/API shapes).
|
||||
5. Produce a fully TDD-granular plan at the shape of the Phase 1 sub-plan format.
|
||||
6. Match the contracts in this master plan byte-for-byte on type signatures. Any contract change requires updating this master plan first.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage.** Every Phase 2-relevant design-spec section (§3.3-3.6, §4.4-4.6, §5, §6.5-6.8, §8.4, §9.2) maps to at least one sub-plan in the spec-coverage matrix.
|
||||
|
||||
**Placeholder scan.** No `TBD` / `TODO` / `FIXME` outside of the "TBW" markers on sub-plan filenames and the bundle-size budget note (which is deliberately deferred to 2A since it depends on Angular bundle analysis).
|
||||
|
||||
**Internal consistency.** Cross-checked: `ISimpleFlight` + `IParsedFlightId` + `FlightRequestType` defined in 2A → consumed by 2B, 2C, 2D, 2E, 2F; `OnlineBoardParams` defined in 2B → consumed by 2C, 2E, 2F; `BoardResponse` + `FlightDetailsResponse` defined in 2C → consumed by 2D, 2E, 2F; `SeoHeadProps` from 1F-seo → consumed by 2F; `useLiveFlights` from 1E → consumed by 2D; `ApiClient` from 1D → consumed by 2C; `SignalRConnection` from 1E → consumed by 2D.
|
||||
|
||||
**Phase 1 contract consumption.** Every Phase 1 contract used by Phase 2 is listed in the prerequisite section. No Phase 2 sub-plan creates functionality already provided by Phase 1 — it only wires and extends.
|
||||
|
||||
**Dependency graph acyclicity.** The graph is a strict DAG: 2A → 2C → 2D → 2E → 2F → 2G → 2H, with 2B as an independent node feeding into 2E. No cycles.
|
||||
|
||||
---
|
||||
|
||||
## Next step
|
||||
|
||||
- **If you approve this master plan:** say so, and I'll write sub-plan **2A** (UI adapter layer) in the next session.
|
||||
- **If you want changes:** tell me, I revise.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Phase 2A -- UI Adapter Layer
|
||||
|
||||
> Sub-plan of Phase 2 (Online Board). Implements the flight-display component library at `src/ui/flights/` and supporting utilities.
|
||||
|
||||
**Depends on:** Phase 1 (foundation complete).
|
||||
**Consumed by:** 2C (API hooks use types), 2E (pages compose components).
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Data model types (`src/features/online-board/types.ts`)
|
||||
|
||||
Port the key Angular interfaces to minimal React-friendly TypeScript types. Source analysis:
|
||||
|
||||
- Angular `ISimpleFlight` = `IDirectFlight | IMultiLegFlight` (`ClientApp/src/typings/flight/flight.ts`)
|
||||
- `IFlightLeg` with stations, times, status (`ClientApp/src/typings/flight/flight-leg.ts`)
|
||||
- `IFlightLegStation` with `IAirportInfo` (`ClientApp/src/typings/flight/flight-station.ts`)
|
||||
- `ITimesSet` with dayChange, local, utc, tzOffset (`ClientApp/src/typings/times.ts`)
|
||||
- `FlightRequestType` enum mapping from Angular `RequestMode` (`ClientApp/src/typings/enums.ts`)
|
||||
- `IParsedFlightId` (`ClientApp/src/typings/flight/flight-id.ts`)
|
||||
- `IBoardResponse`, `IDaysResponse` (`ClientApp/src/typings/responses.ts`)
|
||||
|
||||
Flatten into idiomatic TS -- no class hierarchies, no Angular deps.
|
||||
|
||||
**Test:** Type-level tests (compile-time satisfaction checks) in `src/features/online-board/types.test.ts`.
|
||||
|
||||
### Task 2: Datetime utility functions (`src/shared/utils/datetime/`)
|
||||
|
||||
Pure functions ported from Angular pipes:
|
||||
|
||||
- `formatDuration(minutes: number): string` -- from `DurationPipe` logic
|
||||
- `formatTime(date: string | Date): string` -- "HH:mm" format
|
||||
- `formatDate(date: string | Date, locale?: string): string` -- localized date string
|
||||
- `isDayChange(scheduledDate: string | Date, actualDate: string | Date): number` -- day offset
|
||||
|
||||
**Test:** `src/shared/utils/datetime/datetime.test.ts` -- TDD, write tests first.
|
||||
|
||||
### Task 3: Airport/city dictionary hook (`src/shared/hooks/useDictionaries.ts`)
|
||||
|
||||
- `useCityName(code: string): string` -- returns city name for IATA code
|
||||
- Phase 2 stub: returns the code itself (passthrough) with TODO for real API
|
||||
- Angular source: `DictionariesService` fetches from `networkService.getDictionary('cities')` -- API endpoint TBD from customer
|
||||
|
||||
**Test:** `src/shared/hooks/useDictionaries.test.ts` -- verify passthrough behavior.
|
||||
|
||||
### Task 4: `StationDisplay.tsx` component
|
||||
|
||||
Renders airport IATA code + city name. Uses `useCityName` hook.
|
||||
|
||||
Props: `{ airportCode: string; airportName?: string; cityName?: string }`.
|
||||
|
||||
No test (UI component -- visual testing in 2G).
|
||||
|
||||
### Task 5: `TimeGroup.tsx` component
|
||||
|
||||
Displays scheduled + actual times with day-change indicator.
|
||||
|
||||
Props: `{ scheduled: string; actual?: string; dayChange?: number; label?: string }`.
|
||||
|
||||
Uses `formatTime` utility.
|
||||
|
||||
### Task 6: `FlightStatus.tsx` component
|
||||
|
||||
Status badge with semantic styling.
|
||||
|
||||
Props: `{ status: FlightStatus }`.
|
||||
|
||||
Maps status to display label + CSS class.
|
||||
|
||||
### Task 7: `DurationDisplay.tsx` component
|
||||
|
||||
Flight duration display.
|
||||
|
||||
Props: `{ minutes: number }`.
|
||||
|
||||
Uses `formatDuration` utility.
|
||||
|
||||
### Task 8: `FlightCard.tsx` component
|
||||
|
||||
Composes Station + TimeGroup + Status + Duration into a flight row.
|
||||
|
||||
Props: `{ flight: ISimpleFlight }`.
|
||||
|
||||
### Task 9: `FlightListSkeleton.tsx` component
|
||||
|
||||
Loading placeholder for flight list. Renders N placeholder rows with CSS animation.
|
||||
|
||||
Props: `{ count?: number }`.
|
||||
|
||||
### Task 10: `FlightList.tsx` component
|
||||
|
||||
List of FlightCards with loading state.
|
||||
|
||||
Props: `{ flights: ISimpleFlight[]; loading?: boolean }`.
|
||||
|
||||
Uses `FlightListSkeleton` when loading.
|
||||
|
||||
### Task 11: Update barrel exports
|
||||
|
||||
- `src/ui/flights/index.ts` -- export all components and types
|
||||
- `src/ui/index.ts` -- re-export from `./flights`
|
||||
|
||||
---
|
||||
|
||||
## Exit criteria
|
||||
|
||||
- `pnpm typecheck` green
|
||||
- `pnpm lint` green
|
||||
- `pnpm test` green with datetime utility tests + type satisfaction tests + dictionary hook test passing
|
||||
- No Angular dependencies in any new file
|
||||
- All components are pure functional React components with typed props
|
||||
@@ -0,0 +1,152 @@
|
||||
# Phase 2B — URL Serializer/Parser
|
||||
|
||||
> **Parent:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2B)
|
||||
> **Status:** Ready to execute
|
||||
|
||||
## Goal
|
||||
|
||||
TDD port of the Angular `OnlineBoardUrlBuilderService` / `OnlineBoardUrlParserService` into pure TypeScript functions with zero Angular dependencies. Byte-exact URL parity with Angular.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`src/features/online-board/url.ts` — pure functions, no side effects, no Date objects.
|
||||
|
||||
## URL Format Reference (from Angular analysis)
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Start | `/onlineboard` or `/onlineboard/` | `/onlineboard` |
|
||||
| Flight | `/onlineboard/flight/{carrier}{flightNumber}{suffix}-{yyyyMMdd}` | `/onlineboard/flight/SU0100D-20250115` |
|
||||
| Departure | `/onlineboard/departure/{station}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/departure/SVO-20250115-08001800` |
|
||||
| Arrival | `/onlineboard/arrival/{station}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/arrival/LED-20250115` |
|
||||
| Route | `/onlineboard/route/{dep}-{arr}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/route/SVO-LED-20250115` |
|
||||
| Details | `/onlineboard/{carrier}{flightNumber}{suffix}-{yyyyMMdd}` | `/onlineboard/SU0100-20250115` |
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Date format:** `yyyyMMdd` (8 digits, no separators)
|
||||
- **Time range:** `{HHmm}{HHmm}` (8 digits continuous, from-to)
|
||||
- **Flight number:** zero-padded to 4 digits in the URL (e.g., `100` becomes `0100`), total flightNumber+suffix = last 5 chars
|
||||
- **Carrier code:** 2 characters (IATA), or 3 characters if 3rd char is a letter (rare 3-char carriers)
|
||||
- **Suffix:** optional single trailing letter on a flight identifier (e.g., `D` in `SU0100D`)
|
||||
- **Station codes:** 3-letter IATA airport codes
|
||||
|
||||
## Exported API
|
||||
|
||||
```ts
|
||||
type OnlineBoardParams =
|
||||
| { type: "start" }
|
||||
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
|
||||
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
|
||||
|
||||
function parseOnlineBoardUrl(path: string): OnlineBoardParams | null;
|
||||
function buildOnlineBoardUrl(params: OnlineBoardParams): string;
|
||||
|
||||
// Lower-level helpers (also exported for 2C/2E consumption)
|
||||
function parseFlightUrlParams(raw: string): IParsedFlightId | null;
|
||||
function buildFlightUrlParams(id: IParsedFlightId): string;
|
||||
function parseStationUrlParams(raw: string): { station: string; date: string; timeFrom?: string; timeTo?: string } | null;
|
||||
function parseRouteUrlParams(raw: string): { departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string } | null;
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Write failing tests for `parseFlightUrlParams`
|
||||
|
||||
**File:** `src/features/online-board/url.test.ts`
|
||||
|
||||
Test cases:
|
||||
- `SU0100-20250115` -> `{ carrier: "SU", flightNumber: "0100", date: "20250115" }`
|
||||
- `SU0100D-20250115` -> `{ carrier: "SU", flightNumber: "0100", suffix: "D", date: "20250115" }`
|
||||
- `SU100-20250115` -> `{ carrier: "SU", flightNumber: "100", date: "20250115" }` (3-digit)
|
||||
- `SU1234-20250115` -> `{ carrier: "SU", flightNumber: "1234", date: "20250115" }` (4-digit)
|
||||
- Empty string -> `null`
|
||||
- Missing date part -> `null`
|
||||
|
||||
### Task 2: Implement `parseFlightUrlParams` to pass tests
|
||||
|
||||
### Task 3: Write failing tests for `buildFlightUrlParams`
|
||||
|
||||
Test cases:
|
||||
- `{ carrier: "SU", flightNumber: "100", date: "20250115" }` -> `SU0100-20250115` (padded)
|
||||
- `{ carrier: "SU", flightNumber: "0100", suffix: "D", date: "20250115" }` -> `SU0100D-20250115`
|
||||
- `{ carrier: "SU", flightNumber: "1234", date: "20250115" }` -> `SU1234-20250115`
|
||||
|
||||
### Task 4: Implement `buildFlightUrlParams` to pass tests
|
||||
|
||||
### Task 5: Write failing tests for `parseStationUrlParams`
|
||||
|
||||
Test cases:
|
||||
- `SVO-20250115` -> `{ station: "SVO", date: "20250115" }`
|
||||
- `SVO-20250115-08001800` -> `{ station: "SVO", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
|
||||
- Empty string -> `null`
|
||||
|
||||
### Task 6: Implement `parseStationUrlParams` to pass tests
|
||||
|
||||
### Task 7: Write failing tests for `parseRouteUrlParams`
|
||||
|
||||
Test cases:
|
||||
- `SVO-LED-20250115` -> `{ departure: "SVO", arrival: "LED", date: "20250115" }`
|
||||
- `SVO-LED-20250115-08001800` -> `{ departure: "SVO", arrival: "LED", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
|
||||
- Empty string -> `null`
|
||||
|
||||
### Task 8: Implement `parseRouteUrlParams` to pass tests
|
||||
|
||||
### Task 9: Write failing tests for `parseOnlineBoardUrl`
|
||||
|
||||
Test cases:
|
||||
- `/onlineboard` -> `{ type: "start" }`
|
||||
- `/onlineboard/` -> `{ type: "start" }`
|
||||
- `/onlineboard/flight/SU0100-20250115` -> `{ type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" }`
|
||||
- `/onlineboard/departure/SVO-20250115` -> `{ type: "departure", station: "SVO", date: "20250115" }`
|
||||
- `/onlineboard/arrival/LED-20250115-08001800` -> `{ type: "arrival", station: "LED", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
|
||||
- `/onlineboard/route/SVO-LED-20250115` -> `{ type: "route", departure: "SVO", arrival: "LED", date: "20250115" }`
|
||||
- `/onlineboard/SU0100-20250115` -> `{ type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" }`
|
||||
- `/some/other/path` -> `null`
|
||||
- Empty string -> `null`
|
||||
|
||||
### Task 10: Implement `parseOnlineBoardUrl` to pass tests
|
||||
|
||||
### Task 11: Write failing tests for `buildOnlineBoardUrl`
|
||||
|
||||
Test cases:
|
||||
- `{ type: "start" }` -> `onlineboard`
|
||||
- `{ type: "flight", carrier: "SU", flightNumber: "100", date: "20250115" }` -> `onlineboard/flight/SU0100-20250115`
|
||||
- `{ type: "departure", station: "SVO", date: "20250115" }` -> `onlineboard/departure/SVO-20250115`
|
||||
- `{ type: "departure", station: "SVO", date: "20250115", timeFrom: "0800", timeTo: "1800" }` -> `onlineboard/departure/SVO-20250115-08001800`
|
||||
- `{ type: "arrival", station: "LED", date: "20250115" }` -> `onlineboard/arrival/LED-20250115`
|
||||
- `{ type: "route", departure: "SVO", arrival: "LED", date: "20250115" }` -> `onlineboard/route/SVO-LED-20250115`
|
||||
- `{ type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" }` -> `onlineboard/SU0100-20250115`
|
||||
|
||||
### Task 12: Implement `buildOnlineBoardUrl` to pass tests
|
||||
|
||||
### Task 13: Roundtrip tests
|
||||
|
||||
For every URL type: `buildOnlineBoardUrl(parseOnlineBoardUrl(url)) === url` (after normalization).
|
||||
|
||||
### Task 14: Edge case tests
|
||||
|
||||
- Suffixed flights roundtrip: `SU0100D-20250115`
|
||||
- 3-digit flight numbers: build pads to 4, parse handles both
|
||||
- Time range only partially specified (only timeFrom, no timeTo) -> no time range in URL
|
||||
- Invalid date format -> null
|
||||
- Unknown route prefix -> null
|
||||
|
||||
### Task 15: Export from barrel
|
||||
|
||||
Update `src/features/online-board/index.ts` to re-export the public API.
|
||||
|
||||
### Task 16: Verification
|
||||
|
||||
Run `pnpm typecheck && pnpm lint && pnpm test`.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
- All tests pass
|
||||
- `parseOnlineBoardUrl` returns null for invalid inputs, never throws
|
||||
- Zero `any` types
|
||||
- Pure functions only — no side effects, no imports from Angular
|
||||
- Dates are strings (`yyyyMMdd`), not Date objects
|
||||
@@ -0,0 +1,96 @@
|
||||
# Phase 2C — API Client Functions + React Hooks
|
||||
|
||||
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2C)
|
||||
|
||||
## Goal
|
||||
|
||||
Wrap the three Online Board REST endpoints in typed, pure API functions and thin React hooks. API functions are dependency-injected with `ApiClient`; hooks use `useApiClient()` from context.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 1 `ApiClient` (`src/shared/api/client.ts`) and `useApiClient` (`src/shared/api/provider.tsx`) exist.
|
||||
- Phase 2A types (`src/features/online-board/types.ts`) exist: `ISimpleFlight`, `IBoardResponse`, `IDaysResponse`, `IParsedFlightId`, `FlightRequestType`.
|
||||
- Phase 2B URL serializer (`src/features/online-board/url.ts`) exists.
|
||||
|
||||
## Deliverables
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/features/online-board/api.ts` | Pure API functions: `searchFlights`, `getFlightDetails`, `getCalendarDays` |
|
||||
| `src/features/online-board/api.test.ts` | TDD tests for API functions (mock fetch, verify URL construction + response mapping) |
|
||||
| `src/features/online-board/hooks/useOnlineBoard.ts` | React hook for search pages |
|
||||
| `src/features/online-board/hooks/useFlightDetails.ts` | React hook for details page |
|
||||
| `src/features/online-board/hooks/useCalendarDays.ts` | React hook for calendar availability |
|
||||
| `src/features/online-board/index.ts` | Updated barrel exports |
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1: Define API function param types and write `api.ts`
|
||||
|
||||
**File:** `src/features/online-board/api.ts`
|
||||
|
||||
Three pure functions, each taking `ApiClient` as first param:
|
||||
|
||||
1. `searchFlights(client, params)` — builds `GET /{locale}/board?...` with optional query params: `flightNumber`, `dateFrom`, `dateTo`, `departure`, `arrival`, `timeFrom`, `timeTo`. Returns `IBoardResponse`.
|
||||
|
||||
2. `getFlightDetails(client, params)` — builds `GET /{locale}/onlineboard/details?flights={carrier}{number}&dates={date}`. Returns `IBoardResponse`.
|
||||
|
||||
3. `getCalendarDays(client, params)` — builds `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`. Returns parsed `string[]` from `IDaysResponse.days`.
|
||||
|
||||
**Key design decisions:**
|
||||
- Functions are PURE — no context, no hooks, no side effects.
|
||||
- Use `ApiClient.get<T>(path, query)` for query-param endpoints.
|
||||
- For calendar endpoint, build the path string directly (it's path-based, not query-based).
|
||||
- Map `IDaysResponse.days` (a single string) to `string[]` by splitting on comma.
|
||||
|
||||
### T2: Write TDD tests for `api.ts`
|
||||
|
||||
**File:** `src/features/online-board/api.test.ts`
|
||||
|
||||
Test cases:
|
||||
- `searchFlights`: correct URL + query params for flight-number search, route search, departure-only, arrival-only; response deserialization
|
||||
- `getFlightDetails`: correct URL + query params; response deserialization
|
||||
- `getCalendarDays`: correct path construction for flight type, route type, departure type, arrival type; response mapping from comma-separated string to array
|
||||
- Error cases: 404 throws `ApiHttpError`, timeout throws `ApiTimeoutError`
|
||||
|
||||
Mock strategy: create `ApiClient` with a mock `fetchImpl` that captures the request URL and returns canned responses.
|
||||
|
||||
### T3: Write `useOnlineBoard` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useOnlineBoard.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for flights, loading, error
|
||||
- `useEffect` calls `searchFlights` on param change
|
||||
- Returns `{ flights, loading, error, refresh }`
|
||||
|
||||
### T4: Write `useFlightDetails` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useFlightDetails.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for flight, loading, error
|
||||
- `useEffect` calls `getFlightDetails` on param change
|
||||
- Returns `{ flight, loading, error }`
|
||||
|
||||
### T5: Write `useCalendarDays` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useCalendarDays.ts`
|
||||
|
||||
- Gets `ApiClient` via `useApiClient()`
|
||||
- `useState` for days, loading
|
||||
- `useEffect` calls `getCalendarDays` on param change
|
||||
- Returns `{ days, loading }`
|
||||
|
||||
### T6: Update barrel exports in `index.ts`
|
||||
|
||||
Add API functions and hooks to the public barrel.
|
||||
|
||||
### T7: Verify — `pnpm typecheck && pnpm lint && pnpm test`
|
||||
|
||||
## Exit gate
|
||||
|
||||
- All API function tests pass (URL construction, response mapping, error handling).
|
||||
- `pnpm typecheck` green — zero `any` types.
|
||||
- `pnpm lint` green.
|
||||
- Hooks compile and export correctly.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Phase 2D — SignalR Wiring
|
||||
|
||||
> **Parent:** `2026-04-14-phase-2-online-board-master.md` § 2D
|
||||
>
|
||||
> **Goal:** Wire the generic `useLiveFlights` hook (1E) to TrackerHub channels for the Online Board's search and details pages. Two thin composition hooks, each tested.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Artifact | Source |
|
||||
|---|---|
|
||||
| `useLiveFlights<TParams, TData>` | `src/shared/hooks/useLiveFlights.ts` (1E) |
|
||||
| `SignalRConnection`, `ConnectionStatus` | `src/shared/signalr/connection.ts` (1E) |
|
||||
| `useOnlineBoard` (refresh callback) | `src/features/online-board/hooks/useOnlineBoard.ts` (2C) |
|
||||
| `useFlightDetails` | `src/features/online-board/hooks/useFlightDetails.ts` (2C) |
|
||||
| `ISimpleFlight`, `IFlightId` | `src/features/online-board/types.ts` (2A) |
|
||||
| `getEnv().SIGNALR_HUB_URL` | `src/env/index.ts` (1A) |
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1 — Create `useLiveBoardSearch` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useLiveBoardSearch.ts`
|
||||
|
||||
- Accepts `{ date: string; departure?: string; arrival?: string }` params and `initialFlights: ISimpleFlight[]`.
|
||||
- Composes `useLiveFlights` with config:
|
||||
- `hubUrl` from `getEnv().SIGNALR_HUB_URL`
|
||||
- `channelKey` builds `board:${date}:${departure ?? ""}:${arrival ?? ""}`
|
||||
- Returns `{ flights: ISimpleFlight[]; connectionStatus: ConnectionStatus }`.
|
||||
- Client-only: `useLiveFlights` already handles SSR guard.
|
||||
|
||||
### T2 — Create `useLiveFlightDetails` hook
|
||||
|
||||
**File:** `src/features/online-board/hooks/useLiveFlightDetails.ts`
|
||||
|
||||
- Accepts `{ carrier: string; flightNumber: string; suffix?: string; date: string }` (matches `IParsedFlightId`) and `initialFlight: ISimpleFlight | null`.
|
||||
- Composes `useLiveFlights` with config:
|
||||
- `hubUrl` from `getEnv().SIGNALR_HUB_URL`
|
||||
- `channelKey` builds `flight:${carrier}${flightNumber}${suffix ?? ""}@${date}`
|
||||
- Returns `{ flight: ISimpleFlight | null; connectionStatus: ConnectionStatus }`.
|
||||
|
||||
### T3 — Write tests for `useLiveBoardSearch`
|
||||
|
||||
**File:** `src/features/online-board/hooks/useLiveBoardSearch.test.ts`
|
||||
|
||||
- **Channel key construction:** date-only, date+departure, date+departure+arrival all produce correct keys.
|
||||
- **Data passthrough:** initial data flows through from `useLiveFlights`.
|
||||
- **Live update:** simulated SignalR message replaces flight data.
|
||||
- **SSR:** returns initial data with idle status when `window` is undefined.
|
||||
|
||||
### T4 — Write tests for `useLiveFlightDetails`
|
||||
|
||||
**File:** `src/features/online-board/hooks/useLiveFlightDetails.test.ts`
|
||||
|
||||
- **Channel key construction:** with and without suffix.
|
||||
- **Data passthrough:** initial flight (or null) flows through.
|
||||
- **Live update:** simulated message replaces single flight.
|
||||
- **SSR:** idle status, no SignalR import.
|
||||
|
||||
### T5 — Export from barrel
|
||||
|
||||
**File:** `src/features/online-board/index.ts`
|
||||
|
||||
- Add exports for both hooks and their result types.
|
||||
|
||||
### T6 — Verification
|
||||
|
||||
- `pnpm typecheck && pnpm lint && pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Exit gates
|
||||
|
||||
| Gate | Verification |
|
||||
|---|---|
|
||||
| Channel keys correct | Unit tests for key construction |
|
||||
| Live updates flow | Mock SignalR push updates state |
|
||||
| SSR safe | Returns initial data without touching SignalR |
|
||||
| No regressions | All existing tests still pass |
|
||||
| Types clean | `pnpm typecheck` passes |
|
||||
| Lint clean | `pnpm lint` passes |
|
||||
@@ -0,0 +1,100 @@
|
||||
# Phase 2E — Routes + Pages
|
||||
|
||||
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` section 2E
|
||||
>
|
||||
> **Depends on:** 2A (UI adapter), 2B (URL serializer), 2C (API hooks), 2D (SignalR wiring)
|
||||
>
|
||||
> **Scope:** 6 route pages under `src/routes/[lang]/onlineboard/`, 3 feature-specific components in `src/features/online-board/components/`, barrel export updates, MF expose update.
|
||||
|
||||
---
|
||||
|
||||
## Task inventory
|
||||
|
||||
### T1 — Shared `OnlineBoardSearchPage` component
|
||||
|
||||
**File:** `src/features/online-board/components/OnlineBoardSearchPage.tsx`
|
||||
|
||||
Shared component composed by all 4 search pages (flight, departure, arrival, route). Accepts parsed URL params, converts to `SearchFlightsParams`, wires `useOnlineBoard` + `useLiveBoardSearch` + `useCalendarDays`, renders `FlightList` + `ConnectionStatusBadge` (inline). Provides `useNavigate` callbacks for date changes and flight detail links.
|
||||
|
||||
**Test:** `src/features/online-board/components/OnlineBoardSearchPage.test.tsx` — renders with mock provider, verifies `FlightList` presence and navigation wiring.
|
||||
|
||||
### T2 — `OnlineBoardStartPage` component
|
||||
|
||||
**File:** `src/features/online-board/components/OnlineBoardStartPage.tsx`
|
||||
|
||||
Start page with plain HTML search form: radio buttons for search mode (flight/departure/arrival/route), text inputs for flight number / airport codes, date input. Submits via `useNavigate` to the correct search URL using `buildOnlineBoardUrl`.
|
||||
|
||||
**Test:** `src/features/online-board/components/OnlineBoardStartPage.test.tsx`
|
||||
|
||||
### T3 — `OnlineBoardDetailsPage` component
|
||||
|
||||
**File:** `src/features/online-board/components/OnlineBoardDetailsPage.tsx`
|
||||
|
||||
Flight details page. Accepts `IParsedFlightId`, wires `useFlightDetails` + `useLiveFlightDetails`, renders flight info (legs, stations, times) using FlightCard + inline detail sections.
|
||||
|
||||
**Test:** `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`
|
||||
|
||||
### T4 — Start page route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/page.tsx`
|
||||
|
||||
Renders `OnlineBoardStartPage`. No API calls. Wraps in `SeoHead` with basic title.
|
||||
|
||||
### T5 — Flight search route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/flight/[params]/page.tsx`
|
||||
|
||||
Parses URL via `parseFlightUrlParams`, renders `OnlineBoardSearchPage` with `type: "flight"`.
|
||||
|
||||
### T6 — Departure search route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/departure/[params]/page.tsx`
|
||||
|
||||
Parses URL via `parseStationUrlParams`, renders `OnlineBoardSearchPage` with `type: "departure"`.
|
||||
|
||||
### T7 — Arrival search route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx`
|
||||
|
||||
Parses URL via `parseStationUrlParams`, renders `OnlineBoardSearchPage` with `type: "arrival"`.
|
||||
|
||||
### T8 — Route search route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/route/[params]/page.tsx`
|
||||
|
||||
Parses URL via `parseRouteUrlParams`, renders `OnlineBoardSearchPage` with `type: "route"`.
|
||||
|
||||
### T9 — Flight details route
|
||||
|
||||
**File:** `src/routes/[lang]/onlineboard/[params]/page.tsx`
|
||||
|
||||
Parses URL via `parseFlightUrlParams`, renders `OnlineBoardDetailsPage`.
|
||||
|
||||
### T10 — Update feature barrel
|
||||
|
||||
**File:** `src/features/online-board/index.ts`
|
||||
|
||||
Add exports for `OnlineBoardStartPage`, `OnlineBoardSearchPage`, `OnlineBoardDetailsPage`.
|
||||
|
||||
### T11 — Update MF expose
|
||||
|
||||
**File:** `src/mf/expose/OnlineBoard.tsx`
|
||||
|
||||
Replace stub with `React.lazy` + `Suspense` loading `OnlineBoardStartPage` from the feature barrel.
|
||||
|
||||
### T12 — Verification
|
||||
|
||||
Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
T1 → T2 → T3 → T4,T5,T6,T7,T8,T9 (parallel) → T10 → T11 → T12
|
||||
|
||||
## Commit plan
|
||||
|
||||
1. Plan file (this document)
|
||||
2. Feature components (T1 + T2 + T3 + tests)
|
||||
3. Route pages (T4-T9)
|
||||
4. Barrel + MF expose updates (T10 + T11) + verification
|
||||
@@ -0,0 +1,86 @@
|
||||
# Phase 2F — SEO + JSON-LD for Online Board
|
||||
|
||||
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2F)
|
||||
> **Depends on:** 2E (route pages), 1F-seo (`SeoHead`, `buildHreflangSet`, `JsonLdRenderer`), 1C (i18n)
|
||||
|
||||
## Goal
|
||||
|
||||
Wire SEO metadata (title, description, canonical, hreflang, OG, Twitter Card) and JSON-LD structured data (`Flight`, `ItemList`) into all 6 Online Board route pages. SEO builders are pure functions; JSON-LD builders produce `schema-dts` typed objects.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/online-board/seo.ts` — 6 builder functions returning `SeoHeadProps`
|
||||
2. `src/features/online-board/json-ld.ts` — 2 JSON-LD builder functions
|
||||
3. Updated route pages wiring `<SeoHead>` and `<JsonLdRenderer>`
|
||||
4. Populated EN locale SEO keys (RU already has values)
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1 — Populate EN locale SEO keys
|
||||
|
||||
The EN locale file has empty SEO.BOARD keys. Add English translations matching the Russian pattern.
|
||||
|
||||
- File: `src/i18n/locales/en/common.json`
|
||||
- Keys: `SEO.BOARD.MAIN`, `SEO.BOARD.FLIGHT-SEARCH`, `SEO.BOARD.DEPARTURE-SEARCH`, `SEO.BOARD.ARRIVAL-SEARCH`, `SEO.BOARD.ROUTE-SEARCH`, `SEO.BOARD.FLIGHT-DETAILS`
|
||||
|
||||
### T2 — TDD: SEO builder functions (`seo.ts`)
|
||||
|
||||
Write tests first in `src/features/online-board/seo.test.ts`, then implement in `src/features/online-board/seo.ts`.
|
||||
|
||||
**Functions:**
|
||||
- `buildOnlineBoardStartSeo(locale, canonicalOrigin)` — start page SEO
|
||||
- `buildFlightSearchSeo(params, locale, canonicalOrigin, cityNames?)` — flight number search
|
||||
- `buildDepartureSearchSeo(params, locale, canonicalOrigin, cityNames?)` — departure search
|
||||
- `buildArrivalSearchSeo(params, locale, canonicalOrigin, cityNames?)` — arrival search
|
||||
- `buildRouteSearchSeo(params, locale, canonicalOrigin, cityNames?)` — route search
|
||||
- `buildFlightDetailsSeo(flight, locale, canonicalOrigin)` — flight details page
|
||||
|
||||
Each returns `SeoHeadProps` with title, description, canonical, hreflang, og, twitter.
|
||||
|
||||
**Test strategy:** Unit tests with mocked i18n `t()` function verifying:
|
||||
- Correct translation keys passed to `t()`
|
||||
- Correct interpolation variables
|
||||
- Canonical URL structure
|
||||
- Hreflang set included
|
||||
- OG tags populated
|
||||
- Twitter card set
|
||||
|
||||
### T3 — TDD: JSON-LD builder functions (`json-ld.ts`)
|
||||
|
||||
Write tests first in `src/features/online-board/json-ld.test.ts`, then implement in `src/features/online-board/json-ld.ts`.
|
||||
|
||||
**Functions:**
|
||||
- `buildFlightJsonLd(flight: ISimpleFlight)` — returns `schema-dts` `Flight` thing
|
||||
- `buildFlightListJsonLd(flights: ISimpleFlight[], searchDescription: string)` — returns `ItemList`
|
||||
|
||||
**Test strategy:** Unit tests verifying:
|
||||
- Correct `@type` set
|
||||
- Flight number, departure/arrival airports mapped
|
||||
- Departure/arrival times mapped
|
||||
- `ItemList` wraps flights as `ListItem` elements
|
||||
- Type-checks against `schema-dts` types
|
||||
|
||||
### T4 — Wire SEO into route pages
|
||||
|
||||
Update the 6 route page files to import and use `<SeoHead>` with appropriate builder + `<JsonLdRenderer>`. No TDD for this step (visual verification).
|
||||
|
||||
- `src/routes/[lang]/onlineboard/page.tsx` — start page
|
||||
- `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` — flight search
|
||||
- `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` — departure search
|
||||
- `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` — arrival search
|
||||
- `src/routes/[lang]/onlineboard/route/[params]/page.tsx` — route search
|
||||
- `src/routes/[lang]/onlineboard/[params]/page.tsx` — flight details
|
||||
|
||||
### T5 — Export from barrel
|
||||
|
||||
Add SEO + JSON-LD exports to `src/features/online-board/index.ts`.
|
||||
|
||||
### T6 — Verify
|
||||
|
||||
Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
|
||||
|
||||
## Commit plan
|
||||
|
||||
1. **Commit 1:** EN locale SEO keys + SEO builder tests + implementation (`seo.ts`)
|
||||
2. **Commit 2:** JSON-LD builder tests + implementation (`json-ld.ts`)
|
||||
3. **Commit 3:** Wire SEO into route pages + barrel exports + verify
|
||||
@@ -0,0 +1,81 @@
|
||||
# Phase 2G — Parity Harnesses
|
||||
|
||||
> **Parent:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2G)
|
||||
> **Depends on:** 2B (URL serializer), 2F (SEO builders)
|
||||
|
||||
## Goal
|
||||
|
||||
Build generic, reusable parity harnesses for URL serialization and SEO output.
|
||||
Register the Online Board feature against both harnesses. Phase 3+ features
|
||||
will register their own serializers into the same framework.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. **URL parity harness** (`tests/parity/url/harness.ts`) — generic framework
|
||||
for table-driven + `fast-check` property-based URL roundtrip tests.
|
||||
2. **URL parity test** (`tests/parity/url/onlineboard.test.ts`) — Online Board
|
||||
registered against the harness, testing against fixture corpus.
|
||||
3. **URL fixture corpus** (`tests/fixtures/phase-2/url-corpus/onlineboard.json`)
|
||||
— ~20 representative URLs covering all 6 route types.
|
||||
4. **SEO parity harness** (`tests/parity/seo/harness.ts`) — generic framework
|
||||
for testing SEO builder output shape/completeness.
|
||||
5. **SEO parity test** (`tests/parity/seo/onlineboard.test.ts`) — Online Board
|
||||
SEO builders registered against the harness.
|
||||
6. **`fast-check`** installed as a dev dependency.
|
||||
|
||||
## Task list
|
||||
|
||||
### T1 — Install `fast-check`
|
||||
|
||||
- `pnpm add -D fast-check`
|
||||
- Verify: `pnpm typecheck`
|
||||
|
||||
### T2 — Create URL fixture corpus
|
||||
|
||||
- File: `tests/fixtures/phase-2/url-corpus/onlineboard.json`
|
||||
- ~20 entries covering: start, flight (with/without suffix, 3-char carrier),
|
||||
departure (with/without time range), arrival (with/without time range),
|
||||
route (with/without time range), details (with/without suffix).
|
||||
- Each entry: `{ url: string, expected: OnlineBoardParams }`.
|
||||
|
||||
### T3 — URL parity harness
|
||||
|
||||
- File: `tests/parity/url/harness.ts`
|
||||
- Exports `UrlParityConfig<T>` interface and `defineUrlParityTests<T>()`.
|
||||
- Table-driven tests: reads fixture file, for each entry asserts
|
||||
`parse(url) === expected` and `build(expected) === url`.
|
||||
- Fuzz tests: uses `fast-check` arbitrary to generate random params,
|
||||
asserts `parse(build(params)) deep-equals params` (roundtrip property).
|
||||
|
||||
### T4 — Register Online Board URL parity
|
||||
|
||||
- File: `tests/parity/url/onlineboard.test.ts`
|
||||
- Imports harness + URL serializer + fixtures.
|
||||
- Defines `fc.Arbitrary<OnlineBoardParams>` covering all 6 discriminants.
|
||||
- Calls `defineUrlParityTests(config)`.
|
||||
|
||||
### T5 — SEO parity harness
|
||||
|
||||
- File: `tests/parity/seo/harness.ts`
|
||||
- Exports `SeoParityConfig` interface and `defineSeoParityTests()`.
|
||||
- Tests: for each registered SEO builder call, asserts output has all required
|
||||
fields (title, description, canonical, hreflang with 10 entries, OG tags).
|
||||
|
||||
### T6 — Register Online Board SEO parity
|
||||
|
||||
- File: `tests/parity/seo/onlineboard.test.ts`
|
||||
- Registers all 6 Online Board SEO builders.
|
||||
- Asserts shape correctness for each route type x 2 languages (ru, en).
|
||||
|
||||
### T7 — Verification
|
||||
|
||||
- `pnpm typecheck && pnpm lint && pnpm test`
|
||||
- All new tests pass. All existing tests still pass.
|
||||
|
||||
## Exit gates
|
||||
|
||||
- URL parity: 100% of fixture corpus passes roundtrip.
|
||||
- `fast-check` fuzz finds no roundtrip asymmetry (100 iterations default).
|
||||
- SEO parity: all 6 route types produce complete SeoHeadProps.
|
||||
- `pnpm typecheck`, `pnpm lint`, `pnpm test` green.
|
||||
- No `any` types in harness code.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Phase 2H — Integration Tests
|
||||
|
||||
**Date:** 2026-04-15
|
||||
**Parent:** `2026-04-14-phase-2-online-board-master.md` section 2H
|
||||
**Scope:** Component-level integration tests for the Online Board feature
|
||||
|
||||
## Overview
|
||||
|
||||
Integration tests that verify the Online Board feature works end-to-end at the component level. Since CI cannot run a full Modern.js dev server + real SignalR hub, these are **vitest + React Testing Library** tests with mocked API and SignalR layers.
|
||||
|
||||
## Deviation from master plan
|
||||
|
||||
The master plan specifies Playwright E2E tests. This sub-plan implements **component-level integration tests** instead — they run in vitest/jsdom, mock the API and SignalR layers, and verify component composition, data flow, URL routing, error handling, and SEO output. This is faster, more reliable in CI, and catches the same integration regressions without requiring a running server.
|
||||
|
||||
## Test inventory
|
||||
|
||||
### 1. `tests/integration/online-board/start-page.test.tsx`
|
||||
- Renders start page, verifies search form with all 4 mode tabs
|
||||
- Verifies correct form fields appear per search type
|
||||
- Verifies form submission builds correct URL
|
||||
|
||||
### 2. `tests/integration/online-board/flight-search.test.tsx`
|
||||
- Renders search page with mocked API returning flight data
|
||||
- Verifies FlightList renders with correct flights
|
||||
- Verifies connection status badge renders
|
||||
- Verifies calendar strip renders with available days
|
||||
|
||||
### 3. `tests/integration/online-board/flight-details.test.tsx`
|
||||
- Renders details page with mocked API returning flight details
|
||||
- Verifies flight info renders (number, status, legs, operating carrier)
|
||||
- Verifies SeoHead produces expected title/canonical
|
||||
- Verifies error state when API fails
|
||||
|
||||
### 4. `tests/integration/online-board/url-roundtrip.test.tsx`
|
||||
- Build URL from params -> parse URL -> verify params match
|
||||
- Verify all 4 search types + details type round-trip correctly
|
||||
- Verify URL params produce correct API call parameters (via toSearchParams)
|
||||
|
||||
### 5. `tests/integration/online-board/error-handling.test.tsx`
|
||||
- Renders search page with mocked API error (ApiHttpError 404, 500)
|
||||
- Renders search page with mocked timeout error
|
||||
- Verifies error UI renders with retry button
|
||||
- Verifies retry triggers re-fetch
|
||||
|
||||
### 6. `tests/integration/online-board/seo-integration.test.tsx`
|
||||
- Renders details page via renderToString, verifies title/description/canonical in HTML
|
||||
- Verifies JSON-LD script block present in rendered HTML
|
||||
- Verifies OG meta tags in rendered HTML
|
||||
- Verifies hreflang links in rendered HTML
|
||||
|
||||
## Mocking strategy
|
||||
|
||||
- **ApiClient**: Mocked via `vi.mock("@/shared/api/provider.js")` — `useApiClient()` returns a mock `ApiClient` whose `.get()` resolves with fixture data.
|
||||
- **SignalR hooks**: Mocked via `vi.mock("../hooks/useLiveBoardSearch.js")` and `vi.mock("../hooks/useLiveFlightDetails.js")` — return static data with configurable connection status.
|
||||
- **Router**: Mocked via `vi.mock("@modern-js/runtime/router")` — `useNavigate` returns a spy, `useParams` returns test locale.
|
||||
- **i18n**: Mocked via `vi.mock("@/i18n/provider.js")` — `useTranslation` returns a stub `t` function that echoes the key.
|
||||
- **Environment**: Mocked via `vi.mock("@/env/index.js")` — returns test environment values.
|
||||
|
||||
## Vitest configuration
|
||||
|
||||
- Integration tests use `// @vitest-environment jsdom` pragma per file
|
||||
- Test files at `tests/integration/online-board/*.test.tsx`
|
||||
- Already included by vitest's `include` pattern (needs `tests/**/*.test.tsx` added)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@testing-library/react` (already installed)
|
||||
- `@testing-library/jest-dom` (to install)
|
||||
- `jsdom` (already installed)
|
||||
|
||||
## Exit criteria
|
||||
|
||||
- All 6 test files pass with `pnpm test`
|
||||
- 20-30 integration tests total
|
||||
- No real HTTP calls or SignalR connections
|
||||
- `pnpm typecheck` passes
|
||||
- `pnpm lint` passes
|
||||
- `pnpm build:standalone` passes
|
||||
|
||||
## Tasks
|
||||
|
||||
1. Install `@testing-library/jest-dom`
|
||||
2. Update vitest config to include `tests/**/*.test.tsx`
|
||||
3. Create shared test fixtures and helpers
|
||||
4. Create all 6 test files
|
||||
5. Verify: typecheck + lint + test + build
|
||||
@@ -0,0 +1,67 @@
|
||||
# Phase 3 -- Schedule Feature (Master Plan)
|
||||
|
||||
> **Parent:** React rewrite project
|
||||
> **Depends on:** Phase 1 (foundation), Phase 2 (online board -- patterns to reuse)
|
||||
|
||||
## Overview
|
||||
|
||||
Port the Angular Schedule feature to the React codebase. The schedule is
|
||||
structurally similar to the online board but differs in several key ways:
|
||||
|
||||
- **Date ranges** (dateFrom/dateTo) instead of single dates.
|
||||
- **Round-trip** search with outbound + return param segments.
|
||||
- **Catch-all** details route for variable-length multi-flight chains.
|
||||
- **POST** for search (not GET).
|
||||
- **No SignalR** -- schedule data is static.
|
||||
- **Connections** filter (C0, C1, ...) in URL params.
|
||||
|
||||
## URL Format
|
||||
|
||||
### Search (route)
|
||||
```
|
||||
schedule/route/{dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]
|
||||
```
|
||||
|
||||
### Round-trip search
|
||||
```
|
||||
schedule/route/{outbound-params}/{return-params}
|
||||
```
|
||||
|
||||
### Details (catch-all)
|
||||
Two formats coexist:
|
||||
1. Simple: `schedule/{flight1-date}/{flight2-date}/...`
|
||||
2. With airports: `schedule/{depCode}/{flight1-date}/{arrCode}/{depCode2}/{flight2-date}/{arrCode2}/...`
|
||||
|
||||
### Start page
|
||||
```
|
||||
schedule
|
||||
```
|
||||
|
||||
## Sub-plans
|
||||
|
||||
| Sub-plan | Name | Deliverables |
|
||||
|----------|------|-------------|
|
||||
| **3A** | URL serializer/parser | `src/features/schedule/url.ts` + tests |
|
||||
| **3B** | API + hooks | `api.ts`, `useScheduleSearch`, `useScheduleDetails`, `useScheduleCalendar` |
|
||||
| **3C** | Route pages | 4 Modern.js route pages |
|
||||
| **3D** | SEO + JSON-LD | `seo.ts`, `json-ld.ts` + tests |
|
||||
| **3E** | Parity + integration | Parity harness registration, ~10 integration tests |
|
||||
|
||||
## Key types (new in `src/features/schedule/types.ts`)
|
||||
|
||||
- `IScheduleSearchParams` -- outbound search parameters
|
||||
- `IScheduleDetailsParams` -- multi-flight details parameters
|
||||
- `IScheduleResponse` -- `IFlight[]` (reuses online-board flight types)
|
||||
- `IScheduleDetailsResponse` -- same as `IBoardResponse`
|
||||
- `IScheduleCalendarParams` -- calendar day availability
|
||||
|
||||
## Execution order
|
||||
|
||||
3A -> 3B -> 3C -> 3D -> 3E (sequential, each builds on prior)
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do NOT modify `ClientApp/`, ASP.NET, or `wwwroot/`.
|
||||
- Update `src/features/schedule/index.ts` barrel.
|
||||
- Update `src/mf/expose/Schedule.tsx` from stub to real component.
|
||||
- Final verification: `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Phase 3A -- Schedule URL Serializer/Parser
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** Phase 2B (reuses parseFlightUrlParams, buildFlightUrlParams)
|
||||
|
||||
## Goal
|
||||
|
||||
TDD implementation of URL parser/builder for the schedule feature covering:
|
||||
1. Start page
|
||||
2. One-way route search
|
||||
3. Round-trip route search
|
||||
4. Multi-flight details (catch-all)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/schedule/types.ts` -- schedule-specific types
|
||||
2. `src/features/schedule/url.ts` -- parse/build functions
|
||||
3. `src/features/schedule/url.test.ts` -- comprehensive tests
|
||||
|
||||
## URL Format Reference
|
||||
|
||||
### Route params: `{dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]`
|
||||
### Details: `{flight1-date}[/{flight2-date}]...` or with airport codes interleaved
|
||||
|
||||
## Tasks
|
||||
|
||||
T1. Create schedule types
|
||||
T2. Write URL test file (TDD -- tests first)
|
||||
T3. Implement url.ts to pass all tests
|
||||
@@ -0,0 +1,18 @@
|
||||
# Phase 3B -- Schedule API + Hooks
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3A (types)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/schedule/api.ts` -- three API functions
|
||||
2. `src/features/schedule/api.test.ts` -- unit tests
|
||||
3. `src/features/schedule/hooks/useScheduleSearch.ts`
|
||||
4. `src/features/schedule/hooks/useScheduleDetails.ts`
|
||||
5. `src/features/schedule/hooks/useScheduleCalendar.ts`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST schedule/1` -- search (body: IScheduleSearchRequest)
|
||||
- `GET schedule/details` -- multi-flight details (query: flights[], dates[], departure, arrival)
|
||||
- `GET days/{date}/382/{param}/schedule/v1` -- calendar days
|
||||
@@ -0,0 +1,20 @@
|
||||
# Phase 3C -- Schedule Route Pages
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3A (URL), 3B (API/hooks)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. Schedule page components in `src/features/schedule/components/`
|
||||
2. Modern.js route pages in `src/routes/[lang]/schedule/`
|
||||
3. 4 routes: start, one-way, round-trip, catch-all details
|
||||
|
||||
## Route Structure
|
||||
|
||||
```
|
||||
src/routes/[lang]/schedule/
|
||||
page.tsx -- start page
|
||||
route/[params]/page.tsx -- one-way search
|
||||
route/[params]/[returnParams]/page.tsx -- round-trip search
|
||||
[...flights]/page.tsx -- catch-all details
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# Phase 3D -- Schedule SEO + JSON-LD Tests
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3C (seo.ts, json-ld.ts already created)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/schedule/seo.test.ts` -- SEO builder tests
|
||||
2. `src/features/schedule/json-ld.test.ts` -- JSON-LD builder tests
|
||||
@@ -0,0 +1,12 @@
|
||||
# Phase 3E -- Schedule Parity + Integration Tests
|
||||
|
||||
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
|
||||
> **Depends on:** 3A-3D (all schedule code)
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. URL parity test registered in harness
|
||||
2. URL fixture corpus for schedule
|
||||
3. Integration tests (~10)
|
||||
4. Updated barrel (`index.ts`)
|
||||
5. Updated MF expose (`Schedule.tsx`)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Phase 4 -- Flights Map (Master Plan)
|
||||
|
||||
## Goal
|
||||
|
||||
Port the Angular `flights-map` feature (single `/flights-map` route) to the React/ModernJS codebase as a Module Federation remote component. The map uses Leaflet for interactive marker/polyline rendering and is behind the `flightsMap` feature flag.
|
||||
|
||||
## Angular analysis summary
|
||||
|
||||
- **Route:** `/flights-map` (start page with embedded Leaflet map)
|
||||
- **Feature flag:** `flightsMap` (env-based)
|
||||
- **APIs:** `destinations/v1` (search routes by departure/arrival), `days/.../flights-map/v1` (calendar availability)
|
||||
- **Leaflet map:** markers (blue default, blue-small zoom-dependent, orange selected), polylines (solid direct, dashed connecting), popups, tooltips, zoom-aware layer visibility
|
||||
- **Components:** start page container, map body (sole Leaflet consumer), filter panel, meta tags, loader/empty overlays
|
||||
- **No SignalR** -- data is static, fetched on filter change
|
||||
- **SEO:** meta tags only in Angular; design spec requires JSON-LD `WebPage` schema
|
||||
|
||||
## Sub-plans
|
||||
|
||||
### 4A -- Types + API + Hooks
|
||||
|
||||
**Files:** `src/features/flights-map/types.ts`, `api.ts`, `api.test.ts`, `hooks/useFlightsMapSearch.ts`, `hooks/useFlightsMapCalendar.ts`
|
||||
|
||||
- Define `IFlightRoute`, `IMapMarker`, `IMapPolyline`, `FlightsMapSearchParams`, `FlightsMapCalendarParams`
|
||||
- Define `IDestinationsResponse`, `IFlightsMapDaysResponse`
|
||||
- API: `searchDestinations(client, params)` -> `GET destinations/1`, `getFlightsMapCalendar(client, params)` -> `GET days/{date}/200/{route}/flights-map/v1`
|
||||
- Hooks: `useFlightsMapSearch(params)`, `useFlightsMapCalendar(params)`
|
||||
- Feature flag: `useFeatureFlag("flightsMap")` via env config
|
||||
- TDD: ~10 tests for API functions
|
||||
|
||||
### 4B -- Leaflet Map Wrapper
|
||||
|
||||
**File:** `src/features/flights-map/components/MapCanvas.tsx`
|
||||
|
||||
- **Only file that imports `leaflet`** (design spec constraint)
|
||||
- Wrapped in `React.lazy()` + `<ClientOnly>` (SSR-safe)
|
||||
- Accepts `markers`, `polylines`, `onMarkerClick` etc. as props -- does not own state
|
||||
- Renders markers (blue/orange icons), polylines (solid/dashed), popups, tooltips
|
||||
- Great-circle arc calculation for polyline rendering
|
||||
- Install `leaflet` + `@types/leaflet`
|
||||
- No TDD (Leaflet requires real DOM)
|
||||
|
||||
### 4C -- Route Pages + Feature Components
|
||||
|
||||
**Files:**
|
||||
- `src/routes/[lang]/flights-map/page.tsx` -- route page with SEO + feature flag guard
|
||||
- `src/features/flights-map/components/FlightsMapStartPage.tsx` -- container (filter + map + loading/empty states)
|
||||
- `src/features/flights-map/components/FlightsMapFilter.tsx` -- search filter (departure, arrival, connections, domestic/international toggles, date picker)
|
||||
- `src/features/flights-map/components/ClientOnly.tsx` -- SSR-safe wrapper
|
||||
|
||||
### 4D -- SEO + JSON-LD + Parity
|
||||
|
||||
**Files:**
|
||||
- `src/features/flights-map/seo.ts` -- `buildFlightsMapSeo(t, locale, canonicalOrigin)`
|
||||
- `src/features/flights-map/json-ld.ts` -- JSON-LD `WebPage` schema builder
|
||||
- `src/features/flights-map/seo.test.ts` -- ~5 integration tests
|
||||
- Update barrel `src/features/flights-map/index.ts`
|
||||
- Update `src/mf/expose/FlightsMap.tsx` from stub to real
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
4A (types/api/hooks) --> 4B (map canvas, needs types)
|
||||
--> 4C (pages/components, needs hooks)
|
||||
4A + 4B + 4C --------> 4D (SEO + barrel + MF expose update)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/`
|
||||
- No `Co-Authored-By` lines in commits
|
||||
- ~8-12 commits total
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 5 — Popular Requests MASTER Plan
|
||||
|
||||
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 5 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
|
||||
|
||||
**Goal of Phase 5:** Port the Popular Requests feature from Angular to React, wire it into existing route pages, implement the `useSearchHistory` hook backed by `@/shared/storage`, and verify SEO + parity. Popular Requests is an embedded component (not a standalone routed page) — used inside the OnlineBoard and Schedule start pages. It also includes the language switcher (ported from Angular layout) and a per-language-namespaced search history hook.
|
||||
|
||||
**Phase 5 exit gate** (must pass before next phase starts):
|
||||
|
||||
- `PopularRequestsPanel` renders correctly with all 5 request modes: `FlightNumber`, `Route`, `Arrival`, `Departure`, `RouteWithBack`.
|
||||
- `usePopularRequests` hook calls `GET /Requests/1/getpopular` and handles loading/error states.
|
||||
- `useSearchHistory` hook persists search history via `@/shared/storage` with per-language namespacing (`afl_history_{lang}`).
|
||||
- No direct `localStorage` access outside `src/shared/storage.ts` (enforced by ESLint `no-restricted-globals`).
|
||||
- MF expose `PopularRequests.tsx` upgraded from stub to real component.
|
||||
- Feature barrel `src/features/popular-requests/index.ts` exports all public surface.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone` all green.
|
||||
|
||||
**Angular source analyzed:**
|
||||
|
||||
- **API:** `GET /api/Requests/1/getpopular` returns `IPopularRequest[]` (union of 4 discriminated types keyed by `RequestMode`).
|
||||
- **Types:** `RequestMode` enum: `FlightNumber | Route | RouteWithBack | Departure | Arrival`. `IPopularRequestType`: `'Schedule' | 'Onlineboard'`. `IPopularRequest` = union of `IPopularRouteRequest | IPopularArrivalRequest | IPopularDepartureRequest | IPopularFlightNumberRequest`.
|
||||
- **Components:** `PopularRequestsComponent` (container, fetches data, handles clicks + navigation), `PopularRequestComponent` (switch by mode), `ArrivalRequestComponent`, `DepartureRequestComponent`, `FlightNumberRequestComponent`, `RouteRequestComponent`, `RequestInfoComponent` (styled clickable span).
|
||||
- **Behavior:** On click, populates filter state in `OnlineBoardFiltersStateService` / `ScheduleFiltersStateService` and navigates to `onlineboard` or `schedule` route. Uses `cityName` pipe (maps IATA code to city name via dictionaries).
|
||||
- **Search History:** `SearchHistoryService` — in-memory array of `ISearchHistoryItem` with dedup-by-URL and prepend logic. Displayed on start pages. React version should persist to localStorage via `@/shared/storage`.
|
||||
- **No dedicated route** — embedded inside `OnlineBoardStartPage` and `ScheduleStartPage`.
|
||||
|
||||
---
|
||||
|
||||
## Sub-plan inventory
|
||||
|
||||
| ID | Sub-plan | Estimated size | Description |
|
||||
|---|---|---|---|
|
||||
| **5A** | Types + API + hooks | Small (8-12 tasks) | `PopularRequest` types, `getPopularRequests` API fn, `usePopularRequests` hook |
|
||||
| **5B** | Route page + components | Medium (12-18 tasks) | `PopularRequestsPanel`, mode-specific sub-components, MF expose update |
|
||||
| **5C** | SEO + parity + integration tests | Small (6-10 tasks) | Verify no SEO regression, integration tests for the panel |
|
||||
| **5D** | Search history hook | Small (8-12 tasks) | `useSearchHistory` with per-language localStorage namespacing via `@/shared/storage` |
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 5A Types + API + hooks │
|
||||
│ src/features/popular- │
|
||||
│ requests/{types,api,hooks} │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ 5B Components + MF expose │◄── depends on 5A types/hooks
|
||||
│ src/features/popular- │
|
||||
│ requests/components/ │
|
||||
│ src/mf/expose/ │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ 5C SEO + parity + tests │◄── depends on 5B components
|
||||
└──────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────┐
|
||||
│ 5D useSearchHistory hook │ (independent of 5A-5C)
|
||||
│ src/shared/hooks/ │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
5D is independent and can be executed in parallel with 5A-5C.
|
||||
|
||||
---
|
||||
|
||||
## Contracts exported per sub-plan
|
||||
|
||||
### 5A — Types + API + hooks
|
||||
|
||||
**Files created:**
|
||||
- `src/features/popular-requests/types.ts` — `RequestMode`, `PopularRequestType`, `PopularRequest` union, sub-types
|
||||
- `src/features/popular-requests/api.ts` — `getPopularRequests(client: ApiClient): Promise<PopularRequest[]>`
|
||||
- `src/features/popular-requests/api.test.ts` — API function tests
|
||||
- `src/features/popular-requests/hooks/usePopularRequests.ts` — React hook wrapping the API call
|
||||
- `src/features/popular-requests/index.ts` — barrel update
|
||||
|
||||
**Consumed by:** 5B (types + hook), 5C (tests)
|
||||
|
||||
### 5B — Components + MF expose
|
||||
|
||||
**Files created:**
|
||||
- `src/features/popular-requests/components/PopularRequestsPanel.tsx` — container component
|
||||
- `src/features/popular-requests/components/PopularRequestItem.tsx` — mode-switching renderer
|
||||
- `src/features/popular-requests/components/RequestInfo.tsx` — styled clickable span
|
||||
|
||||
**Files modified:**
|
||||
- `src/mf/expose/PopularRequests.tsx` — stub replaced with real component
|
||||
- `src/features/popular-requests/index.ts` — barrel update
|
||||
|
||||
**Consumed by:** 5C (integration tests), route pages (OnlineBoardStartPage, ScheduleStartPage)
|
||||
|
||||
### 5C — SEO + parity + integration tests
|
||||
|
||||
**Files created:**
|
||||
- `src/features/popular-requests/components/PopularRequestsPanel.test.tsx` — component tests
|
||||
|
||||
**Consumed by:** Exit gate verification
|
||||
|
||||
### 5D — Search history hook
|
||||
|
||||
**Files created:**
|
||||
- `src/shared/hooks/useSearchHistory.ts` — hook with per-language localStorage namespacing
|
||||
- `src/shared/hooks/useSearchHistory.test.ts` — hook tests
|
||||
|
||||
**Files modified:**
|
||||
- `src/features/popular-requests/index.ts` — re-export if appropriate
|
||||
|
||||
**Consumed by:** Start pages (OnlineBoardStartPage, ScheduleStartPage) in future integration
|
||||
@@ -0,0 +1,443 @@
|
||||
# Phase 6: Cutover & Decommission Runbook
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-04-15
|
||||
**Target:** Flip all traffic from Angular SPA to React Module Federation remote, soak for 1 week, then archive the Angular codebase.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-Cutover Checklist
|
||||
|
||||
Every gate below must be **green** before starting the traffic ramp.
|
||||
|
||||
| # | Gate | Verified by | Status |
|
||||
|---|------|-------------|--------|
|
||||
| 1 | Phase 1 exit: Foundation complete (ModernJS SSR, MF 2.0, i18n, API client, SignalR, layout, SEO, analytics, logger, metrics, security, deploy) | Tech lead | [ ] |
|
||||
| 2 | Phase 2 exit: Online Board feature parity confirmed (URL serializer, API hooks, SignalR wiring, routes, SEO, integration tests) | QA lead | [ ] |
|
||||
| 3 | Phase 3 exit: Schedule feature parity confirmed | QA lead | [ ] |
|
||||
| 4 | Phase 4 exit: Flights Map feature parity confirmed | QA lead | [ ] |
|
||||
| 5 | Phase 5 exit: Popular Requests feature parity confirmed | QA lead | [ ] |
|
||||
| 6 | Load test passed at 100 RPS sustained for 30 minutes with p95 < 500ms | Performance engineer | [ ] |
|
||||
| 7 | Staging environment fully verified (all routes, SSR, MF remote loading) | QA lead | [ ] |
|
||||
| 8 | Rollback procedure rehearsed in staging (< 1 minute switchover confirmed) | Ops engineer | [ ] |
|
||||
| 9 | Monitoring dashboards operational (error rate, p95 latency, Web Vitals, SignalR health) | SRE | [ ] |
|
||||
| 10 | Search Console coverage verified (no indexing regressions on staging) | SEO lead | [ ] |
|
||||
| 11 | All `pnpm typecheck && pnpm lint && pnpm test && pnpm build:both` pass on the release branch | CI | [ ] |
|
||||
| 12 | Incident communication plan agreed (who to notify, escalation path) | Team lead | [ ] |
|
||||
| 13 | Customer sign-off for production traffic shift | Project manager | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## 2. Proxy Rule Configuration Template
|
||||
|
||||
The actual LB/proxy technology depends on the customer's infrastructure. Below are placeholder templates for common setups. Replace `<REACT_UPSTREAM>` and `<ANGULAR_UPSTREAM>` with the real backend addresses.
|
||||
|
||||
### 2.1 nginx
|
||||
|
||||
```nginx
|
||||
# --- Phase 6 cutover: traffic split ---
|
||||
# Adjust the weight to control traffic ramp (see Section 3).
|
||||
# weight=0 means no traffic to that upstream.
|
||||
|
||||
upstream react_backend {
|
||||
server <REACT_UPSTREAM> weight=100;
|
||||
}
|
||||
|
||||
upstream angular_backend {
|
||||
server <ANGULAR_UPSTREAM> weight=0; # Set to 0 at 100% cutover
|
||||
}
|
||||
|
||||
# Split block — used during ramp
|
||||
split_clients "${remote_addr}${request_uri}" $backend {
|
||||
# Adjust percentages during ramp (Section 3)
|
||||
# 5% react_backend; # Step 1
|
||||
# 25% react_backend; # Step 2
|
||||
# 50% react_backend; # Step 3
|
||||
100% react_backend; # Step 4 (final)
|
||||
* angular_backend;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name flights.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support for SignalR
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Health check — always probe the active backend
|
||||
location /health {
|
||||
proxy_pass http://react_backend/health;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 HAProxy
|
||||
|
||||
```haproxy
|
||||
# --- Phase 6 cutover: traffic split ---
|
||||
frontend flights_frontend
|
||||
bind *:443 ssl crt /etc/ssl/certs/flights.pem
|
||||
default_backend react_backend
|
||||
|
||||
# During ramp, use ACL + random stick-table for percentage split:
|
||||
# acl is_canary rand(100) lt 5 # 5%
|
||||
# use_backend react_backend if is_canary
|
||||
# default_backend angular_backend
|
||||
|
||||
backend react_backend
|
||||
server react1 <REACT_UPSTREAM> check
|
||||
option httpchk GET /health
|
||||
http-check expect status 200
|
||||
|
||||
backend angular_backend
|
||||
server angular1 <ANGULAR_UPSTREAM> check
|
||||
# Set to "disabled" state after 100% cutover:
|
||||
# server angular1 <ANGULAR_UPSTREAM> check disabled
|
||||
```
|
||||
|
||||
### 2.3 Customer LB (generic)
|
||||
|
||||
```
|
||||
# If the customer uses a proprietary load balancer or CDN (e.g., F5, AWS ALB, Cloudflare):
|
||||
#
|
||||
# 1. Create a weighted target group / origin pool with two backends:
|
||||
# - React: weight = <RAMP_PERCENTAGE>
|
||||
# - Angular: weight = <100 - RAMP_PERCENTAGE>
|
||||
#
|
||||
# 2. Attach both targets to the main listener rule for flights.example.com/*
|
||||
#
|
||||
# 3. Adjust weights per the schedule in Section 3.
|
||||
#
|
||||
# 4. At 100% cutover, remove the Angular target entirely.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Traffic Ramp Schedule
|
||||
|
||||
Total ramp duration: **72 hours** (3 days). Each step requires explicit go/no-go from the on-call engineer.
|
||||
|
||||
| Step | Time (T+) | React % | Angular % | Duration before next step | Go/No-Go by |
|
||||
|------|-----------|---------|-----------|---------------------------|-------------|
|
||||
| 1 | T+0h | 5% | 95% | 12 hours | On-call engineer |
|
||||
| 2 | T+12h | 25% | 75% | 12 hours | On-call engineer |
|
||||
| 3 | T+24h | 50% | 50% | 24 hours | On-call engineer |
|
||||
| 4 | T+48h | 100% | 0% | Start soak period | Tech lead |
|
||||
|
||||
### Step Execution
|
||||
|
||||
For each step:
|
||||
|
||||
1. **Update proxy weights** per Section 2 templates
|
||||
2. **Reload proxy config** (graceful reload, no dropped connections):
|
||||
```bash
|
||||
# nginx
|
||||
nginx -t && nginx -s reload
|
||||
|
||||
# HAProxy
|
||||
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
|
||||
```
|
||||
3. **Verify the split** by checking access logs for both backends
|
||||
4. **Monitor for the hold period** (Section 4)
|
||||
5. **Record go/no-go decision** in the incident channel with timestamp
|
||||
|
||||
---
|
||||
|
||||
## 4. Monitoring Checklist During Ramp
|
||||
|
||||
Monitor these metrics continuously during each ramp step. Any breach triggers the hold or rollback procedure.
|
||||
|
||||
### 4.1 Error Rate
|
||||
|
||||
- [ ] Overall error rate (5xx) < 0.5% throughout ramp step
|
||||
- [ ] No new error patterns in application logs
|
||||
- [ ] No CSP violation spikes
|
||||
- [ ] No unhandled promise rejections in client-side error tracking
|
||||
|
||||
### 4.2 Latency
|
||||
|
||||
- [ ] p50 latency within 10% of Angular baseline
|
||||
- [ ] p95 latency < 500ms
|
||||
- [ ] p99 latency < 2s
|
||||
- [ ] Time to First Byte (TTFB) < 800ms
|
||||
|
||||
### 4.3 Web Vitals (Core Web Vitals from RUM / Dynatrace)
|
||||
|
||||
- [ ] LCP (Largest Contentful Paint) < 2.5s
|
||||
- [ ] FID / INP (Interaction to Next Paint) < 200ms
|
||||
- [ ] CLS (Cumulative Layout Shift) < 0.1
|
||||
|
||||
### 4.4 Search Console
|
||||
|
||||
- [ ] No indexing coverage drops
|
||||
- [ ] No new crawl errors
|
||||
- [ ] Structured data (JSON-LD) validated without errors
|
||||
- [ ] No mobile usability regressions
|
||||
|
||||
### 4.5 SignalR Health
|
||||
|
||||
- [ ] SignalR hub connections stable (no reconnection storms)
|
||||
- [ ] Real-time flight updates arriving within 2s of server push
|
||||
- [ ] WebSocket upgrade success rate > 99%
|
||||
|
||||
### 4.6 Business Metrics
|
||||
|
||||
- [ ] Page views per session consistent with Angular baseline
|
||||
- [ ] Bounce rate not elevated > 5% above baseline
|
||||
- [ ] Analytics events firing (Yandex.Metrica, CTM, Variocube, Dynatrace)
|
||||
|
||||
### 4.7 Infrastructure
|
||||
|
||||
- [ ] CPU utilization < 70% on React nodes
|
||||
- [ ] Memory utilization stable (no upward trend)
|
||||
- [ ] No OOM kills
|
||||
- [ ] Health check (`/health`) returning 200 on all React nodes
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback Procedure
|
||||
|
||||
**Target: < 1 minute to restore Angular traffic.**
|
||||
|
||||
### 5.1 Trigger Criteria
|
||||
|
||||
Roll back immediately if any of:
|
||||
- Error rate exceeds 1% for more than 5 minutes
|
||||
- p95 latency exceeds 2x Angular baseline for more than 10 minutes
|
||||
- `/health` returns 503 on > 50% of React nodes
|
||||
- SignalR connections drop and do not recover within 5 minutes
|
||||
- Any S1 incident is declared
|
||||
|
||||
### 5.2 Rollback Steps
|
||||
|
||||
1. **Flip proxy weights back to Angular:**
|
||||
```bash
|
||||
# nginx — set react weight=0, angular weight=100
|
||||
# Edit the split_clients block or upstream weights
|
||||
nginx -t && nginx -s reload
|
||||
|
||||
# HAProxy — switch default_backend to angular_backend
|
||||
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
|
||||
|
||||
# Customer LB — set React target weight to 0, Angular to 100
|
||||
```
|
||||
|
||||
2. **Verify Angular is serving traffic:**
|
||||
```bash
|
||||
curl -sI https://flights.example.com/ | grep -i server
|
||||
# Should show the ASP.NET/Angular response headers
|
||||
```
|
||||
|
||||
3. **Confirm health:**
|
||||
- [ ] Error rate returning to baseline
|
||||
- [ ] p95 latency returning to baseline
|
||||
- [ ] `/health` returning 200
|
||||
|
||||
4. **Notify stakeholders** in the incident channel
|
||||
|
||||
5. **Post-mortem:** File within 24 hours. Determine root cause before attempting another ramp.
|
||||
|
||||
### 5.3 Post-Rollback
|
||||
|
||||
- Do NOT re-attempt ramp until the root cause is identified and fixed
|
||||
- Re-run the full pre-cutover checklist (Section 1) before the next attempt
|
||||
- Consider a smaller initial percentage (2% instead of 5%) for the retry
|
||||
|
||||
---
|
||||
|
||||
## 6. One-Week Soak Criteria
|
||||
|
||||
After reaching 100% React traffic (T+48h), maintain for **7 calendar days** before proceeding to decommission.
|
||||
|
||||
### 6.1 Soak Pass Criteria
|
||||
|
||||
All of these must be true for the entire 7-day period:
|
||||
|
||||
| Criterion | Threshold | How to verify |
|
||||
|-----------|-----------|---------------|
|
||||
| Angular traffic | **Zero hits** in Angular access logs | `grep -c "angular_backend" /var/log/nginx/access.log` returns 0 for each day |
|
||||
| Error rate | < 0.1% (5xx responses) | Monitoring dashboard daily average |
|
||||
| p95 latency | < 500ms | Monitoring dashboard daily p95 |
|
||||
| p99 latency | < 2s | Monitoring dashboard daily p99 |
|
||||
| Core Web Vitals | All "Good" threshold | RUM / Dynatrace daily report |
|
||||
| Search Console | No coverage regression | Weekly Search Console report |
|
||||
| SignalR health | No reconnection storms, < 0.1% dropped connections | SignalR hub metrics |
|
||||
| Analytics parity | Event counts within 5% of pre-cutover Angular baseline | Analytics dashboard comparison |
|
||||
| OOM / restart count | Zero unexpected container restarts | Container orchestrator logs |
|
||||
|
||||
### 6.2 Soak Failure
|
||||
|
||||
If any criterion is breached during the soak:
|
||||
|
||||
1. **Do NOT immediately roll back** unless it meets Section 5.1 trigger criteria
|
||||
2. Investigate the root cause
|
||||
3. If a fix is deployed, restart the 7-day soak clock
|
||||
4. If the issue is transient and recovers within 1 hour, document but do not restart the clock
|
||||
|
||||
### 6.3 Soak Sign-Off
|
||||
|
||||
At the end of the 7-day soak, obtain written sign-off from:
|
||||
|
||||
- [ ] Tech lead
|
||||
- [ ] QA lead
|
||||
- [ ] Project manager
|
||||
- [ ] Customer representative (if required by contract)
|
||||
|
||||
---
|
||||
|
||||
## 7. Angular Decommission Steps
|
||||
|
||||
Only proceed after soak sign-off (Section 6.3).
|
||||
|
||||
### 7.1 Git Tag
|
||||
|
||||
Tag the last commit that includes the Angular code:
|
||||
|
||||
```bash
|
||||
# Identify the current HEAD (should be the release branch tip)
|
||||
git log --oneline -1
|
||||
|
||||
# Create an annotated tag
|
||||
git tag -a angular-final -m "Final state of Angular codebase before decommission"
|
||||
|
||||
# Push the tag to the remote
|
||||
git push origin angular-final
|
||||
```
|
||||
|
||||
### 7.2 Archive Branch
|
||||
|
||||
Create an archive branch preserving the full Angular codebase:
|
||||
|
||||
```bash
|
||||
# Create archive branch from current HEAD
|
||||
git checkout -b archive/angular-spa
|
||||
|
||||
# Push to remote
|
||||
git push -u origin archive/angular-spa
|
||||
|
||||
# Return to the main development branch
|
||||
git checkout plan/react-rewrite # or main, depending on workflow
|
||||
```
|
||||
|
||||
### 7.3 Remove Angular / ASP.NET Files
|
||||
|
||||
**WARNING: Only do this after customer approval. This runbook does NOT execute this step automatically.**
|
||||
|
||||
Files and directories to remove (review with the customer first):
|
||||
|
||||
```
|
||||
ClientApp/ # Angular SPA source
|
||||
src/
|
||||
angular.json
|
||||
karma.conf.js
|
||||
tsconfig*.json (Angular-specific)
|
||||
package.json (Angular)
|
||||
cypress/
|
||||
.storybook/
|
||||
...
|
||||
|
||||
Aeroflot.Flights.Web.csproj # ASP.NET host project
|
||||
Startup.cs # ASP.NET startup
|
||||
Program.cs # ASP.NET entry point
|
||||
Controllers/ # ASP.NET controllers (if any)
|
||||
wwwroot/ # Static assets served by ASP.NET
|
||||
appsettings*.json # ASP.NET configuration
|
||||
*.sln # .NET solution file
|
||||
```
|
||||
|
||||
Steps:
|
||||
|
||||
1. Create a new branch for the cleanup:
|
||||
```bash
|
||||
git checkout -b chore/remove-angular-code
|
||||
```
|
||||
|
||||
2. Remove the files listed above (verify the list with `git status` before committing)
|
||||
|
||||
3. Update `.gitignore` to remove Angular/ASP.NET-specific entries
|
||||
|
||||
4. Run the full verification suite:
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm build:both
|
||||
```
|
||||
|
||||
5. Commit and create a PR for review
|
||||
|
||||
6. After merge, verify production deployment is unaffected
|
||||
|
||||
### 7.4 Infrastructure Cleanup
|
||||
|
||||
After the Angular code is removed and deployed:
|
||||
|
||||
- [ ] Decommission Angular backend VMs / containers
|
||||
- [ ] Remove Angular upstream from load balancer configuration
|
||||
- [ ] Remove Angular-specific monitoring dashboards (or archive them)
|
||||
- [ ] Update DNS records if Angular was on a separate subdomain
|
||||
- [ ] Revoke Angular-specific secrets / certificates if any
|
||||
- [ ] Update architecture diagrams and documentation
|
||||
|
||||
### 7.5 Post-Decommission Verification
|
||||
|
||||
- [ ] All routes return correct responses from React
|
||||
- [ ] No references to Angular backend in proxy configs
|
||||
- [ ] No orphaned infrastructure resources
|
||||
- [ ] Documentation updated to reflect React-only architecture
|
||||
- [ ] Incident runbook (docs/superpowers/phase-1/runbook.md) updated to remove Angular references
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Quick Reference Commands
|
||||
|
||||
```bash
|
||||
# Check which backend is serving a request
|
||||
curl -sI https://flights.example.com/ | grep -E "Server|X-Powered-By"
|
||||
|
||||
# Check Angular access log for hits (should be zero during soak)
|
||||
grep -c "angular_backend" /var/log/nginx/access.log
|
||||
|
||||
# Verify React health
|
||||
curl -s https://flights.example.com/health | jq .
|
||||
|
||||
# Check MF manifest is accessible
|
||||
curl -sI https://flights.example.com/mf-manifest.json
|
||||
|
||||
# Verify SSR is working (check for rendered HTML in response body)
|
||||
curl -s https://flights.example.com/ru/onlineboard | grep -c "data-mf-expose"
|
||||
|
||||
# Check SignalR hub connectivity
|
||||
curl -s https://flights.example.com/signalr/negotiate -X POST | jq .
|
||||
```
|
||||
|
||||
## Appendix B: Contacts
|
||||
|
||||
| Role | Name | Contact |
|
||||
|------|------|---------|
|
||||
| Tech lead | TBD | TBD |
|
||||
| On-call engineer | TBD | TBD |
|
||||
| QA lead | TBD | TBD |
|
||||
| SRE | TBD | TBD |
|
||||
| Customer representative | TBD | TBD |
|
||||
| Incident channel | TBD | TBD |
|
||||
|
||||
---
|
||||
|
||||
## Appendix C: Timeline Summary
|
||||
|
||||
```
|
||||
Day 0 : Pre-cutover checklist complete, customer sign-off
|
||||
Day 0-3 : Traffic ramp (5% -> 25% -> 50% -> 100%)
|
||||
Day 3-10 : One-week soak at 100% React
|
||||
Day 10 : Soak sign-off
|
||||
Day 10+ : Angular decommission (git tag, archive, file removal)
|
||||
Day 11+ : Infrastructure cleanup
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
// ESLint v9 flat config. Mirrors the rule set from
|
||||
// docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md (Task 4).
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import boundaries from "eslint-plugin-boundaries";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"ClientApp/**",
|
||||
"wwwroot/**",
|
||||
"**/*.cjs",
|
||||
"pnpm-lock.yaml",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
project: "./tsconfig.json",
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
boundaries,
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
},
|
||||
},
|
||||
"boundaries/elements": [
|
||||
{ type: "routes", pattern: "src/routes/*" },
|
||||
{ type: "mf", pattern: "src/mf/*" },
|
||||
{ type: "features", pattern: "src/features/*", capture: ["feature"] },
|
||||
{ type: "ui", pattern: "src/ui/*" },
|
||||
{ type: "shared", pattern: "src/shared/*" },
|
||||
{ type: "observability", pattern: "src/observability/*" },
|
||||
{ type: "i18n", pattern: "src/i18n/*" },
|
||||
{ type: "env", pattern: "src/env/*" },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
// Design spec §1.2 layered dependency direction:
|
||||
// features/ cannot import routes/ or mf/
|
||||
// ui/ cannot import features/
|
||||
// shared/ cannot import features/, routes/, mf/, observability/
|
||||
// observability/ cannot import features/, routes/, mf/
|
||||
"boundaries/element-types": [
|
||||
"error",
|
||||
{
|
||||
default: "allow",
|
||||
rules: [
|
||||
{
|
||||
from: "features",
|
||||
disallow: ["routes", "mf"],
|
||||
message: "Features must not import from routes/ or mf/. Use the HostContract or a shared module instead.",
|
||||
},
|
||||
{
|
||||
from: "ui",
|
||||
disallow: ["features", "routes", "mf"],
|
||||
message: "UI layer must not import from features/, routes/, or mf/. UI is consumed by features, not the other way around.",
|
||||
},
|
||||
{
|
||||
from: "shared",
|
||||
disallow: ["features", "routes", "mf", "observability"],
|
||||
message: "Shared modules must not import from features/, routes/, mf/, or observability/.",
|
||||
},
|
||||
{
|
||||
from: "observability",
|
||||
disallow: ["features", "routes", "mf"],
|
||||
message: "Observability modules must not import from features/, routes/, or mf/.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// --- Restricted imports (master plan §1A-3) ---
|
||||
// OTel SDK internals + react-i18next: merged into one block so ESLint 9
|
||||
// flat config doesn't override one rule with the other.
|
||||
// otel.ts is allowed to import OTel SDK; provider.tsx is allowed to import react-i18next.
|
||||
// Neither file needs the other's exemption, so combining ignores is safe.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/observability/metrics/otel.ts", "src/observability/metrics/otel.test.ts", "src/i18n/provider.tsx"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@opentelemetry/sdk-metrics",
|
||||
message: "Import from @opentelemetry/api or use getMeter() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
|
||||
},
|
||||
{
|
||||
name: "@opentelemetry/sdk-node",
|
||||
message: "Import from @opentelemetry/api or use getMeter()/getTracer() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
|
||||
},
|
||||
{
|
||||
name: "react-i18next",
|
||||
message: "Import useTranslation from @/i18n/provider instead. Direct react-i18next imports are restricted to src/i18n/provider.tsx.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// @microsoft/signalr: forbidden in files that run during SSR.
|
||||
// SSR-bundle = routes/ and server/ directories. Features + shared/hooks are client-side.
|
||||
{
|
||||
files: ["src/routes/**/*.{ts,tsx}", "src/server/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@microsoft/signalr",
|
||||
message: "SignalR must not be imported in SSR-bundle files (routes/, server/). Use dynamic import in a useEffect or a client-only wrapper.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// window.localStorage / window.sessionStorage: only src/shared/storage.ts
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: ["src/shared/storage.ts", "src/shared/storage.test.ts"],
|
||||
rules: {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
name: "localStorage",
|
||||
message: "Use the storage module from @/shared/storage instead. Direct localStorage access is restricted to src/shared/storage.ts.",
|
||||
},
|
||||
{
|
||||
name: "sessionStorage",
|
||||
message: "Use the storage module from @/shared/storage instead. Direct sessionStorage access is restricted to src/shared/storage.ts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { appTools, defineConfig } from "@modern-js/app-tools";
|
||||
import { moduleFederationPlugin } from "@module-federation/modern-js";
|
||||
|
||||
const buildTarget = process.env["BUILD_TARGET"];
|
||||
const isRemote = buildTarget === "remote";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()],
|
||||
server: isRemote ? {} : { ssr: { mode: "stream" } },
|
||||
output: {
|
||||
distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createModuleFederationConfig } from "@module-federation/modern-js";
|
||||
|
||||
export default createModuleFederationConfig({
|
||||
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",
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true, requiredVersion: "^18.2.0" },
|
||||
"react-dom": { singleton: true, requiredVersion: "^18.2.0" },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@aeroflot/flights-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Aeroflot Flights — Modern.js + MF 2.0 React remote component (Phase 1 foundation)",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.2.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3",
|
||||
"scripts": {
|
||||
"dev": "modern dev",
|
||||
"build:standalone": "BUILD_TARGET=standalone modern build",
|
||||
"build:remote": "BUILD_TARGET=remote modern build",
|
||||
"build:both": "pnpm build:standalone && pnpm build:remote",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@modern-js/app-tools": "2.70.8",
|
||||
"@modern-js/runtime": "^3.1.3",
|
||||
"@module-federation/enhanced": "2.3.2",
|
||||
"@module-federation/modern-js": "2.3.2",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
||||
"@opentelemetry/sdk-node": "^0.214.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-icu": "^2.0.0",
|
||||
"i18next-resources-to-backend": "^1.0.0",
|
||||
"intl-messageformat": "^10.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lru-cache": "^10.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"schema-dts": "^2.0.0",
|
||||
"web-vitals": "^5.2.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-boundaries": "^5.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.0.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"react-test-renderer": "^19.2.5",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+13760
File diff suppressed because it is too large
Load Diff
Vendored
+91
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("getEnv", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear env to a clean slate, then set a minimum valid shape.
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("VITEST_")) continue;
|
||||
delete process.env[key];
|
||||
}
|
||||
process.env["NODE_ENV"] = "testing";
|
||||
process.env["BUILD_TARGET"] = "standalone";
|
||||
process.env["PROD_ORIGIN"] = "https://flights.aeroflot.ru";
|
||||
process.env["API_BASE_URL"] = "https://platform.aeroflot.ru";
|
||||
process.env["SIGNALR_HUB_URL"] = "wss://platform.aeroflot.ru/hub";
|
||||
process.env["ANALYTICS_METRICA"] = "true";
|
||||
process.env["ANALYTICS_CTM"] = "false";
|
||||
process.env["ANALYTICS_VARIOCUBE"] = "false";
|
||||
process.env["ANALYTICS_DYNATRACE"] = "true";
|
||||
process.env["VERSION"] = "abc1234";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = { ...originalEnv };
|
||||
// Reset the module cache so getEnv re-reads the updated env.
|
||||
const mod = await import("./index.js");
|
||||
mod.__resetEnvCacheForTests();
|
||||
});
|
||||
|
||||
it("returns a typed Env object for valid input", async () => {
|
||||
const { getEnv } = await import("./index.js");
|
||||
const env = getEnv();
|
||||
expect(env.NODE_ENV).toBe("testing");
|
||||
expect(env.BUILD_TARGET).toBe("standalone");
|
||||
expect(env.PROD_ORIGIN).toBe("https://flights.aeroflot.ru");
|
||||
expect(env.API_BASE_URL).toBe("https://platform.aeroflot.ru");
|
||||
expect(env.SIGNALR_HUB_URL).toBe("wss://platform.aeroflot.ru/hub");
|
||||
expect(env.ANALYTICS_ENABLED).toEqual({
|
||||
metrica: true,
|
||||
ctm: false,
|
||||
variocube: false,
|
||||
dynatrace: true,
|
||||
});
|
||||
expect(env.VERSION).toBe("abc1234");
|
||||
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caches the result across calls (same object identity)", async () => {
|
||||
const { getEnv } = await import("./index.js");
|
||||
const a = getEnv();
|
||||
const b = getEnv();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("throws a readable error when a required field is missing", async () => {
|
||||
delete process.env["API_BASE_URL"];
|
||||
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
|
||||
__resetEnvCacheForTests();
|
||||
expect(() => getEnv()).toThrow(/API_BASE_URL/);
|
||||
});
|
||||
|
||||
it("throws when NODE_ENV is not one of the allowed values", async () => {
|
||||
process.env["NODE_ENV"] = "banana";
|
||||
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
|
||||
__resetEnvCacheForTests();
|
||||
expect(() => getEnv()).toThrow(/NODE_ENV/);
|
||||
});
|
||||
|
||||
it("throws when BUILD_TARGET is neither standalone nor remote", async () => {
|
||||
process.env["BUILD_TARGET"] = "hybrid";
|
||||
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
|
||||
__resetEnvCacheForTests();
|
||||
expect(() => getEnv()).toThrow(/BUILD_TARGET/);
|
||||
});
|
||||
|
||||
it("accepts optional OTEL_EXPORTER_OTLP_ENDPOINT when provided", async () => {
|
||||
process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://otel.example/v1/traces";
|
||||
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
|
||||
__resetEnvCacheForTests();
|
||||
const env = getEnv();
|
||||
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example/v1/traces");
|
||||
});
|
||||
|
||||
it("rejects OTEL_EXPORTER_OTLP_ENDPOINT that is not a URL", async () => {
|
||||
process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "not-a-url";
|
||||
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
|
||||
__resetEnvCacheForTests();
|
||||
expect(() => getEnv()).toThrow(/OTEL_EXPORTER_OTLP_ENDPOINT/);
|
||||
});
|
||||
});
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalyticsProviders } from "@/observability/analytics/types";
|
||||
|
||||
const boolish = z
|
||||
.enum(["true", "false", "1", "0"])
|
||||
.transform((v) => v === "true" || v === "1");
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "testing", "staging", "production"]),
|
||||
BUILD_TARGET: z.enum(["standalone", "remote"]),
|
||||
PROD_ORIGIN: z.string().url(),
|
||||
API_BASE_URL: z.string().url(),
|
||||
SIGNALR_HUB_URL: z.string().url(),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
||||
LOGS_ENDPOINT: z.string().url().optional(),
|
||||
ANALYTICS_METRICA: boolish.default("false"),
|
||||
ANALYTICS_CTM: boolish.default("false"),
|
||||
ANALYTICS_VARIOCUBE: boolish.default("false"),
|
||||
ANALYTICS_DYNATRACE: boolish.default("false"),
|
||||
FEATURE_FLIGHTS_MAP: boolish.default("false"),
|
||||
VERSION: z.string().min(1),
|
||||
});
|
||||
|
||||
type RawEnv = z.infer<typeof EnvSchema>;
|
||||
|
||||
export interface Env {
|
||||
NODE_ENV: RawEnv["NODE_ENV"];
|
||||
BUILD_TARGET: RawEnv["BUILD_TARGET"];
|
||||
PROD_ORIGIN: string;
|
||||
API_BASE_URL: string;
|
||||
SIGNALR_HUB_URL: string;
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
||||
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
||||
LOGS_ENDPOINT?: string;
|
||||
ANALYTICS_ENABLED: AnalyticsProviders;
|
||||
FEATURE_FLIGHTS_MAP: boolean;
|
||||
VERSION: string;
|
||||
}
|
||||
|
||||
let cached: Env | undefined;
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (cached) return cached;
|
||||
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new Error(`Invalid environment configuration: ${details}`);
|
||||
}
|
||||
|
||||
const raw = parsed.data;
|
||||
const result: Env = {
|
||||
NODE_ENV: raw.NODE_ENV,
|
||||
BUILD_TARGET: raw.BUILD_TARGET,
|
||||
PROD_ORIGIN: raw.PROD_ORIGIN,
|
||||
API_BASE_URL: raw.API_BASE_URL,
|
||||
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
|
||||
ANALYTICS_ENABLED: {
|
||||
metrica: raw.ANALYTICS_METRICA,
|
||||
ctm: raw.ANALYTICS_CTM,
|
||||
variocube: raw.ANALYTICS_VARIOCUBE,
|
||||
dynatrace: raw.ANALYTICS_DYNATRACE,
|
||||
},
|
||||
FEATURE_FLIGHTS_MAP: raw.FEATURE_FLIGHTS_MAP,
|
||||
VERSION: raw.VERSION,
|
||||
};
|
||||
if (raw.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined) {
|
||||
result.OTEL_EXPORTER_OTLP_ENDPOINT = raw.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
}
|
||||
if (raw.OTEL_EXPORTER_OTLP_HEADERS !== undefined) {
|
||||
result.OTEL_EXPORTER_OTLP_HEADERS = raw.OTEL_EXPORTER_OTLP_HEADERS;
|
||||
}
|
||||
if (raw.LOGS_ENDPOINT !== undefined) {
|
||||
result.LOGS_ENDPOINT = raw.LOGS_ENDPOINT;
|
||||
}
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: resets the module-level cache so tests can mutate process.env
|
||||
* and re-read. Do NOT call this from production code.
|
||||
*/
|
||||
export function __resetEnvCacheForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import { searchDestinations, getFlightsMapCalendar } from "./api";
|
||||
import type {
|
||||
FlightsMapSearchParams,
|
||||
IDestinationsResponse,
|
||||
IFlightsMapDaysResponse,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockClient(responseBody: unknown, status = 200) {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.test.com",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
|
||||
return { client, mockFetch };
|
||||
}
|
||||
|
||||
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
|
||||
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
|
||||
return new URL(call[0]);
|
||||
}
|
||||
|
||||
const DESTINATIONS_RESPONSE: IDestinationsResponse = {
|
||||
data: {
|
||||
routes: [
|
||||
{ route: ["SVO", "LED"], isDirect: true },
|
||||
{ route: ["SVO", "DME", "LED"], isDirect: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const DAYS_RESPONSE: IFlightsMapDaysResponse = {
|
||||
days: "01101",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchDestinations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchDestinations", () => {
|
||||
it("sends GET to destinations/1 with query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
|
||||
|
||||
const params: FlightsMapSearchParams = {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250601",
|
||||
dateTo: "20251201",
|
||||
};
|
||||
|
||||
await searchDestinations(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/destinations/1");
|
||||
expect(url.searchParams.get("departure")).toBe("SVO");
|
||||
expect(url.searchParams.get("arrival")).toBe("LED");
|
||||
expect(url.searchParams.get("dateFrom")).toBe("20250601");
|
||||
expect(url.searchParams.get("dateTo")).toBe("20251201");
|
||||
expect(url.searchParams.get("connections")).toBe("0");
|
||||
});
|
||||
|
||||
it("omits arrival param when not provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
|
||||
|
||||
await searchDestinations(client, {
|
||||
departure: "SVO",
|
||||
dateFrom: "20250601",
|
||||
dateTo: "20251201",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.has("arrival")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends connections param when specified", async () => {
|
||||
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
|
||||
|
||||
await searchDestinations(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250601",
|
||||
dateTo: "20251201",
|
||||
connections: 1,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("connections")).toBe("1");
|
||||
});
|
||||
|
||||
it("returns the deserialized response", async () => {
|
||||
const { client } = createMockClient(DESTINATIONS_RESPONSE);
|
||||
|
||||
const result = await searchDestinations(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20250601",
|
||||
dateTo: "20251201",
|
||||
});
|
||||
|
||||
expect(result).toEqual(DESTINATIONS_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
searchDestinations(client, {
|
||||
departure: "SVO",
|
||||
dateFrom: "20250601",
|
||||
dateTo: "20251201",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getFlightsMapCalendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFlightsMapCalendar", () => {
|
||||
it("builds correct path for direct route", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/days/20250601/200/route/SVO-LED/flights-map/v1");
|
||||
});
|
||||
|
||||
it("builds correct path for connecting route", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: true,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe(
|
||||
"/days/20250601/200/connections/SVO-LED-1/flights-map/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds correct path for departure-only (spider mode)", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/days/20250601/200/departure/SVO/flights-map/v1");
|
||||
});
|
||||
|
||||
it("parses binary days string into available date strings", async () => {
|
||||
const { client } = createMockClient({ days: "10110" });
|
||||
|
||||
const result = await getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(["20250601", "20250603", "20250604"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
const result = await getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
getFlightsMapCalendar(client, {
|
||||
date: "20250601",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Flights Map API functions.
|
||||
*
|
||||
* Pure functions -- each takes an `ApiClient` as a parameter (dependency
|
||||
* injection). No React hooks, no context, no side effects.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@/shared/api/client.js";
|
||||
import type {
|
||||
FlightsMapSearchParams,
|
||||
FlightsMapCalendarParams,
|
||||
IDestinationsResponse,
|
||||
IFlightsMapDaysResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search flight destinations/routes on the map.
|
||||
* Maps to: `GET destinations/1?departure=...&arrival=...&dateFrom=...&dateTo=...&connections=0`
|
||||
*/
|
||||
export async function searchDestinations(
|
||||
client: ApiClient,
|
||||
params: FlightsMapSearchParams,
|
||||
): Promise<IDestinationsResponse> {
|
||||
const query: Record<string, string> = {
|
||||
departure: params.departure,
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
connections: String(params.connections ?? 0),
|
||||
};
|
||||
|
||||
if (params.arrival) {
|
||||
query["arrival"] = params.arrival;
|
||||
}
|
||||
|
||||
return client.get<IDestinationsResponse>("destinations/1", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available calendar days for a flights-map route.
|
||||
* Maps to: `GET days/{date}/200/{routeSegment}/flights-map/v1`
|
||||
*
|
||||
* The `days` response field is a binary string ("0110...") where each
|
||||
* character represents one day starting from `date`. We parse it into
|
||||
* an array of available date strings.
|
||||
*/
|
||||
export async function getFlightsMapCalendar(
|
||||
client: ApiClient,
|
||||
params: FlightsMapCalendarParams,
|
||||
): Promise<string[]> {
|
||||
const routeSegment = buildRouteSegment(params);
|
||||
if (!routeSegment) return [];
|
||||
|
||||
const path = `days/${params.date}/200/${routeSegment}/flights-map/v1`;
|
||||
const response = await client.get<IFlightsMapDaysResponse>(path);
|
||||
return parseBinaryDays(response.days, params.date);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRouteSegment(params: FlightsMapCalendarParams): string | null {
|
||||
if (params.departure && params.arrival) {
|
||||
return params.connections
|
||||
? `connections/${params.departure}-${params.arrival}-1`
|
||||
: `route/${params.departure}-${params.arrival}`;
|
||||
}
|
||||
|
||||
if (params.departure) {
|
||||
return `departure/${params.departure}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a binary days string ("01101...") into an array of ISO date strings
|
||||
* representing the available (=1) days, starting from `startDate`.
|
||||
*/
|
||||
function parseBinaryDays(days: string, startDate: string): string[] {
|
||||
if (!days) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
const start = parseYyyymmdd(startDate);
|
||||
if (!start) return [];
|
||||
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
if (days[i] === "1") {
|
||||
const d = new Date(start);
|
||||
d.setDate(d.getDate() + i);
|
||||
result.push(formatYyyymmdd(d));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseYyyymmdd(s: string): Date | null {
|
||||
if (s.length !== 8) return null;
|
||||
const year = Number(s.slice(0, 4));
|
||||
const month = Number(s.slice(4, 6)) - 1;
|
||||
const day = Number(s.slice(6, 8));
|
||||
const d = new Date(year, month, day);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function formatYyyymmdd(d: Date): string {
|
||||
const y = d.getFullYear().toString();
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
return `${y}${m}${day}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* SSR-safe wrapper that renders children only on the client.
|
||||
*
|
||||
* Returns `null` during SSR. After hydration, renders children.
|
||||
* Used to wrap components that depend on browser APIs (e.g. Leaflet).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
|
||||
export interface ClientOnlyProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders children only after component has mounted in the browser.
|
||||
* Returns `fallback` (default: null) during SSR and initial render.
|
||||
*/
|
||||
export function ClientOnly({ children, fallback = null }: ClientOnlyProps): JSX.Element {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return <>{mounted ? children : fallback}</>;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Flights Map filter panel.
|
||||
*
|
||||
* Provides departure/arrival inputs, connections toggle, domestic/international
|
||||
* filter toggles, and date picker. Calls back to the parent via onChange.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type FC, useState, useCallback, type FormEvent } from "react";
|
||||
import type { IFlightsMapFilterState } from "../types.js";
|
||||
|
||||
export interface FlightsMapFilterProps {
|
||||
value: IFlightsMapFilterState;
|
||||
availableDays?: string[];
|
||||
onChange: (state: IFlightsMapFilterState) => void;
|
||||
}
|
||||
|
||||
function yyyymmddToDateInput(value: string): string {
|
||||
if (value.length !== 8) return "";
|
||||
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function dateInputToYyyymmdd(value: string): string {
|
||||
return value.replace(/-/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter component for the flights map. Controls departure, arrival,
|
||||
* connections, domestic/international toggles, and date selection.
|
||||
*/
|
||||
export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [departure, setDeparture] = useState(value.departure ?? "");
|
||||
const [arrival, setArrival] = useState(value.arrival ?? "");
|
||||
|
||||
const handleDepartureBlur = useCallback(() => {
|
||||
const code = departure.trim().toUpperCase();
|
||||
if (code !== value.departure) {
|
||||
onChange({ ...value, departure: code || undefined, arrival: undefined });
|
||||
setArrival("");
|
||||
}
|
||||
}, [departure, value, onChange]);
|
||||
|
||||
const handleArrivalBlur = useCallback(() => {
|
||||
const code = arrival.trim().toUpperCase();
|
||||
if (code !== value.arrival) {
|
||||
onChange({ ...value, arrival: code || undefined });
|
||||
}
|
||||
}, [arrival, value, onChange]);
|
||||
|
||||
const handleExchange = useCallback(() => {
|
||||
const newDep = value.arrival ?? "";
|
||||
const newArr = value.departure ?? "";
|
||||
setDeparture(newDep);
|
||||
setArrival(newArr);
|
||||
onChange({
|
||||
...value,
|
||||
departure: newDep || undefined,
|
||||
arrival: newArr || undefined,
|
||||
});
|
||||
}, [value, onChange]);
|
||||
|
||||
const handleConnectionsChange = useCallback(
|
||||
(e: FormEvent<HTMLInputElement>) => {
|
||||
onChange({ ...value, connections: (e.target as HTMLInputElement).checked });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const handleDomesticChange = useCallback(
|
||||
(e: FormEvent<HTMLInputElement>) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
onChange({
|
||||
...value,
|
||||
domestic: checked,
|
||||
international: checked ? false : value.international,
|
||||
});
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const handleInternationalChange = useCallback(
|
||||
(e: FormEvent<HTMLInputElement>) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
onChange({
|
||||
...value,
|
||||
international: checked,
|
||||
domestic: checked ? false : value.domestic,
|
||||
});
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(e: FormEvent<HTMLInputElement>) => {
|
||||
const dateValue = (e.target as HTMLInputElement).value;
|
||||
onChange({ ...value, date: dateValue ? dateInputToYyyymmdd(dateValue) : undefined });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flights-map-filter" data-testid="flights-map-filter">
|
||||
<div className="flights-map-filter__field">
|
||||
<label htmlFor="fm-departure">Departure</label>
|
||||
<input
|
||||
id="fm-departure"
|
||||
type="text"
|
||||
placeholder="e.g. SVO"
|
||||
maxLength={3}
|
||||
value={departure}
|
||||
onChange={(e) => setDeparture(e.target.value)}
|
||||
onBlur={handleDepartureBlur}
|
||||
data-testid="fm-departure-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flights-map-filter__exchange"
|
||||
onClick={handleExchange}
|
||||
aria-label="Exchange departure and arrival"
|
||||
data-testid="fm-exchange-btn"
|
||||
>
|
||||
⇆
|
||||
</button>
|
||||
|
||||
<div className="flights-map-filter__field">
|
||||
<label htmlFor="fm-arrival">Arrival</label>
|
||||
<input
|
||||
id="fm-arrival"
|
||||
type="text"
|
||||
placeholder="e.g. LED"
|
||||
maxLength={3}
|
||||
value={arrival}
|
||||
onChange={(e) => setArrival(e.target.value)}
|
||||
onBlur={handleArrivalBlur}
|
||||
data-testid="fm-arrival-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flights-map-filter__field">
|
||||
<label htmlFor="fm-date">Date</label>
|
||||
<input
|
||||
id="fm-date"
|
||||
type="date"
|
||||
value={value.date ? yyyymmddToDateInput(value.date) : ""}
|
||||
onChange={handleDateChange}
|
||||
data-testid="fm-date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flights-map-filter__toggles">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.connections}
|
||||
onChange={handleConnectionsChange}
|
||||
data-testid="fm-connections-toggle"
|
||||
/>
|
||||
Connections
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.domestic}
|
||||
onChange={handleDomesticChange}
|
||||
data-testid="fm-domestic-toggle"
|
||||
/>
|
||||
Domestic
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.international}
|
||||
onChange={handleInternationalChange}
|
||||
data-testid="fm-international-toggle"
|
||||
/>
|
||||
International
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Flights Map start page -- container component.
|
||||
*
|
||||
* Manages filter state, drives the map and calendar hooks, and renders
|
||||
* the filter panel + map canvas + loading/empty overlays.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type FC, lazy, Suspense, useState, useCallback, useMemo } from "react";
|
||||
import { ClientOnly } from "./ClientOnly.js";
|
||||
import { FlightsMapFilter } from "./FlightsMapFilter.js";
|
||||
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
|
||||
import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
import type {
|
||||
IFlightsMapFilterState,
|
||||
FlightsMapSearchParams,
|
||||
FlightsMapCalendarParams,
|
||||
IMapMarker,
|
||||
IMapPolyline,
|
||||
} from "../types.js";
|
||||
|
||||
const MapCanvas = lazy(() =>
|
||||
import("./MapCanvas.js").then((m) => ({ default: m.MapCanvas })),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function todayYyyymmdd(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear().toString();
|
||||
const m = (now.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = now.getDate().toString().padStart(2, "0");
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function addMonthsYyyymmdd(base: string, months: number): string {
|
||||
const y = Number(base.slice(0, 4));
|
||||
const m = Number(base.slice(4, 6)) - 1;
|
||||
const d = Number(base.slice(6, 8));
|
||||
const date = new Date(y, m + months, d);
|
||||
const ry = date.getFullYear().toString();
|
||||
const rm = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const rd = date.getDate().toString().padStart(2, "0");
|
||||
return `${ry}${rm}${rd}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FlightsMapStartPage: FC = () => {
|
||||
const env = getEnv();
|
||||
|
||||
const [filterState, setFilterState] = useState<IFlightsMapFilterState>({
|
||||
connections: false,
|
||||
domestic: false,
|
||||
international: false,
|
||||
});
|
||||
|
||||
// Build search params from filter state
|
||||
const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
|
||||
if (!filterState.departure) return null;
|
||||
|
||||
const today = todayYyyymmdd();
|
||||
return {
|
||||
departure: filterState.departure,
|
||||
arrival: filterState.arrival,
|
||||
dateFrom: today,
|
||||
dateTo: addMonthsYyyymmdd(today, 6),
|
||||
connections: filterState.connections ? 1 : 0,
|
||||
};
|
||||
}, [filterState.departure, filterState.arrival, filterState.connections]);
|
||||
|
||||
// Build calendar params
|
||||
const calendarParams = useMemo<FlightsMapCalendarParams | null>(() => {
|
||||
if (!filterState.departure) return null;
|
||||
|
||||
const today = todayYyyymmdd();
|
||||
return {
|
||||
date: today,
|
||||
departure: filterState.departure,
|
||||
arrival: filterState.arrival,
|
||||
connections: filterState.connections,
|
||||
};
|
||||
}, [filterState.departure, filterState.arrival, filterState.connections]);
|
||||
|
||||
const { routes, loading, error } = useFlightsMapSearch(searchParams);
|
||||
const { availableDays } = useFlightsMapCalendar(calendarParams);
|
||||
|
||||
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
|
||||
setFilterState(newState);
|
||||
}, []);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(markerId: string) => {
|
||||
if (!filterState.departure) {
|
||||
setFilterState((prev): IFlightsMapFilterState => ({ ...prev, departure: markerId }));
|
||||
} else if (!filterState.arrival && markerId !== filterState.departure) {
|
||||
setFilterState((prev): IFlightsMapFilterState => ({ ...prev, arrival: markerId }));
|
||||
} else {
|
||||
setFilterState((prev): IFlightsMapFilterState => ({
|
||||
...prev,
|
||||
departure: markerId,
|
||||
arrival: undefined,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filterState.departure, filterState.arrival],
|
||||
);
|
||||
|
||||
// Build markers and polylines from routes (placeholder -- real city
|
||||
// coordinates come from a dictionaries service in a future iteration)
|
||||
const markers = useMemo<IMapMarker[]>(() => [], []);
|
||||
const polylines = useMemo<IMapPolyline[]>(() => [], []);
|
||||
|
||||
// Tile URL from env or default
|
||||
const tileUrl = `${env.API_BASE_URL}/tiles/{z}/{x}/{y}.png`;
|
||||
|
||||
return (
|
||||
<div className="flights-map-start" data-testid="flights-map-start">
|
||||
<h1 className="flights-map-start__title">Flight Map</h1>
|
||||
|
||||
<FlightsMapFilter
|
||||
value={filterState}
|
||||
availableDays={availableDays}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
<div className="flights-map-start__map-wrapper">
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<div aria-busy="true" data-testid="map-loading">
|
||||
Loading map...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div aria-busy="true" data-testid="map-loading">
|
||||
Loading map...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MapCanvas
|
||||
markers={markers}
|
||||
polylines={polylines}
|
||||
tileUrl={tileUrl}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
className="flights-map-start__map"
|
||||
/>
|
||||
</Suspense>
|
||||
</ClientOnly>
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
className="flights-map-start__loader"
|
||||
aria-busy="true"
|
||||
data-testid="map-loader"
|
||||
>
|
||||
Loading routes...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div
|
||||
className="flights-map-start__error"
|
||||
role="alert"
|
||||
data-testid="map-error"
|
||||
>
|
||||
Failed to load routes. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
!error &&
|
||||
searchParams !== null &&
|
||||
routes.length === 0 && (
|
||||
<div
|
||||
className="flights-map-start__empty"
|
||||
data-testid="map-no-directions"
|
||||
>
|
||||
No directions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Leaflet map wrapper for the Flights Map feature.
|
||||
*
|
||||
* **This is the ONLY file in the codebase that imports `leaflet`.**
|
||||
* Per design spec, all Leaflet usage is encapsulated here.
|
||||
*
|
||||
* Wrapped in React.lazy() + <ClientOnly> by consumers for SSR safety.
|
||||
* Accepts markers, polylines, popups as props -- does not own state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, type FC } from "react";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import type { IMapMarker, IMapPolyline, IMapPopup, MarkerStyle, PolylineStyle } from "../types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker icons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARKER_ICONS: Record<MarkerStyle, L.IconOptions> = {
|
||||
blue: {
|
||||
iconUrl: "/assets/img/leaflet/marker-blue.png",
|
||||
iconSize: [15, 15] as L.PointExpression,
|
||||
iconAnchor: [7, 7] as L.PointExpression,
|
||||
popupAnchor: [0, -10] as L.PointExpression,
|
||||
},
|
||||
"blue-small": {
|
||||
iconUrl: "/assets/img/leaflet/marker-blue-small.png",
|
||||
iconSize: [11, 11] as L.PointExpression,
|
||||
iconAnchor: [5, 5] as L.PointExpression,
|
||||
popupAnchor: [0, -10] as L.PointExpression,
|
||||
},
|
||||
orange: {
|
||||
iconUrl: "/assets/img/leaflet/marker-orange.png",
|
||||
iconSize: [20, 20] as L.PointExpression,
|
||||
iconAnchor: [10, 10] as L.PointExpression,
|
||||
popupAnchor: [0, -20] as L.PointExpression,
|
||||
},
|
||||
};
|
||||
|
||||
function getIcon(style: MarkerStyle): L.Icon {
|
||||
const opts = MARKER_ICONS[style];
|
||||
return L.icon(opts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polyline styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POLYLINE_STYLES: Record<PolylineStyle, L.PolylineOptions> = {
|
||||
direct: {
|
||||
color: "#2457ff",
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
connecting: {
|
||||
color: "#2433ff",
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
dashArray: "4 14",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Great-circle arc calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deg2rad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function rad2deg(rad: number): number {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function buildGreatCircleArc(
|
||||
from: L.LatLng,
|
||||
to: L.LatLng,
|
||||
segments = 64,
|
||||
): L.LatLng[] {
|
||||
const phi1 = deg2rad(from.lat);
|
||||
const lam1 = deg2rad(from.lng);
|
||||
const phi2 = deg2rad(to.lat);
|
||||
const lam2 = deg2rad(to.lng);
|
||||
|
||||
const delta =
|
||||
2 *
|
||||
Math.asin(
|
||||
Math.sqrt(
|
||||
Math.sin((phi2 - phi1) / 2) ** 2 +
|
||||
Math.cos(phi1) *
|
||||
Math.cos(phi2) *
|
||||
Math.sin((lam2 - lam1) / 2) ** 2,
|
||||
),
|
||||
);
|
||||
|
||||
if (delta === 0) return [from, to];
|
||||
|
||||
const points: L.LatLng[] = [];
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const f = i / segments;
|
||||
const A = Math.sin((1 - f) * delta) / Math.sin(delta);
|
||||
const B = Math.sin(f * delta) / Math.sin(delta);
|
||||
|
||||
const x =
|
||||
A * Math.cos(phi1) * Math.cos(lam1) +
|
||||
B * Math.cos(phi2) * Math.cos(lam2);
|
||||
const y =
|
||||
A * Math.cos(phi1) * Math.sin(lam1) +
|
||||
B * Math.cos(phi2) * Math.sin(lam2);
|
||||
const z = A * Math.sin(phi1) + B * Math.sin(phi2);
|
||||
|
||||
const lat = rad2deg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
||||
const lng = rad2deg(Math.atan2(y, x));
|
||||
|
||||
points.push(L.latLng(lat, lng));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MapCanvasProps {
|
||||
markers: IMapMarker[];
|
||||
polylines: IMapPolyline[];
|
||||
popups?: IMapPopup[];
|
||||
tileUrl: string;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
onMarkerClick?: (markerId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Leaflet map canvas. Must be loaded via React.lazy() and wrapped in
|
||||
* <ClientOnly> for SSR safety.
|
||||
*/
|
||||
export const MapCanvas: FC<MapCanvasProps> = ({
|
||||
markers,
|
||||
polylines,
|
||||
popups,
|
||||
tileUrl,
|
||||
center = [53, 45],
|
||||
zoom = 5,
|
||||
minZoom = 3,
|
||||
maxZoom = 6,
|
||||
onMarkerClick,
|
||||
className,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markersLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const polylinesLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const popupsLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
const onMarkerClickRef = useRef(onMarkerClick);
|
||||
onMarkerClickRef.current = onMarkerClick;
|
||||
|
||||
// --- Initialize map ---
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const southWest: L.LatLngExpression = [-70, -185];
|
||||
const northEast: L.LatLngExpression = [80, 200];
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
center,
|
||||
zoom,
|
||||
attributionControl: false,
|
||||
maxBounds: [southWest, northEast],
|
||||
maxBoundsViscosity: 1,
|
||||
});
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom,
|
||||
minZoom,
|
||||
}).addTo(map);
|
||||
|
||||
markersLayerRef.current = L.layerGroup().addTo(map);
|
||||
polylinesLayerRef.current = L.layerGroup().addTo(map);
|
||||
popupsLayerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
markersLayerRef.current = null;
|
||||
polylinesLayerRef.current = null;
|
||||
popupsLayerRef.current = null;
|
||||
};
|
||||
// Only run once on mount -- stable props used from refs
|
||||
}, []);
|
||||
|
||||
// --- Sync markers ---
|
||||
const syncMarkers = useCallback(() => {
|
||||
const layer = markersLayerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
for (const m of markers) {
|
||||
const marker = L.marker([m.lat, m.lng], {
|
||||
icon: getIcon(m.style),
|
||||
title: m.id,
|
||||
});
|
||||
|
||||
if (m.label) {
|
||||
marker.bindTooltip(m.label, {
|
||||
permanent: m.tooltipPermanent ?? false,
|
||||
direction: "top",
|
||||
className: "city-label",
|
||||
});
|
||||
}
|
||||
|
||||
marker.on("click", () => {
|
||||
onMarkerClickRef.current?.(m.id);
|
||||
});
|
||||
|
||||
marker.addTo(layer);
|
||||
}
|
||||
}, [markers]);
|
||||
|
||||
useEffect(() => {
|
||||
syncMarkers();
|
||||
}, [syncMarkers]);
|
||||
|
||||
// --- Sync polylines ---
|
||||
useEffect(() => {
|
||||
const layer = polylinesLayerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
for (const pl of polylines) {
|
||||
const style = POLYLINE_STYLES[pl.style];
|
||||
|
||||
// Build great-circle arcs between consecutive points
|
||||
const arcPoints: L.LatLng[] = [];
|
||||
for (let i = 0; i < pl.points.length - 1; i++) {
|
||||
const ptFrom = pl.points[i];
|
||||
const ptTo = pl.points[i + 1];
|
||||
if (!ptFrom || !ptTo) continue;
|
||||
const from = L.latLng(ptFrom.lat, ptFrom.lng);
|
||||
const to = L.latLng(ptTo.lat, ptTo.lng);
|
||||
const arc = buildGreatCircleArc(from, to);
|
||||
arcPoints.push(...(i === 0 ? arc : arc.slice(1)));
|
||||
}
|
||||
|
||||
if (arcPoints.length >= 2) {
|
||||
L.polyline(arcPoints, style).addTo(layer);
|
||||
}
|
||||
}
|
||||
}, [polylines]);
|
||||
|
||||
// --- Sync popups ---
|
||||
useEffect(() => {
|
||||
const layer = popupsLayerRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!layer || !map) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
if (!popups) return;
|
||||
|
||||
for (const p of popups) {
|
||||
L.popup({
|
||||
closeButton: true,
|
||||
autoClose: false,
|
||||
closeOnClick: false,
|
||||
})
|
||||
.setLatLng([p.lat, p.lng])
|
||||
.setContent(p.content)
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [popups]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
data-testid="map-canvas"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Feature flag hook for the flights-map feature.
|
||||
*
|
||||
* Reads from environment configuration. In future phases this can be
|
||||
* extended to read from a remote feature flag service.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { getEnv } from "@/env/index.js";
|
||||
|
||||
/**
|
||||
* Known feature flag names.
|
||||
*/
|
||||
type FeatureFlagName = "flightsMap";
|
||||
|
||||
/**
|
||||
* Check whether a feature flag is enabled.
|
||||
* Currently backed by env vars; extend to remote config as needed.
|
||||
*/
|
||||
export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
const env = getEnv();
|
||||
|
||||
switch (flag) {
|
||||
case "flightsMap":
|
||||
return env.FEATURE_FLIGHTS_MAP ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* React hook for flights map calendar day availability.
|
||||
*
|
||||
* Calls `getFlightsMapCalendar` on param change, manages loading/days state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getFlightsMapCalendar } from "../api.js";
|
||||
import type { FlightsMapCalendarParams } from "../types.js";
|
||||
|
||||
export interface UseFlightsMapCalendarResult {
|
||||
availableDays: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the calendar strip. Fetches available flights-map days for the
|
||||
* given route context.
|
||||
*/
|
||||
export function useFlightsMapCalendar(
|
||||
params: FlightsMapCalendarParams | null,
|
||||
): UseFlightsMapCalendarResult {
|
||||
const client = useApiClient();
|
||||
const [availableDays, setAvailableDays] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!paramsRef.current) {
|
||||
setAvailableDays([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
getFlightsMapCalendar(client, paramsRef.current)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setAvailableDays(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAvailableDays([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params?.date,
|
||||
params?.departure,
|
||||
params?.arrival,
|
||||
params?.connections,
|
||||
]);
|
||||
|
||||
return { availableDays, loading };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* React hook for flights map destination search.
|
||||
*
|
||||
* Calls `searchDestinations` on param change, manages loading/error/data state.
|
||||
* No SignalR -- map data is static.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { searchDestinations } from "../api.js";
|
||||
import type { FlightsMapSearchParams, IDestinationsResponse, IFlightRoute } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseFlightsMapSearchResult {
|
||||
routes: IFlightRoute[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the flights map. Fetches destination routes based on search params
|
||||
* and provides refresh capability.
|
||||
*/
|
||||
export function useFlightsMapSearch(
|
||||
params: FlightsMapSearchParams | null,
|
||||
): UseFlightsMapSearchResult {
|
||||
const client = useApiClient();
|
||||
const [routes, setRoutes] = useState<IFlightRoute[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paramsRef.current) {
|
||||
setRoutes([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
searchDestinations(client, paramsRef.current)
|
||||
.then((response: IDestinationsResponse) => {
|
||||
if (!cancelled) {
|
||||
setRoutes(response.data.routes);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setRoutes([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params?.departure,
|
||||
params?.arrival,
|
||||
params?.dateFrom,
|
||||
params?.dateTo,
|
||||
params?.connections,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
return { routes, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Public barrel for the flights-map feature. See frozen-barrels.md.
|
||||
|
||||
// 4A -- Types
|
||||
export type {
|
||||
FlightsMapSearchParams,
|
||||
FlightsMapCalendarParams,
|
||||
IFlightRoute,
|
||||
IDestinationsResponse,
|
||||
IFlightsMapDaysResponse,
|
||||
IMapMarker,
|
||||
IMapPolyline,
|
||||
IMapPopup,
|
||||
MarkerStyle,
|
||||
PolylineStyle,
|
||||
IFlightsMapFilterState,
|
||||
} from "./types.js";
|
||||
|
||||
// 4A -- API functions
|
||||
export { searchDestinations, getFlightsMapCalendar } from "./api.js";
|
||||
|
||||
// 4A -- React hooks
|
||||
export { useFlightsMapSearch } from "./hooks/useFlightsMapSearch.js";
|
||||
export type { UseFlightsMapSearchResult } from "./hooks/useFlightsMapSearch.js";
|
||||
export { useFlightsMapCalendar } from "./hooks/useFlightsMapCalendar.js";
|
||||
export type { UseFlightsMapCalendarResult } from "./hooks/useFlightsMapCalendar.js";
|
||||
export { useFeatureFlag } from "./hooks/useFeatureFlag.js";
|
||||
|
||||
// 4D -- SEO builder functions
|
||||
export { buildFlightsMapSeo } from "./seo.js";
|
||||
export type { TFunction } from "./seo.js";
|
||||
|
||||
// 4D -- JSON-LD builder functions
|
||||
export { buildFlightsMapJsonLd } from "./json-ld.js";
|
||||
|
||||
// 4C -- Feature-specific page components
|
||||
export { FlightsMapStartPage } from "./components/FlightsMapStartPage.js";
|
||||
export { FlightsMapFilter } from "./components/FlightsMapFilter.js";
|
||||
export type { FlightsMapFilterProps } from "./components/FlightsMapFilter.js";
|
||||
export { ClientOnly } from "./components/ClientOnly.js";
|
||||
export type { ClientOnlyProps } from "./components/ClientOnly.js";
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* JSON-LD schema builder for the Flights Map page.
|
||||
*
|
||||
* Produces a schema.org WebPage typed object ready for <JsonLdRenderer>.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { WebPage } from "schema-dts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SITE_NAME = "Aeroflot";
|
||||
const PATH_WITHOUT_LOCALE = "/flights-map";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a schema.org WebPage JSON-LD object for the flights map page.
|
||||
*/
|
||||
export function buildFlightsMapJsonLd(
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): WebPage {
|
||||
const url = `${canonicalOrigin}/${locale}${PATH_WITHOUT_LOCALE}`;
|
||||
|
||||
return {
|
||||
"@type": "WebPage",
|
||||
name: `${SITE_NAME} - Flight Map`,
|
||||
url,
|
||||
inLanguage: locale,
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: SITE_NAME,
|
||||
url: canonicalOrigin,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFlightsMapSeo } from "./seo.js";
|
||||
import { buildFlightsMapJsonLd } from "./json-ld.js";
|
||||
|
||||
/** Stub t() that returns the key for assertion. */
|
||||
function stubT(key: string): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
const CANONICAL = "https://www.aeroflot.ru";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFlightsMapSeo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFlightsMapSeo", () => {
|
||||
it("uses FLIGHTS_MAP translation keys", () => {
|
||||
const result = buildFlightsMapSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toBe("SEO.FLIGHTS_MAP.TITLE");
|
||||
expect(result.description).toBe("SEO.FLIGHTS_MAP.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to /{locale}/flights-map", () => {
|
||||
const result = buildFlightsMapSeo(stubT, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe("https://www.aeroflot.ru/en/flights-map");
|
||||
});
|
||||
|
||||
it("includes hreflang with 10 entries", () => {
|
||||
const result = buildFlightsMapSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og tags", () => {
|
||||
const result = buildFlightsMapSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.title).toBe(result.title);
|
||||
expect(result.og.type).toBe("website");
|
||||
expect(result.og.locale).toBe("ru");
|
||||
expect(result.og.siteName).toBe("Aeroflot");
|
||||
expect(result.og.url).toBe("https://www.aeroflot.ru/ru/flights-map");
|
||||
});
|
||||
|
||||
it("sets twitter card to summary", () => {
|
||||
const result = buildFlightsMapSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.twitter).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test assertion guards above
|
||||
expect(result.twitter!.card).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFlightsMapJsonLd
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFlightsMapJsonLd", () => {
|
||||
it("returns a WebPage schema", () => {
|
||||
const result = buildFlightsMapJsonLd("ru", CANONICAL);
|
||||
|
||||
expect(result["@type"]).toBe("WebPage");
|
||||
});
|
||||
|
||||
it("sets the correct URL with locale", () => {
|
||||
const result = buildFlightsMapJsonLd("en", CANONICAL);
|
||||
|
||||
expect(result.url).toBe("https://www.aeroflot.ru/en/flights-map");
|
||||
});
|
||||
|
||||
it("sets inLanguage to the locale", () => {
|
||||
const result = buildFlightsMapJsonLd("ja", CANONICAL);
|
||||
|
||||
expect(result.inLanguage).toBe("ja");
|
||||
});
|
||||
|
||||
it("includes isPartOf WebSite reference", () => {
|
||||
const result = buildFlightsMapJsonLd("ru", CANONICAL);
|
||||
|
||||
expect(result.isPartOf).toBeDefined();
|
||||
const site = result.isPartOf as { "@type": string; name: string; url: string };
|
||||
expect(site["@type"]).toBe("WebSite");
|
||||
expect(site.name).toBe("Aeroflot");
|
||||
expect(site.url).toBe(CANONICAL);
|
||||
});
|
||||
|
||||
it("sets name with site name prefix", () => {
|
||||
const result = buildFlightsMapJsonLd("ru", CANONICAL);
|
||||
|
||||
expect(result.name).toContain("Aeroflot");
|
||||
expect(result.name).toContain("Flight Map");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* SEO builder functions for Flights Map route page.
|
||||
*
|
||||
* Pure function -- all data arrives via parameters, no hooks or side effects.
|
||||
* Returns a SeoHeadProps object ready for <SeoHead>.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SeoHeadProps } from "@/ui/seo/SeoHead.js";
|
||||
import { buildHreflangSet } from "@/shared/seo/hreflang.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TFunction = (key: string, opts?: any) => string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OG_IMAGE = "https://www.aeroflot.ru/static/images/aeroflot-og-default.png";
|
||||
const SITE_NAME = "Aeroflot";
|
||||
const PATH_WITHOUT_LOCALE = "/flights-map";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SEO props for the Flights Map start page.
|
||||
*/
|
||||
export function buildFlightsMapSeo(
|
||||
t: TFunction,
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const title = t("SEO.FLIGHTS_MAP.TITLE");
|
||||
const description = t("SEO.FLIGHTS_MAP.DESCRIPTION");
|
||||
const canonical = `${canonicalOrigin}/${locale}${PATH_WITHOUT_LOCALE}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflang: buildHreflangSet({
|
||||
canonicalOrigin,
|
||||
pathWithoutLocale: PATH_WITHOUT_LOCALE,
|
||||
}),
|
||||
og: {
|
||||
title,
|
||||
description,
|
||||
url: canonical,
|
||||
image: OG_IMAGE,
|
||||
type: "website",
|
||||
locale,
|
||||
siteName: SITE_NAME,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Data model types for the Flights Map feature.
|
||||
*
|
||||
* Covers API request/response shapes, map marker/polyline props,
|
||||
* and filter state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API request types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parameters for GET destinations/1 search.
|
||||
*/
|
||||
export interface FlightsMapSearchParams {
|
||||
departure: string;
|
||||
arrival?: string | undefined;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
connections?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for GET days/{date}/200/{route}/flights-map/v1 calendar.
|
||||
*/
|
||||
export interface FlightsMapCalendarParams {
|
||||
date: string;
|
||||
departure: string;
|
||||
arrival?: string | undefined;
|
||||
connections: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single flight route from the destinations API.
|
||||
*/
|
||||
export interface IFlightRoute {
|
||||
route: string[];
|
||||
isDirect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET destinations/1.
|
||||
*/
|
||||
export interface IDestinationsResponse {
|
||||
data: {
|
||||
routes: IFlightRoute[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET days/.../flights-map/v1.
|
||||
* The `days` field is a string of "0" and "1" characters, one per day.
|
||||
*/
|
||||
export interface IFlightsMapDaysResponse {
|
||||
days: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map component prop types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Marker style variant for the map.
|
||||
*/
|
||||
export type MarkerStyle = "blue" | "blue-small" | "orange";
|
||||
|
||||
/**
|
||||
* A marker to render on the map.
|
||||
*/
|
||||
export interface IMapMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
style: MarkerStyle;
|
||||
label?: string | undefined;
|
||||
tooltipPermanent?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyline style variant for the map.
|
||||
*/
|
||||
export type PolylineStyle = "direct" | "connecting";
|
||||
|
||||
/**
|
||||
* A polyline to render on the map.
|
||||
*/
|
||||
export interface IMapPolyline {
|
||||
id: string;
|
||||
points: Array<{ lat: number; lng: number }>;
|
||||
style: PolylineStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup content to show on the map.
|
||||
*/
|
||||
export interface IMapPopup {
|
||||
lat: number;
|
||||
lng: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* State shape for the flights map filter.
|
||||
*/
|
||||
export interface IFlightsMapFilterState {
|
||||
departure?: string | undefined;
|
||||
arrival?: string | undefined;
|
||||
date?: string | undefined;
|
||||
connections: boolean;
|
||||
domestic: boolean;
|
||||
international: boolean;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import {
|
||||
searchFlights,
|
||||
getFlightDetails,
|
||||
getCalendarDays,
|
||||
} from "./api";
|
||||
import type {
|
||||
SearchFlightsParams,
|
||||
FlightDetailsParams,
|
||||
CalendarParams,
|
||||
} from "./api";
|
||||
import type { IBoardResponse, IDaysResponse } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an ApiClient with a mock fetch that captures the request URL
|
||||
* and returns a canned response.
|
||||
*/
|
||||
function createMockClient(responseBody: unknown, status = 200) {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.test.com",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
|
||||
return { client, mockFetch };
|
||||
}
|
||||
|
||||
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
|
||||
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
|
||||
return new URL(call[0]);
|
||||
}
|
||||
|
||||
/** Minimal valid IBoardResponse for testing */
|
||||
const BOARD_RESPONSE: IBoardResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: [],
|
||||
daysOfFlight: ["2025-01-15"],
|
||||
},
|
||||
};
|
||||
|
||||
const DAYS_RESPONSE: IDaysResponse = {
|
||||
days: "2025-01-15,2025-01-16,2025-01-17",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchFlights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchFlights", () => {
|
||||
it("sends dateFrom and dateTo as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const params: SearchFlightsParams = {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
};
|
||||
|
||||
await searchFlights(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/board");
|
||||
expect(url.searchParams.get("dateFrom")).toBe("20250115");
|
||||
expect(url.searchParams.get("dateTo")).toBe("20250116");
|
||||
});
|
||||
|
||||
it("includes flightNumber when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
flightNumber: "SU100",
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("flightNumber")).toBe("SU100");
|
||||
});
|
||||
|
||||
it("includes departure and arrival when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("departure")).toBe("SVO");
|
||||
expect(url.searchParams.get("arrival")).toBe("JFK");
|
||||
});
|
||||
|
||||
it("includes timeFrom and timeTo when provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
timeFrom: "0800",
|
||||
timeTo: "2000",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.get("timeFrom")).toBe("0800");
|
||||
expect(url.searchParams.get("timeTo")).toBe("2000");
|
||||
});
|
||||
|
||||
it("omits optional params when not provided", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.searchParams.has("flightNumber")).toBe(false);
|
||||
expect(url.searchParams.has("departure")).toBe(false);
|
||||
expect(url.searchParams.has("arrival")).toBe(false);
|
||||
expect(url.searchParams.has("timeFrom")).toBe(false);
|
||||
expect(url.searchParams.has("timeTo")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the deserialized IBoardResponse", async () => {
|
||||
const { client } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const result = await searchFlights(client, {
|
||||
dateFrom: "20250115",
|
||||
dateTo: "20250116",
|
||||
});
|
||||
|
||||
expect(result).toEqual(BOARD_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
searchFlights(client, { dateFrom: "20250115", dateTo: "20250116" }),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getFlightDetails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getFlightDetails", () => {
|
||||
it("sends flights and dates as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const params: FlightDetailsParams = {
|
||||
flights: "SU100",
|
||||
dates: "2025-01-15",
|
||||
};
|
||||
|
||||
await getFlightDetails(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/onlineboard/details");
|
||||
expect(url.searchParams.get("flights")).toBe("SU100");
|
||||
expect(url.searchParams.get("dates")).toBe("2025-01-15");
|
||||
});
|
||||
|
||||
it("returns the deserialized IBoardResponse", async () => {
|
||||
const { client } = createMockClient(BOARD_RESPONSE);
|
||||
|
||||
const result = await getFlightDetails(client, {
|
||||
flights: "SU100",
|
||||
dates: "2025-01-15",
|
||||
});
|
||||
|
||||
expect(result).toEqual(BOARD_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
getFlightDetails(client, { flights: "SU999", dates: "2025-01-15" }),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCalendarDays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCalendarDays", () => {
|
||||
it("builds correct path for flight type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const params: CalendarParams = {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
};
|
||||
|
||||
await getCalendarDays(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/flight/SU100/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for departure type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "departure",
|
||||
departure: "SVO",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/departure/SVO/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for arrival type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "arrival",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/31/arrival/JFK/board/");
|
||||
});
|
||||
|
||||
it("builds correct path for route type", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "route",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe(
|
||||
"/v1/days/2025-01-15/31/route/SVO-JFK/board/",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses comma-separated days string into array", async () => {
|
||||
const { client } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const result = await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
});
|
||||
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
const result = await getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
getCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
searchType: "flight",
|
||||
flightNumber: "SU100",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Online Board API functions.
|
||||
*
|
||||
* Pure functions — each takes an `ApiClient` as a parameter (dependency
|
||||
* injection). No React hooks, no context, no side effects.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@/shared/api/client.js";
|
||||
import type {
|
||||
IBoardResponse,
|
||||
IDaysResponse,
|
||||
FlightRequestType,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchFlightsParams {
|
||||
/** Flight number search: e.g. "SU100" */
|
||||
flightNumber?: string;
|
||||
/** Date range start — yyyyMMdd format (used by the API as `dateFrom`) */
|
||||
dateFrom: string;
|
||||
/** Date range end — yyyyMMdd format (used by the API as `dateTo`) */
|
||||
dateTo: string;
|
||||
/** Departure airport IATA code */
|
||||
departure?: string;
|
||||
/** Arrival airport IATA code */
|
||||
arrival?: string;
|
||||
/** Time range start — HHmm format */
|
||||
timeFrom?: string;
|
||||
/** Time range end — HHmm format */
|
||||
timeTo?: string;
|
||||
}
|
||||
|
||||
export interface FlightDetailsParams {
|
||||
/** Carrier code + flight number, e.g. "SU100" */
|
||||
flights: string;
|
||||
/** Date in yyyy-MM-dd format */
|
||||
dates: string;
|
||||
}
|
||||
|
||||
export interface CalendarParams {
|
||||
/** Base date — yyyy-MM-dd format */
|
||||
date: string;
|
||||
/** Search type discriminator */
|
||||
searchType: FlightRequestType;
|
||||
/** Flight number (for "flight" type), e.g. "SU100" */
|
||||
flightNumber?: string;
|
||||
/** Departure airport IATA code (for "departure" or "route" type) */
|
||||
departure?: string;
|
||||
/** Arrival airport IATA code (for "arrival" or "route" type) */
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search flights on the online board.
|
||||
* Maps to: `GET /board?flightNumber=...&dateFrom=...&dateTo=...&...`
|
||||
*/
|
||||
export async function searchFlights(
|
||||
client: ApiClient,
|
||||
params: SearchFlightsParams,
|
||||
): Promise<IBoardResponse> {
|
||||
const query: Record<string, string> = {
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
};
|
||||
|
||||
if (params.flightNumber) query["flightNumber"] = params.flightNumber;
|
||||
if (params.departure) query["departure"] = params.departure;
|
||||
if (params.arrival) query["arrival"] = params.arrival;
|
||||
if (params.timeFrom) query["timeFrom"] = params.timeFrom;
|
||||
if (params.timeTo) query["timeTo"] = params.timeTo;
|
||||
|
||||
return client.get<IBoardResponse>("board", query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flight details.
|
||||
* Maps to: `GET /onlineboard/details?flights=SU100&dates=2025-01-15`
|
||||
*/
|
||||
export async function getFlightDetails(
|
||||
client: ApiClient,
|
||||
params: FlightDetailsParams,
|
||||
): Promise<IBoardResponse> {
|
||||
return client.get<IBoardResponse>("onlineboard/details", {
|
||||
flights: params.flights,
|
||||
dates: params.dates,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available calendar days for a given search context.
|
||||
* Maps to: `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`
|
||||
*
|
||||
* The API returns `{ days: "2025-01-01,2025-01-02,..." }` — a single
|
||||
* comma-separated string. This function splits it into `string[]`.
|
||||
*/
|
||||
export async function getCalendarDays(
|
||||
client: ApiClient,
|
||||
params: CalendarParams,
|
||||
): Promise<string[]> {
|
||||
const searchSegment = buildCalendarSearchSegment(params);
|
||||
const path = `v1/days/${params.date}/31/${searchSegment}/board/`;
|
||||
|
||||
const response = await client.get<IDaysResponse>(path);
|
||||
return parseCalendarDays(response.days);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildCalendarSearchSegment(params: CalendarParams): string {
|
||||
switch (params.searchType) {
|
||||
case "flight":
|
||||
return `flight/${params.flightNumber ?? ""}`;
|
||||
case "departure":
|
||||
return `departure/${params.departure ?? ""}`;
|
||||
case "arrival":
|
||||
return `arrival/${params.arrival ?? ""}`;
|
||||
case "route": {
|
||||
const dep = params.departure ?? "";
|
||||
const arr = params.arrival ?? "";
|
||||
return `route/${dep}-${arr}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCalendarDays(days: string): string[] {
|
||||
if (!days) return [];
|
||||
return days.split(",").map((d) => d.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests for OnlineBoardDetailsPage component.
|
||||
*
|
||||
* Verifies rendering in loading, error, not-found, and success states.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { OnlineBoardDetailsPage } from "./OnlineBoardDetailsPage.js";
|
||||
import type { IParsedFlightId, IDirectFlight } from "../types.js";
|
||||
|
||||
const mockFlightId: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
const mockFlight: IDirectFlight = {
|
||||
id: "SU100-20250115",
|
||||
flightId: { carrier: "SU", flightNumber: "100", suffix: "", date: "20250115" },
|
||||
routeType: "Direct",
|
||||
status: "Scheduled",
|
||||
flyingTime: "10:30",
|
||||
operatingBy: {},
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10:30",
|
||||
dayChange: 0,
|
||||
updated: "2025-01-15T00:00:00Z",
|
||||
operatingBy: {},
|
||||
equipment: { name: "Boeing 777-300ER", code: "77W" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "open",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "10:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "07:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "John F. Kennedy",
|
||||
airportCode: "JFK",
|
||||
city: "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "14:30",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "19:30",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mutable state for test control
|
||||
let mockState = {
|
||||
flight: mockFlight as IDirectFlight | null,
|
||||
loading: false,
|
||||
error: null as Error | null,
|
||||
};
|
||||
|
||||
vi.mock("../hooks/useFlightDetails.js", () => ({
|
||||
useFlightDetails: () => mockState,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useLiveFlightDetails.js", () => ({
|
||||
useLiveFlightDetails: (_id: unknown, initialFlight: unknown) => ({
|
||||
flight: initialFlight,
|
||||
connectionStatus: "idle" as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "ru" },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardDetailsPage", () => {
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
flight: mockFlight,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
it("renders flight details", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flight-details")).toBeTruthy();
|
||||
// "SU 100" appears in both the header and FlightCard
|
||||
expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders loading skeleton", () => {
|
||||
mockState = { flight: null, loading: true, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.queryByTestId("flight-details")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error state", () => {
|
||||
mockState = { flight: null, loading: false, error: new Error("fail") };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders not-found state", () => {
|
||||
mockState = { flight: null, loading: false, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders flight legs", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flight-legs")).toBeTruthy();
|
||||
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays departure and arrival stations", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
// SVO and JFK appear in both FlightCard and FlightLegs sections
|
||||
expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("displays aircraft info", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays flying time", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flying-time")).toBeTruthy();
|
||||
expect(screen.getByText("Total flying time: 10:30")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Online Board flight details page component.
|
||||
*
|
||||
* Receives parsed flight ID, fetches flight details via useFlightDetails,
|
||||
* wires live updates via useLiveFlightDetails, renders detailed flight info.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { FlightCard } from "@/ui/flights/FlightCard.js";
|
||||
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
|
||||
import { SeoHead } from "@/ui/seo/SeoHead.js";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useFlightDetails } from "../hooks/useFlightDetails.js";
|
||||
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
|
||||
import { buildFlightDetailsSeo } from "../seo.js";
|
||||
import { buildFlightJsonLd } from "../json-ld.js";
|
||||
import type { IParsedFlightId, IFlightLeg } from "../types.js";
|
||||
|
||||
export interface OnlineBoardDetailsPageProps {
|
||||
/** Parsed flight identifier from the URL */
|
||||
flightId: IParsedFlightId;
|
||||
/** Current locale for SEO */
|
||||
locale: string;
|
||||
/** Canonical origin for SEO URLs */
|
||||
canonicalOrigin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all legs of a flight with departure/arrival station details.
|
||||
*/
|
||||
function FlightLegs({ legs }: { legs: IFlightLeg[] }): JSX.Element {
|
||||
return (
|
||||
<div className="flight-details__legs" data-testid="flight-legs">
|
||||
{legs.map((leg) => (
|
||||
<div key={leg.index} className="flight-details__leg" data-testid={`flight-leg-${leg.index}`}>
|
||||
<div className="flight-details__leg-header">
|
||||
<span className="flight-details__leg-index">Leg {leg.index + 1}</span>
|
||||
<span className="flight-details__leg-status">{leg.status}</span>
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-stations">
|
||||
<div className="flight-details__leg-departure">
|
||||
<span className="flight-details__station-code">
|
||||
{leg.departure.scheduled.airportCode}
|
||||
</span>
|
||||
<span className="flight-details__station-name">
|
||||
{leg.departure.scheduled.airport}
|
||||
</span>
|
||||
<span className="flight-details__station-city">
|
||||
{leg.departure.scheduled.city}
|
||||
</span>
|
||||
{leg.departure.terminal && (
|
||||
<span className="flight-details__terminal">
|
||||
Terminal {leg.departure.terminal}
|
||||
</span>
|
||||
)}
|
||||
{leg.departure.gate && (
|
||||
<span className="flight-details__gate">Gate {leg.departure.gate}</span>
|
||||
)}
|
||||
<span className="flight-details__time">
|
||||
{leg.departure.times.scheduledDeparture.local}
|
||||
</span>
|
||||
{leg.departure.times.actualBlockOff && (
|
||||
<span className="flight-details__time-actual">
|
||||
Actual: {leg.departure.times.actualBlockOff.local}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-duration">
|
||||
<span>{leg.flyingTime}</span>
|
||||
</div>
|
||||
|
||||
<div className="flight-details__leg-arrival">
|
||||
<span className="flight-details__station-code">
|
||||
{leg.arrival.scheduled.airportCode}
|
||||
</span>
|
||||
<span className="flight-details__station-name">
|
||||
{leg.arrival.scheduled.airport}
|
||||
</span>
|
||||
<span className="flight-details__station-city">
|
||||
{leg.arrival.scheduled.city}
|
||||
</span>
|
||||
{leg.arrival.terminal && (
|
||||
<span className="flight-details__terminal">
|
||||
Terminal {leg.arrival.terminal}
|
||||
</span>
|
||||
)}
|
||||
{leg.arrival.bagBelt && (
|
||||
<span className="flight-details__bag-belt">
|
||||
Baggage belt {leg.arrival.bagBelt}
|
||||
</span>
|
||||
)}
|
||||
<span className="flight-details__time">
|
||||
{leg.arrival.times.scheduledArrival.local}
|
||||
</span>
|
||||
{leg.arrival.times.actualBlockOn && (
|
||||
<span className="flight-details__time-actual">
|
||||
Actual: {leg.arrival.times.actualBlockOn.local}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leg.equipment.name && (
|
||||
<div className="flight-details__aircraft">
|
||||
Aircraft: {leg.equipment.name}
|
||||
{leg.equipment.code ? ` (${leg.equipment.code})` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract legs from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getLegs(flight: { routeType: string; leg?: IFlightLeg; legs?: IFlightLeg[] }): IFlightLeg[] {
|
||||
if (flight.routeType === "Direct" && "leg" in flight && flight.leg) {
|
||||
return [flight.leg];
|
||||
}
|
||||
if ("legs" in flight && flight.legs) {
|
||||
return flight.legs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flight details page. Displays comprehensive flight information
|
||||
* with live updates via SignalR.
|
||||
*/
|
||||
export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
flightId,
|
||||
locale,
|
||||
canonicalOrigin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Fetch flight details
|
||||
const detailsParams = {
|
||||
flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
|
||||
dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}`,
|
||||
};
|
||||
const { flight, loading, error } = useFlightDetails(detailsParams);
|
||||
|
||||
// Live updates via SignalR
|
||||
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
|
||||
flightId,
|
||||
flight,
|
||||
);
|
||||
|
||||
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
|
||||
|
||||
if (loading) {
|
||||
return <FlightListSkeleton count={1} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flight-details flight-details--error" data-testid="flight-details-error">
|
||||
<p>Failed to load flight details. Please try again.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayFlight) {
|
||||
return (
|
||||
<div className="flight-details flight-details--not-found" data-testid="flight-details-not-found">
|
||||
<p>Flight not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const legs = getLegs(displayFlight);
|
||||
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
|
||||
|
||||
const seoProps = buildFlightDetailsSeo(t, displayFlight, locale, canonicalOrigin);
|
||||
const jsonLd = buildFlightJsonLd(displayFlight);
|
||||
|
||||
return (
|
||||
<div className="flight-details" data-testid="flight-details">
|
||||
<SeoHead {...seoProps} />
|
||||
<JsonLdRenderer data={jsonLd} />
|
||||
{/* Connection status */}
|
||||
<div className="flight-details__status" data-testid="connection-status">
|
||||
{connectionStatus === "live" && (
|
||||
<span className="connection-badge connection-badge--live">Live</span>
|
||||
)}
|
||||
{connectionStatus === "reconnecting" && (
|
||||
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
|
||||
)}
|
||||
{connectionStatus === "offline" && (
|
||||
<span className="connection-badge connection-badge--offline">Offline</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight header */}
|
||||
<div className="flight-details__header">
|
||||
<h1 className="flight-details__flight-number">{flightNumber}</h1>
|
||||
<span className="flight-details__overall-status">{displayFlight.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary card */}
|
||||
<FlightCard flight={displayFlight} />
|
||||
|
||||
{/* Operating carrier */}
|
||||
{displayFlight.operatingBy.carrier && (
|
||||
<div className="flight-details__operating" data-testid="operating-carrier">
|
||||
Operated by: {displayFlight.operatingBy.carrier}
|
||||
{displayFlight.operatingBy.flightNumber
|
||||
? ` ${displayFlight.operatingBy.flightNumber}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed leg information */}
|
||||
<FlightLegs legs={legs} />
|
||||
|
||||
{/* Flying time */}
|
||||
<div className="flight-details__flying-time" data-testid="flying-time">
|
||||
Total flying time: {displayFlight.flyingTime}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tests for OnlineBoardSearchPage component.
|
||||
*
|
||||
* Verifies rendering with mock providers and navigation wiring.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { OnlineBoardSearchPage } from "./OnlineBoardSearchPage.js";
|
||||
import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js";
|
||||
|
||||
// Mock all hooks and router
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useOnlineBoard.js", () => ({
|
||||
useOnlineBoard: () => ({
|
||||
flights: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
|
||||
useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({
|
||||
flights: initialFlights,
|
||||
connectionStatus: "idle" as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCalendarDays.js", () => ({
|
||||
useCalendarDays: () => ({
|
||||
days: [],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardSearchPage", () => {
|
||||
const departureParsedParams: OnlineBoardSearchPageProps["params"] = {
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders search page container", () => {
|
||||
render(<OnlineBoardSearchPage params={departureParsedParams} />);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty flight list when no results", () => {
|
||||
render(<OnlineBoardSearchPage params={departureParsedParams} />);
|
||||
expect(screen.getByText("No flights found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for flight search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for route search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders for arrival search type", () => {
|
||||
render(
|
||||
<OnlineBoardSearchPage
|
||||
params={{
|
||||
type: "arrival",
|
||||
station: "JFK",
|
||||
date: "20250115",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("online-board-search")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Shared search results page for all 4 online board search types.
|
||||
*
|
||||
* Each route page (flight, departure, arrival, route) composes this
|
||||
* component with its parsed params. This component handles:
|
||||
* - Converting parsed URL params to API search params
|
||||
* - Wiring useOnlineBoard for data fetching
|
||||
* - Wiring useLiveBoardSearch for live SignalR updates
|
||||
* - Rendering FlightList with results
|
||||
* - Navigation to flight details on card click
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { FlightList } from "@/ui/flights/FlightList.js";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
||||
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import { buildFlightListJsonLd } from "../json-ld.js";
|
||||
import type { OnlineBoardParams } from "../url.js";
|
||||
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
||||
import type { FlightRequestType, ISimpleFlight } from "../types.js";
|
||||
|
||||
export interface OnlineBoardSearchPageProps {
|
||||
/** Parsed and validated URL params from the route */
|
||||
params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed online board URL params into API search params.
|
||||
* The API expects dateFrom/dateTo (same day for single-date searches).
|
||||
*/
|
||||
function toSearchParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): SearchFlightsParams {
|
||||
const base: SearchFlightsParams = {
|
||||
dateFrom: params.date,
|
||||
dateTo: params.date,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
base.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
base.departure = params.departure;
|
||||
base.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
if ("timeFrom" in params && params.timeFrom) {
|
||||
base.timeFrom = params.timeFrom;
|
||||
}
|
||||
if ("timeTo" in params && params.timeTo) {
|
||||
base.timeTo = params.timeTo;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed params into calendar API params.
|
||||
*/
|
||||
function toCalendarParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): CalendarParams {
|
||||
const base: CalendarParams = {
|
||||
date: params.date,
|
||||
searchType: params.type as FlightRequestType,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "flight":
|
||||
base.flightNumber = `${params.carrier}${params.flightNumber}`;
|
||||
break;
|
||||
case "departure":
|
||||
base.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
base.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
base.departure = params.departure;
|
||||
base.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract live board channel params from parsed URL params.
|
||||
*/
|
||||
function toLiveBoardParams(
|
||||
params: OnlineBoardSearchPageProps["params"],
|
||||
): { date: string; departure?: string; arrival?: string } {
|
||||
const result: { date: string; departure?: string; arrival?: string } = {
|
||||
date: params.date,
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case "departure":
|
||||
result.departure = params.station;
|
||||
break;
|
||||
case "arrival":
|
||||
result.arrival = params.station;
|
||||
break;
|
||||
case "route":
|
||||
result.departure = params.departure;
|
||||
result.arrival = params.arrival;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared search results page composed by all 4 search route pages.
|
||||
*/
|
||||
export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
params,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
// Data fetching
|
||||
const searchParams = toSearchParams(params);
|
||||
const { flights, loading, error, refresh } = useOnlineBoard(searchParams);
|
||||
|
||||
// Live updates via SignalR
|
||||
const liveBoardParams = toLiveBoardParams(params);
|
||||
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
|
||||
liveBoardParams,
|
||||
flights,
|
||||
);
|
||||
|
||||
// Calendar days
|
||||
const calendarParams = toCalendarParams(params);
|
||||
const { days: calendarDays } = useCalendarDays(calendarParams);
|
||||
|
||||
// Navigation: click a flight to go to details
|
||||
const handleFlightClick = useCallback(
|
||||
(flight: ISimpleFlight) => {
|
||||
const detailsParams: OnlineBoardParams = flight.flightId.suffix
|
||||
? {
|
||||
type: "details",
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
suffix: flight.flightId.suffix,
|
||||
date: flight.flightId.date,
|
||||
}
|
||||
: {
|
||||
type: "details",
|
||||
carrier: flight.flightId.carrier,
|
||||
flightNumber: flight.flightId.flightNumber,
|
||||
date: flight.flightId.date,
|
||||
};
|
||||
const detailsUrl = buildOnlineBoardUrl(detailsParams);
|
||||
void navigate(`/${lang}/${detailsUrl}`);
|
||||
},
|
||||
[navigate, lang],
|
||||
);
|
||||
|
||||
// Navigation: change date via calendar
|
||||
const handleDateChange = useCallback(
|
||||
(newDate: string) => {
|
||||
const newParams = { ...params, date: newDate };
|
||||
const url = buildOnlineBoardUrl(newParams);
|
||||
void navigate(`/${lang}/${url}`);
|
||||
},
|
||||
[navigate, lang, params],
|
||||
);
|
||||
|
||||
// Use live flights when connected, otherwise fetched flights
|
||||
const displayFlights = connectionStatus === "live" ? liveFlights : flights;
|
||||
|
||||
// JSON-LD for search results (rendered once we have flights)
|
||||
const searchDescription = `Online board ${params.type} search results`;
|
||||
const jsonLd = displayFlights.length > 0
|
||||
? buildFlightListJsonLd(displayFlights, searchDescription)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="online-board-search" data-testid="online-board-search">
|
||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||
{/* Connection status indicator */}
|
||||
<div className="online-board-search__status" data-testid="connection-status">
|
||||
{connectionStatus === "live" && (
|
||||
<span className="connection-badge connection-badge--live">Live</span>
|
||||
)}
|
||||
{connectionStatus === "reconnecting" && (
|
||||
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
|
||||
)}
|
||||
{connectionStatus === "offline" && (
|
||||
<span className="connection-badge connection-badge--offline">Offline</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar strip (simple date list for now) */}
|
||||
{calendarDays.length > 0 && (
|
||||
<div className="online-board-search__calendar" data-testid="calendar-strip">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`calendar-day${day === params.date ? " calendar-day--active" : ""}`}
|
||||
onClick={() => handleDateChange(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="online-board-search__error" data-testid="search-error">
|
||||
<p>Failed to load flights. Please try again.</p>
|
||||
<button type="button" onClick={refresh}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flight list */}
|
||||
<FlightList flights={displayFlights} loading={loading} />
|
||||
|
||||
{/* Flight click overlay — we make the list clickable */}
|
||||
{!loading && displayFlights.length > 0 && (
|
||||
<div className="online-board-search__actions" data-testid="flight-actions">
|
||||
{displayFlights.map((flight) => (
|
||||
<button
|
||||
key={flight.id}
|
||||
type="button"
|
||||
className="flight-detail-link"
|
||||
data-testid={`flight-link-${flight.id}`}
|
||||
onClick={() => handleFlightClick(flight)}
|
||||
>
|
||||
View details for {flight.flightId.carrier} {flight.flightId.flightNumber}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Tests for OnlineBoardStartPage component.
|
||||
*
|
||||
* Verifies form rendering with different search modes and submit behavior.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { OnlineBoardStartPage } from "./OnlineBoardStartPage.js";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
useParams: () => ({ lang: "ru" }),
|
||||
}));
|
||||
|
||||
describe("OnlineBoardStartPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders start page with search form", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
expect(screen.getByTestId("online-board-start")).toBeTruthy();
|
||||
expect(screen.getByTestId("search-form")).toBeTruthy();
|
||||
expect(screen.getByText("Online Board")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders search type radio buttons", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
expect(screen.getByLabelText("Flight")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Departure")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Arrival")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Route")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows flight number input by default (flight mode)", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to departure mode and shows departure input", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByLabelText("Departure"));
|
||||
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to route mode and shows both airport inputs", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByLabelText("Route"));
|
||||
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits flight search and navigates", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
const flightInput = screen.getByTestId("flight-number-input") as HTMLInputElement;
|
||||
fireEvent.change(flightInput, { target: { value: "SU100" } });
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
|
||||
expect(navigatedUrl).toContain("/ru/onlineboard/flight/SU");
|
||||
});
|
||||
|
||||
it("does not submit when flight number is empty", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits departure search and navigates", () => {
|
||||
render(<OnlineBoardStartPage />);
|
||||
fireEvent.click(screen.getByLabelText("Departure"));
|
||||
const input = screen.getByTestId("departure-airport-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "SVO" } });
|
||||
fireEvent.submit(screen.getByTestId("search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
|
||||
expect(navigatedUrl).toContain("/ru/onlineboard/departure/SVO");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Online Board start page — search form with tabs for different search modes.
|
||||
*
|
||||
* No API calls on load. Pure form that navigates to the appropriate
|
||||
* search route on submit.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type FC, useState, useCallback, type FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import type { FlightRequestType } from "../types.js";
|
||||
|
||||
/**
|
||||
* Format today's date as yyyyMMdd for URL params.
|
||||
*/
|
||||
function todayAsYyyymmdd(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear().toString();
|
||||
const m = (now.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = now.getDate().toString().padStart(2, "0");
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date input value (yyyy-MM-dd) to yyyyMMdd format.
|
||||
*/
|
||||
function dateInputToYyyymmdd(value: string): string {
|
||||
return value.replace(/-/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert yyyyMMdd to yyyy-MM-dd for date input value.
|
||||
*/
|
||||
function yyyymmddToDateInput(value: string): string {
|
||||
if (value.length !== 8) return "";
|
||||
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
export const OnlineBoardStartPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
const [searchType, setSearchType] = useState<FlightRequestType>("flight");
|
||||
const [flightNumber, setFlightNumber] = useState("");
|
||||
const [departureAirport, setDepartureAirport] = useState("");
|
||||
const [arrivalAirport, setArrivalAirport] = useState("");
|
||||
const [date, setDate] = useState(yyyymmddToDateInput(todayAsYyyymmdd()));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dateParam = dateInputToYyyymmdd(date);
|
||||
if (dateParam.length !== 8) return;
|
||||
|
||||
let url: string;
|
||||
|
||||
switch (searchType) {
|
||||
case "flight": {
|
||||
if (!flightNumber.trim()) return;
|
||||
// Extract carrier (first 2 chars) and number (rest)
|
||||
const cleaned = flightNumber.trim().replace(/\s+/g, "");
|
||||
const carrier = cleaned.slice(0, 2).toUpperCase();
|
||||
const num = cleaned.slice(2);
|
||||
if (!carrier || !num) return;
|
||||
url = buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier,
|
||||
flightNumber: num,
|
||||
date: dateParam,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "departure": {
|
||||
if (!departureAirport.trim()) return;
|
||||
url = buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: departureAirport.trim().toUpperCase(),
|
||||
date: dateParam,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "arrival": {
|
||||
if (!arrivalAirport.trim()) return;
|
||||
url = buildOnlineBoardUrl({
|
||||
type: "arrival",
|
||||
station: arrivalAirport.trim().toUpperCase(),
|
||||
date: dateParam,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "route": {
|
||||
if (!departureAirport.trim() || !arrivalAirport.trim()) return;
|
||||
url = buildOnlineBoardUrl({
|
||||
type: "route",
|
||||
departure: departureAirport.trim().toUpperCase(),
|
||||
arrival: arrivalAirport.trim().toUpperCase(),
|
||||
date: dateParam,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void navigate(`/${lang}/${url}`);
|
||||
},
|
||||
[searchType, flightNumber, departureAirport, arrivalAirport, date, navigate, lang],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="online-board-start" data-testid="online-board-start">
|
||||
<h1 className="online-board-start__title">Online Board</h1>
|
||||
|
||||
<form
|
||||
className="online-board-start__form"
|
||||
data-testid="search-form"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Search mode tabs */}
|
||||
<fieldset className="online-board-start__tabs">
|
||||
<legend>Search type</legend>
|
||||
{(["flight", "departure", "arrival", "route"] as const).map((type) => (
|
||||
<label key={type} className="online-board-start__tab">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchType"
|
||||
value={type}
|
||||
checked={searchType === type}
|
||||
onChange={() => setSearchType(type)}
|
||||
/>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{/* Flight number input (flight mode) */}
|
||||
{searchType === "flight" && (
|
||||
<div className="online-board-start__field">
|
||||
<label htmlFor="flight-number">Flight number</label>
|
||||
<input
|
||||
id="flight-number"
|
||||
type="text"
|
||||
placeholder="e.g. SU100"
|
||||
value={flightNumber}
|
||||
onChange={(e) => setFlightNumber(e.target.value)}
|
||||
data-testid="flight-number-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Departure airport (departure, route modes) */}
|
||||
{(searchType === "departure" || searchType === "route") && (
|
||||
<div className="online-board-start__field">
|
||||
<label htmlFor="departure-airport">Departure airport</label>
|
||||
<input
|
||||
id="departure-airport"
|
||||
type="text"
|
||||
placeholder="e.g. SVO"
|
||||
maxLength={3}
|
||||
value={departureAirport}
|
||||
onChange={(e) => setDepartureAirport(e.target.value)}
|
||||
data-testid="departure-airport-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrival airport (arrival, route modes) */}
|
||||
{(searchType === "arrival" || searchType === "route") && (
|
||||
<div className="online-board-start__field">
|
||||
<label htmlFor="arrival-airport">Arrival airport</label>
|
||||
<input
|
||||
id="arrival-airport"
|
||||
type="text"
|
||||
placeholder="e.g. JFK"
|
||||
maxLength={3}
|
||||
value={arrivalAirport}
|
||||
onChange={(e) => setArrivalAirport(e.target.value)}
|
||||
data-testid="arrival-airport-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date input */}
|
||||
<div className="online-board-start__field">
|
||||
<label htmlFor="search-date">Date</label>
|
||||
<input
|
||||
id="search-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
data-testid="date-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
className="online-board-start__submit"
|
||||
data-testid="search-submit"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* React hook for calendar day availability.
|
||||
*
|
||||
* Calls `getCalendarDays` on param change, manages loading/days state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getCalendarDays } from "../api.js";
|
||||
import type { CalendarParams } from "../api.js";
|
||||
|
||||
export interface UseCalendarDaysResult {
|
||||
days: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the calendar strip. Fetches available flight days for the
|
||||
* given search context.
|
||||
*/
|
||||
export function useCalendarDays(params: CalendarParams): UseCalendarDaysResult {
|
||||
const client = useApiClient();
|
||||
const [days, setDays] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
getCalendarDays(client, paramsRef.current)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDays(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDays([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params.date,
|
||||
params.searchType,
|
||||
params.flightNumber,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
]);
|
||||
|
||||
return { days, loading };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* React hook for the flight details page.
|
||||
*
|
||||
* Calls `getFlightDetails` on param change, manages loading/error/data state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getFlightDetails } from "../api.js";
|
||||
import type { FlightDetailsParams } from "../api.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseFlightDetailsResult {
|
||||
flight: ISimpleFlight | null;
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the flight details page. Fetches a single flight's details
|
||||
* based on flight ID params.
|
||||
*/
|
||||
export function useFlightDetails(
|
||||
params: FlightDetailsParams,
|
||||
): UseFlightDetailsResult {
|
||||
const client = useApiClient();
|
||||
const [flight, setFlight] = useState<ISimpleFlight | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getFlightDetails(client, paramsRef.current)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
// Details endpoint returns a board response; take the first route
|
||||
const firstFlight = response.data.routes[0] ?? null;
|
||||
setFlight(firstFlight);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, params.flights, params.dates]);
|
||||
|
||||
return { flight, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
_resetSharedConnections,
|
||||
getSharedConnection,
|
||||
} from "@/shared/signalr/connection.js";
|
||||
import { buildBoardChannelKey, useLiveBoardSearch } from "./useLiveBoardSearch.js";
|
||||
import type { LiveBoardSearchParams } from "./useLiveBoardSearch.js";
|
||||
import type { ISimpleFlight, IFlightId, IFlightLeg } from "../types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env mock — getEnv() must return SIGNALR_HUB_URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("@/env/index.js", () => ({
|
||||
getEnv: () => ({
|
||||
SIGNALR_HUB_URL: "https://hub.test/tracker",
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock hub helpers (same pattern as useLiveFlights.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockHub() {
|
||||
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
|
||||
const list = handlers[method] ?? [];
|
||||
handlers[method] = list;
|
||||
list.push(handler);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
onclose: vi.fn(),
|
||||
onreconnecting: vi.fn(),
|
||||
onreconnected: vi.fn(),
|
||||
_simulateMessage(channel: string, ...args: unknown[]) {
|
||||
for (const h of handlers[channel] ?? []) h(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installMockHub(hubUrl: string) {
|
||||
const hub = createMockHub();
|
||||
const conn = getSharedConnection({ hubUrl });
|
||||
conn._buildConnection = vi.fn().mockResolvedValue(hub);
|
||||
return { hub, conn };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFlight(id: string): ISimpleFlight {
|
||||
return {
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "",
|
||||
date: "2025-01-15",
|
||||
} satisfies IFlightId,
|
||||
flyingTime: "3h",
|
||||
operatingBy: {},
|
||||
id,
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "LED",
|
||||
airportCode: "LED",
|
||||
city: "St Petersburg",
|
||||
cityCode: "LED",
|
||||
countryCode: "RU",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "",
|
||||
localTime: "",
|
||||
tzOffset: 3,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "SVO",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "Open",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "",
|
||||
localTime: "",
|
||||
tzOffset: 3,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
dayChange: 0,
|
||||
equipment: {},
|
||||
flags: {
|
||||
checkinAvailable: true,
|
||||
returnToAirport: false,
|
||||
routeChanged: false,
|
||||
},
|
||||
flyingTime: "3h",
|
||||
index: 0,
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
updated: "2025-01-15T00:00:00Z",
|
||||
} satisfies IFlightLeg,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildBoardChannelKey", () => {
|
||||
it("builds key with date only", () => {
|
||||
expect(buildBoardChannelKey({ date: "20250115" })).toBe(
|
||||
"board:20250115::",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds key with date + departure", () => {
|
||||
expect(
|
||||
buildBoardChannelKey({ date: "20250115", departure: "SVO" }),
|
||||
).toBe("board:20250115:SVO:");
|
||||
});
|
||||
|
||||
it("builds key with date + departure + arrival", () => {
|
||||
expect(
|
||||
buildBoardChannelKey({
|
||||
date: "20250115",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
}),
|
||||
).toBe("board:20250115:SVO:LED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLiveBoardSearch", () => {
|
||||
const HUB_URL = "https://hub.test/tracker";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
_resetSharedConnections();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns initial flights on mount", async () => {
|
||||
installMockHub(HUB_URL);
|
||||
const initial = [makeFlight("f1")];
|
||||
const params: LiveBoardSearchParams = { date: "20250115" };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveBoardSearch(params, initial),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.flights).toEqual(initial);
|
||||
});
|
||||
|
||||
it("subscribes to the correct channel", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const params: LiveBoardSearchParams = {
|
||||
date: "20250115",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
};
|
||||
|
||||
renderHook(() => useLiveBoardSearch(params, []));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(hub.on).toHaveBeenCalledWith(
|
||||
"board:20250115:SVO:LED",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates flights when a SignalR message arrives", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const params: LiveBoardSearchParams = { date: "20250115" };
|
||||
const initial = [makeFlight("f1")];
|
||||
const channel = "board:20250115::";
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveBoardSearch(params, initial),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const updated = [makeFlight("f2")];
|
||||
act(() => {
|
||||
hub._simulateMessage(channel, updated);
|
||||
});
|
||||
|
||||
expect(result.current.flights).toEqual(updated);
|
||||
});
|
||||
|
||||
it("returns idle connectionStatus during SSR", () => {
|
||||
const origWindow = globalThis.window;
|
||||
// @ts-expect-error -- intentionally deleting window for SSR simulation
|
||||
delete globalThis.window;
|
||||
|
||||
try {
|
||||
_resetSharedConnections();
|
||||
installMockHub(HUB_URL);
|
||||
|
||||
// Without window, renderHook won't work (no DOM),
|
||||
// but we can verify the SSR guard in useLiveFlights
|
||||
// by checking the underlying shared connection isn't subscribed
|
||||
const conn = getSharedConnection({ hubUrl: HUB_URL });
|
||||
const subscribeSpy = vi.spyOn(conn, "subscribe");
|
||||
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
subscribeSpy.mockRestore();
|
||||
} finally {
|
||||
globalThis.window = origWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Live SignalR hook for Online Board search pages.
|
||||
*
|
||||
* Composes the generic `useLiveFlights` (1E) with TrackerHub's
|
||||
* `SubscribeDate` channel. When the server pushes a `RefreshDate`
|
||||
* message, `useLiveFlights` replaces the flight list atomically.
|
||||
*
|
||||
* Client-only — SSR is handled by `useLiveFlights` internally.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useLiveFlights,
|
||||
type UseLiveFlightsConfig,
|
||||
} from "@/shared/hooks/useLiveFlights.js";
|
||||
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Param & result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LiveBoardSearchParams {
|
||||
date: string;
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
export interface UseLiveBoardSearchResult {
|
||||
flights: ISimpleFlight[];
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel key builder (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildBoardChannelKey(params: LiveBoardSearchParams): string {
|
||||
return `board:${params.date}:${params.departure ?? ""}:${params.arrival ?? ""}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLiveBoardSearch(
|
||||
params: LiveBoardSearchParams,
|
||||
initialFlights: ISimpleFlight[],
|
||||
): UseLiveBoardSearchResult {
|
||||
const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>(
|
||||
() => ({
|
||||
hubUrl: getEnv().SIGNALR_HUB_URL,
|
||||
channelKey: buildBoardChannelKey,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { data, connectionStatus } = useLiveFlights<
|
||||
LiveBoardSearchParams,
|
||||
ISimpleFlight
|
||||
>(params, initialFlights, config);
|
||||
|
||||
return { flights: data, connectionStatus };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
_resetSharedConnections,
|
||||
getSharedConnection,
|
||||
} from "@/shared/signalr/connection.js";
|
||||
import {
|
||||
buildFlightChannelKey,
|
||||
useLiveFlightDetails,
|
||||
} from "./useLiveFlightDetails.js";
|
||||
import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("@/env/index.js", () => ({
|
||||
getEnv: () => ({
|
||||
SIGNALR_HUB_URL: "https://hub.test/tracker",
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock hub helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockHub() {
|
||||
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
|
||||
const list = handlers[method] ?? [];
|
||||
handlers[method] = list;
|
||||
list.push(handler);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
onclose: vi.fn(),
|
||||
onreconnecting: vi.fn(),
|
||||
onreconnected: vi.fn(),
|
||||
_simulateMessage(channel: string, ...args: unknown[]) {
|
||||
for (const h of handlers[channel] ?? []) h(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installMockHub(hubUrl: string) {
|
||||
const hub = createMockHub();
|
||||
const conn = getSharedConnection({ hubUrl });
|
||||
conn._buildConnection = vi.fn().mockResolvedValue(hub);
|
||||
return { hub, conn };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFlight(id: string): ISimpleFlight {
|
||||
return {
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "",
|
||||
date: "2025-01-15",
|
||||
} satisfies IFlightId,
|
||||
flyingTime: "3h",
|
||||
operatingBy: {},
|
||||
id,
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "LED",
|
||||
airportCode: "LED",
|
||||
city: "St Petersburg",
|
||||
cityCode: "LED",
|
||||
countryCode: "RU",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "",
|
||||
localTime: "",
|
||||
tzOffset: 3,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "SVO",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "Open",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "",
|
||||
localTime: "",
|
||||
tzOffset: 3,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
dayChange: 0,
|
||||
equipment: {},
|
||||
flags: {
|
||||
checkinAvailable: true,
|
||||
returnToAirport: false,
|
||||
routeChanged: false,
|
||||
},
|
||||
flyingTime: "3h",
|
||||
index: 0,
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
updated: "2025-01-15T00:00:00Z",
|
||||
} satisfies IFlightLeg,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFlightChannelKey", () => {
|
||||
it("builds key without suffix", () => {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
expect(buildFlightChannelKey(id)).toBe("flight:SU100@2025-01-15");
|
||||
});
|
||||
|
||||
it("builds key with suffix", () => {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "A",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
expect(buildFlightChannelKey(id)).toBe("flight:SU100A@2025-01-15");
|
||||
});
|
||||
|
||||
it("builds key with empty suffix", () => {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
expect(buildFlightChannelKey(id)).toBe("flight:SU100@2025-01-15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLiveFlightDetails", () => {
|
||||
const HUB_URL = "https://hub.test/tracker";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
_resetSharedConnections();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns initial flight on mount", async () => {
|
||||
installMockHub(HUB_URL);
|
||||
const initial = makeFlight("f1");
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveFlightDetails(id, initial),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.flight).toEqual(initial);
|
||||
});
|
||||
|
||||
it("returns null when initial flight is null", async () => {
|
||||
installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveFlightDetails(id, null),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.flight).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes to the correct channel", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
suffix: "A",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
|
||||
renderHook(() => useLiveFlightDetails(id, null));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(hub.on).toHaveBeenCalledWith(
|
||||
"flight:SU100A@2025-01-15",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates flight when a SignalR message arrives", async () => {
|
||||
const { hub } = installMockHub(HUB_URL);
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "2025-01-15",
|
||||
};
|
||||
const channel = "flight:SU100@2025-01-15";
|
||||
const initial = makeFlight("f1");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLiveFlightDetails(id, initial),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const updated = [makeFlight("f2")];
|
||||
act(() => {
|
||||
hub._simulateMessage(channel, updated);
|
||||
});
|
||||
|
||||
// useLiveFlights replaces the full array; our hook takes [0]
|
||||
expect(result.current.flight).toEqual(updated[0]);
|
||||
});
|
||||
|
||||
it("returns idle connectionStatus during SSR", () => {
|
||||
const origWindow = globalThis.window;
|
||||
// @ts-expect-error -- intentionally deleting window for SSR simulation
|
||||
delete globalThis.window;
|
||||
|
||||
try {
|
||||
_resetSharedConnections();
|
||||
installMockHub(HUB_URL);
|
||||
|
||||
const conn = getSharedConnection({ hubUrl: HUB_URL });
|
||||
const subscribeSpy = vi.spyOn(conn, "subscribe");
|
||||
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
subscribeSpy.mockRestore();
|
||||
} finally {
|
||||
globalThis.window = origWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Live SignalR hook for the Online Board flight details page.
|
||||
*
|
||||
* Composes the generic `useLiveFlights` (1E) with TrackerHub's
|
||||
* `Subscribe` channel for a single flight. When the server pushes a
|
||||
* `Refresh` message, `useLiveFlights` replaces the flight data.
|
||||
*
|
||||
* Client-only — SSR is handled by `useLiveFlights` internally.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useLiveFlights,
|
||||
type UseLiveFlightsConfig,
|
||||
} from "@/shared/hooks/useLiveFlights.js";
|
||||
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
|
||||
import type { ISimpleFlight, IParsedFlightId } from "../types.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseLiveFlightDetailsResult {
|
||||
flight: ISimpleFlight | null;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel key builder (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildFlightChannelKey(id: IParsedFlightId): string {
|
||||
return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLiveFlightDetails(
|
||||
id: IParsedFlightId,
|
||||
initialFlight: ISimpleFlight | null,
|
||||
): UseLiveFlightDetailsResult {
|
||||
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>(
|
||||
() => ({
|
||||
hubUrl: getEnv().SIGNALR_HUB_URL,
|
||||
channelKey: buildFlightChannelKey,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// useLiveFlights expects an array — wrap/unwrap the single flight
|
||||
const initialData = useMemo(
|
||||
() => (initialFlight ? [initialFlight] : []),
|
||||
[initialFlight],
|
||||
);
|
||||
|
||||
const { data, connectionStatus } = useLiveFlights<
|
||||
IParsedFlightId,
|
||||
ISimpleFlight
|
||||
>(id, initialData, config);
|
||||
|
||||
const flight = data[0] ?? null;
|
||||
|
||||
return { flight, connectionStatus };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* React hook for the online board search pages.
|
||||
*
|
||||
* Calls `searchFlights` on param change, manages loading/error/data state.
|
||||
* Thin wrapper — integration-tested by 2E/2H, not unit-tested here.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { searchFlights } from "../api.js";
|
||||
import type { SearchFlightsParams } from "../api.js";
|
||||
import type { ISimpleFlight } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UseOnlineBoardResult {
|
||||
flights: ISimpleFlight[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for online board search pages. Fetches flights based on search params
|
||||
* and provides refresh capability.
|
||||
*/
|
||||
export function useOnlineBoard(
|
||||
params: SearchFlightsParams,
|
||||
): UseOnlineBoardResult {
|
||||
const client = useApiClient();
|
||||
const [flights, setFlights] = useState<ISimpleFlight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Serialize params for useEffect dependency
|
||||
const paramsRef = useRef(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
searchFlights(client, paramsRef.current)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setFlights(response.data.routes);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
params.flightNumber,
|
||||
params.dateFrom,
|
||||
params.dateTo,
|
||||
params.departure,
|
||||
params.arrival,
|
||||
params.timeFrom,
|
||||
params.timeTo,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
return { flights, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Public barrel for the online-board feature.
|
||||
// This file is the ONLY public surface — other sub-plans and features
|
||||
// must import exclusively from "@/features/online-board", never from
|
||||
// deeper paths. See docs/superpowers/phase-1/frozen-barrels.md for the rule.
|
||||
|
||||
// 2B — URL serializer/parser
|
||||
export type { OnlineBoardParams } from "./url.js";
|
||||
export {
|
||||
parseOnlineBoardUrl,
|
||||
buildOnlineBoardUrl,
|
||||
parseFlightUrlParams,
|
||||
buildFlightUrlParams,
|
||||
parseStationUrlParams,
|
||||
parseRouteUrlParams,
|
||||
} from "./url.js";
|
||||
|
||||
// 2C — API functions
|
||||
export type {
|
||||
SearchFlightsParams,
|
||||
FlightDetailsParams,
|
||||
CalendarParams,
|
||||
} from "./api.js";
|
||||
export { searchFlights, getFlightDetails, getCalendarDays } from "./api.js";
|
||||
|
||||
// 2C — React hooks
|
||||
export { useOnlineBoard } from "./hooks/useOnlineBoard.js";
|
||||
export type { UseOnlineBoardResult } from "./hooks/useOnlineBoard.js";
|
||||
export { useFlightDetails } from "./hooks/useFlightDetails.js";
|
||||
export type { UseFlightDetailsResult } from "./hooks/useFlightDetails.js";
|
||||
export { useCalendarDays } from "./hooks/useCalendarDays.js";
|
||||
export type { UseCalendarDaysResult } from "./hooks/useCalendarDays.js";
|
||||
|
||||
// 2D — SignalR wiring hooks
|
||||
export { useLiveBoardSearch } from "./hooks/useLiveBoardSearch.js";
|
||||
export type { UseLiveBoardSearchResult } from "./hooks/useLiveBoardSearch.js";
|
||||
export { useLiveFlightDetails } from "./hooks/useLiveFlightDetails.js";
|
||||
export type { UseLiveFlightDetailsResult } from "./hooks/useLiveFlightDetails.js";
|
||||
|
||||
// 2F — SEO builder functions
|
||||
export {
|
||||
buildOnlineBoardStartSeo,
|
||||
buildFlightSearchSeo,
|
||||
buildDepartureSearchSeo,
|
||||
buildArrivalSearchSeo,
|
||||
buildRouteSearchSeo,
|
||||
buildFlightDetailsSeo,
|
||||
} from "./seo.js";
|
||||
export type { TFunction, CityNames } from "./seo.js";
|
||||
|
||||
// 2F — JSON-LD builder functions
|
||||
export { buildFlightJsonLd, buildFlightListJsonLd } from "./json-ld.js";
|
||||
|
||||
// 2E — Feature-specific page components
|
||||
export { OnlineBoardStartPage } from "./components/OnlineBoardStartPage.js";
|
||||
export { OnlineBoardSearchPage } from "./components/OnlineBoardSearchPage.js";
|
||||
export type { OnlineBoardSearchPageProps } from "./components/OnlineBoardSearchPage.js";
|
||||
export { OnlineBoardDetailsPage } from "./components/OnlineBoardDetailsPage.js";
|
||||
export type { OnlineBoardDetailsPageProps } from "./components/OnlineBoardDetailsPage.js";
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Flight, ItemList } from "schema-dts";
|
||||
import { buildFlightJsonLd, buildFlightListJsonLd } from "./json-ld.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
function makeDirectFlight(overrides: Partial<{
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
date: string;
|
||||
depCode: string;
|
||||
depCity: string;
|
||||
depAirport: string;
|
||||
arrCode: string;
|
||||
arrCity: string;
|
||||
arrAirport: string;
|
||||
depTime: string;
|
||||
arrTime: string;
|
||||
aircraft: string;
|
||||
}> = {}): ISimpleFlight {
|
||||
return {
|
||||
id: "test-flight-1",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: overrides.carrier ?? "SU",
|
||||
flightNumber: overrides.flightNumber ?? "0100",
|
||||
suffix: "",
|
||||
date: overrides.date ?? "20250115",
|
||||
},
|
||||
flyingTime: "10h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10h 30m",
|
||||
updated: "2025-01-15T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: overrides.aircraft ?? "Boeing 777-300ER", code: "773" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: overrides.depAirport ?? "Sheremetyevo International Airport",
|
||||
airportCode: overrides.depCode ?? "SVO",
|
||||
city: overrides.depCity ?? "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.depTime ?? "2025-01-15T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: overrides.arrAirport ?? "John F. Kennedy International Airport",
|
||||
airportCode: overrides.arrCode ?? "JFK",
|
||||
city: overrides.arrCity ?? "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: overrides.arrTime ?? "2025-01-15T14:30:00",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "2025-01-15T19:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFlightJsonLd", () => {
|
||||
it("returns a Flight schema with correct @type", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result["@type"]).toBe("Flight");
|
||||
});
|
||||
|
||||
it("sets flightNumber in IATA format", () => {
|
||||
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0100" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.flightNumber).toBe("SU0100");
|
||||
});
|
||||
|
||||
it("maps departure airport", () => {
|
||||
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.departureAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "SVO",
|
||||
name: "Sheremetyevo",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps arrival airport", () => {
|
||||
const flight = makeDirectFlight({ arrCode: "JFK", arrAirport: "JFK Airport" });
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.arrivalAirport).toEqual({
|
||||
"@type": "Airport",
|
||||
iataCode: "JFK",
|
||||
name: "JFK Airport",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps departure and arrival times", () => {
|
||||
const flight = makeDirectFlight({
|
||||
depTime: "2025-01-15T10:00:00",
|
||||
arrTime: "2025-01-15T14:30:00",
|
||||
});
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.departureTime).toBe("2025-01-15T10:00:00");
|
||||
expect(result.arrivalTime).toBe("2025-01-15T14:30:00");
|
||||
});
|
||||
|
||||
it("maps estimated flight duration", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.estimatedFlightDuration).toBe("10h 30m");
|
||||
});
|
||||
|
||||
it("maps provider to Aeroflot", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
"@type": "Airline",
|
||||
name: "Aeroflot",
|
||||
iataCode: "SU",
|
||||
});
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts Flight", () => {
|
||||
const flight = makeDirectFlight();
|
||||
const result = buildFlightJsonLd(flight);
|
||||
// TypeScript compile-time check: assigning to Flight should not error
|
||||
const _typed: Flight = result;
|
||||
expect(_typed["@type"]).toBe("Flight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightListJsonLd", () => {
|
||||
it("returns an ItemList schema with correct @type", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildFlightListJsonLd(flights, "Flights from Moscow");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
});
|
||||
|
||||
it("sets the description on the ItemList", () => {
|
||||
const flights = [makeDirectFlight()];
|
||||
const result = buildFlightListJsonLd(flights, "Departures from SVO");
|
||||
|
||||
expect(result.description).toBe("Departures from SVO");
|
||||
});
|
||||
|
||||
it("wraps each flight as a ListItem with position", () => {
|
||||
const flights = [
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0100" }),
|
||||
makeDirectFlight({ carrier: "SU", flightNumber: "0200" }),
|
||||
];
|
||||
const result = buildFlightListJsonLd(flights, "Flights");
|
||||
const items = result.itemListElement;
|
||||
|
||||
expect(Array.isArray(items)).toBe(true);
|
||||
const itemArray = items as unknown as Array<{ "@type": string; position: number; item: Flight }>;
|
||||
expect(itemArray).toHaveLength(2);
|
||||
expect(itemArray[0]).toHaveProperty("@type", "ListItem");
|
||||
expect(itemArray[0]).toHaveProperty("position", 1);
|
||||
expect(itemArray[1]).toHaveProperty("position", 2);
|
||||
});
|
||||
|
||||
it("embeds Flight objects inside ListItem.item", () => {
|
||||
const flights = [makeDirectFlight({ carrier: "SU", flightNumber: "0100" })];
|
||||
const result = buildFlightListJsonLd(flights, "Flights");
|
||||
const items = result.itemListElement as unknown as Array<{ item: Flight }>;
|
||||
|
||||
expect(items[0]).toHaveProperty("item.@type", "Flight");
|
||||
expect(items[0]).toHaveProperty("item.flightNumber", "SU0100");
|
||||
});
|
||||
|
||||
it("handles empty flight list", () => {
|
||||
const result = buildFlightListJsonLd([], "No flights");
|
||||
|
||||
expect(result["@type"]).toBe("ItemList");
|
||||
expect(result.itemListElement).toEqual([]);
|
||||
});
|
||||
|
||||
it("is type-compatible with schema-dts ItemList", () => {
|
||||
const result = buildFlightListJsonLd([], "test");
|
||||
const _typed: ItemList = result;
|
||||
expect(_typed["@type"]).toBe("ItemList");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* JSON-LD schema builders for Online Board pages.
|
||||
*
|
||||
* Produces schema-dts typed objects ready for <JsonLdRenderer>.
|
||||
* Uses schema.org Flight and ItemList types.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Flight, ItemList, ListItem } from "schema-dts";
|
||||
import type { ISimpleFlight, IFlightLeg } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the first leg from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getFirstLeg(flight: ISimpleFlight): IFlightLeg | undefined {
|
||||
if (flight.routeType === "Direct") {
|
||||
return flight.leg;
|
||||
}
|
||||
return flight.legs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last leg from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getLastLeg(flight: ISimpleFlight): IFlightLeg | undefined {
|
||||
if (flight.routeType === "Direct") {
|
||||
return flight.leg;
|
||||
}
|
||||
const { legs } = flight;
|
||||
return legs[legs.length - 1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public JSON-LD builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a schema.org Flight JSON-LD object from a flight record.
|
||||
*
|
||||
* Maps the first leg's departure and last leg's arrival for multi-leg flights.
|
||||
*/
|
||||
export function buildFlightJsonLd(flight: ISimpleFlight): Flight {
|
||||
const firstLeg = getFirstLeg(flight);
|
||||
const lastLeg = getLastLeg(flight);
|
||||
|
||||
const { carrier, flightNumber } = flight.flightId;
|
||||
|
||||
const result: Flight = {
|
||||
"@type": "Flight",
|
||||
flightNumber: `${carrier}${flightNumber}`,
|
||||
provider: {
|
||||
"@type": "Airline",
|
||||
name: "Aeroflot",
|
||||
iataCode: carrier,
|
||||
},
|
||||
estimatedFlightDuration: flight.flyingTime,
|
||||
};
|
||||
|
||||
if (firstLeg) {
|
||||
result.departureAirport = {
|
||||
"@type": "Airport",
|
||||
iataCode: firstLeg.departure.scheduled.airportCode,
|
||||
name: firstLeg.departure.scheduled.airport,
|
||||
};
|
||||
result.departureTime = firstLeg.departure.times.scheduledDeparture.local;
|
||||
|
||||
if (firstLeg.departure.terminal) {
|
||||
result.departureTerminal = firstLeg.departure.terminal;
|
||||
}
|
||||
if (firstLeg.departure.gate) {
|
||||
result.departureGate = firstLeg.departure.gate;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastLeg) {
|
||||
result.arrivalAirport = {
|
||||
"@type": "Airport",
|
||||
iataCode: lastLeg.arrival.scheduled.airportCode,
|
||||
name: lastLeg.arrival.scheduled.airport,
|
||||
};
|
||||
result.arrivalTime = lastLeg.arrival.times.scheduledArrival.local;
|
||||
|
||||
if (lastLeg.arrival.terminal) {
|
||||
result.arrivalTerminal = lastLeg.arrival.terminal;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstLeg?.equipment.name) {
|
||||
result.aircraft = firstLeg.equipment.name;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schema.org ItemList of Flight JSON-LD objects from search results.
|
||||
*
|
||||
* Each flight becomes a ListItem with a position (1-indexed).
|
||||
*/
|
||||
export function buildFlightListJsonLd(
|
||||
flights: ISimpleFlight[],
|
||||
searchDescription: string,
|
||||
): ItemList {
|
||||
const items: ListItem[] = flights.map((flight, index) => ({
|
||||
"@type": "ListItem" as const,
|
||||
position: index + 1,
|
||||
item: buildFlightJsonLd(flight),
|
||||
}));
|
||||
|
||||
return {
|
||||
"@type": "ItemList",
|
||||
description: searchDescription,
|
||||
itemListElement: items,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOnlineBoardStartSeo,
|
||||
buildFlightSearchSeo,
|
||||
buildDepartureSearchSeo,
|
||||
buildArrivalSearchSeo,
|
||||
buildRouteSearchSeo,
|
||||
buildFlightDetailsSeo,
|
||||
} from "./seo.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
/** Stub t() that returns the key + interpolation vars for assertion. */
|
||||
function stubT(key: string, opts?: Record<string, unknown>): string {
|
||||
if (opts && Object.keys(opts).length > 0) {
|
||||
const vars = Object.entries(opts)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",");
|
||||
return `${key}|${vars}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
const CANONICAL = "https://www.aeroflot.ru";
|
||||
|
||||
describe("buildOnlineBoardStartSeo", () => {
|
||||
it("uses MAIN translation keys for title and description", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toBe("SEO.BOARD.MAIN.TITLE");
|
||||
expect(result.description).toBe("SEO.BOARD.MAIN.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to /{locale}/onlineboard", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe("https://www.aeroflot.ru/en/onlineboard");
|
||||
});
|
||||
|
||||
it("includes hreflang with 10 entries (9 langs + x-default)", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og tags", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.title).toBe(result.title);
|
||||
expect(result.og.description).toBe(result.description);
|
||||
expect(result.og.url).toBe(result.canonical);
|
||||
expect(result.og.type).toBe("website");
|
||||
expect(result.og.locale).toBe("ru");
|
||||
expect(result.og.siteName).toBe("Aeroflot");
|
||||
expect(result.og.image).toContain("aeroflot");
|
||||
});
|
||||
|
||||
it("sets twitter card", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.twitter).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test assertion guards above
|
||||
expect(result.twitter!.card).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightSearchSeo", () => {
|
||||
const params = {
|
||||
type: "flight" as const,
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses FLIGHT-SEARCH translation keys with interpolation", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.FLIGHT-SEARCH.TITLE");
|
||||
expect(result.title).toContain("flightNumber=SU 0100");
|
||||
expect(result.description).toContain("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to the flight search URL", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/flight/SU0100-20250115",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDepartureSearchSeo", () => {
|
||||
const params = {
|
||||
type: "departure" as const,
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses DEPARTURE-SEARCH translation keys with city name", () => {
|
||||
const result = buildDepartureSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ departure: "Moscow" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.DEPARTURE-SEARCH.TITLE");
|
||||
expect(result.title).toContain("departureCity=Moscow");
|
||||
});
|
||||
|
||||
it("falls back to station code when no city name provided", () => {
|
||||
const result = buildDepartureSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("departureCity=SVO");
|
||||
});
|
||||
|
||||
it("sets canonical to the departure search URL", () => {
|
||||
const result = buildDepartureSearchSeo(stubT, params, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/en/onlineboard/departure/SVO-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArrivalSearchSeo", () => {
|
||||
const params = {
|
||||
type: "arrival" as const,
|
||||
station: "JFK",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses ARRIVAL-SEARCH translation keys with city name", () => {
|
||||
const result = buildArrivalSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ arrival: "New York" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.ARRIVAL-SEARCH.TITLE");
|
||||
expect(result.title).toContain("arrivalCity=New York");
|
||||
});
|
||||
|
||||
it("falls back to station code when no city name provided", () => {
|
||||
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("arrivalCity=JFK");
|
||||
});
|
||||
|
||||
it("sets canonical to the arrival search URL", () => {
|
||||
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/arrival/JFK-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRouteSearchSeo", () => {
|
||||
const params = {
|
||||
type: "route" as const,
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses ROUTE-SEARCH translation keys with both city names", () => {
|
||||
const result = buildRouteSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ departure: "Moscow", arrival: "New York" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.ROUTE-SEARCH.TITLE");
|
||||
expect(result.title).toContain("departureCity=Moscow");
|
||||
expect(result.title).toContain("arrivalCity=New York");
|
||||
});
|
||||
|
||||
it("falls back to IATA codes when no city names provided", () => {
|
||||
const result = buildRouteSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("departureCity=SVO");
|
||||
expect(result.title).toContain("arrivalCity=JFK");
|
||||
});
|
||||
|
||||
it("sets canonical to the route search URL", () => {
|
||||
const result = buildRouteSearchSeo(stubT, params, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/en/onlineboard/route/SVO-JFK-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightDetailsSeo", () => {
|
||||
const flight: ISimpleFlight = {
|
||||
id: "SU100-20250115",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "",
|
||||
date: "20250115",
|
||||
},
|
||||
flyingTime: "10h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10h 30m",
|
||||
updated: "2025-01-15T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: "Boeing 777-300ER", code: "773" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo International Airport",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "John F. Kennedy International Airport",
|
||||
airportCode: "JFK",
|
||||
city: "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T14:30:00",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "2025-01-15T19:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("uses FLIGHT-DETAILS translation keys", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE");
|
||||
expect(result.title).toContain("flightNumber=SU 0100");
|
||||
});
|
||||
|
||||
it("sets canonical to the details URL", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/SU0100-20250115",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og.type to article for details pages", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.type).toBe("article");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* SEO builder functions for Online Board route pages.
|
||||
*
|
||||
* Each function is PURE — all data arrives via parameters, no hooks or
|
||||
* side effects. Returns a SeoHeadProps object ready for <SeoHead>.
|
||||
*
|
||||
* Translation keys follow the Angular pattern from meta-tags.service.ts:
|
||||
* SEO.BOARD.{MAIN,FLIGHT-SEARCH,DEPARTURE-SEARCH,ARRIVAL-SEARCH,ROUTE-SEARCH,FLIGHT-DETAILS}.{TITLE,DESCRIPTION}
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SeoHeadProps } from "@/ui/seo/SeoHead.js";
|
||||
import { buildHreflangSet } from "@/shared/seo/hreflang.js";
|
||||
import { buildOnlineBoardUrl } from "./url.js";
|
||||
import type { OnlineBoardParams } from "./url.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translation function signature — intentionally loose to accept
|
||||
* both i18next's TFunction and simple test stubs.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TFunction = (key: string, opts?: any) => string;
|
||||
|
||||
/** Optional city names for station/route searches */
|
||||
export interface CityNames {
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OG_IMAGE = "https://www.aeroflot.ru/static/images/aeroflot-og-default.png";
|
||||
const SITE_NAME = "Aeroflot";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a yyyyMMdd date string to dd.MM.yyyy for display in SEO strings.
|
||||
*/
|
||||
function formatDateForSeo(yyyymmdd: string): string {
|
||||
if (yyyymmdd.length !== 8) return yyyymmdd;
|
||||
const day = yyyymmdd.slice(6, 8);
|
||||
const month = yyyymmdd.slice(4, 6);
|
||||
const year = yyyymmdd.slice(0, 4);
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical URL for a given set of params + locale.
|
||||
*/
|
||||
function buildCanonical(
|
||||
canonicalOrigin: string,
|
||||
locale: string,
|
||||
params: OnlineBoardParams,
|
||||
): string {
|
||||
const path = buildOnlineBoardUrl(params);
|
||||
return `${canonicalOrigin}/${locale}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path without locale prefix for hreflang generation.
|
||||
*/
|
||||
function buildPathWithoutLocale(params: OnlineBoardParams): string {
|
||||
const path = buildOnlineBoardUrl(params);
|
||||
return `/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the common OG + Twitter fields from title, description, canonical, locale.
|
||||
*/
|
||||
function buildCommonSeoProps(args: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical: string;
|
||||
hreflangPath: string;
|
||||
canonicalOrigin: string;
|
||||
locale: string;
|
||||
ogType: "website" | "article";
|
||||
}): Pick<SeoHeadProps, "og" | "twitter" | "hreflang"> {
|
||||
return {
|
||||
hreflang: buildHreflangSet({
|
||||
canonicalOrigin: args.canonicalOrigin,
|
||||
pathWithoutLocale: args.hreflangPath,
|
||||
}),
|
||||
og: {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
url: args.canonical,
|
||||
image: OG_IMAGE,
|
||||
type: args.ogType,
|
||||
locale: args.locale,
|
||||
siteName: SITE_NAME,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public SEO builder functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SEO props for the Online Board start page.
|
||||
*/
|
||||
export function buildOnlineBoardStartSeo(
|
||||
t: TFunction,
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const params: OnlineBoardParams = { type: "start" };
|
||||
const title = t("SEO.BOARD.MAIN.TITLE");
|
||||
const description = t("SEO.BOARD.MAIN.DESCRIPTION");
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for flight number search results page.
|
||||
*/
|
||||
export function buildFlightSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const flightDisplay = `${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.FLIGHT-SEARCH.TITLE", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for departure search results page.
|
||||
*/
|
||||
export function buildDepartureSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const departureCity = cityNames?.departure ?? params.station;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
|
||||
departureCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
|
||||
departureCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for arrival search results page.
|
||||
*/
|
||||
export function buildArrivalSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const arrivalCity = cityNames?.arrival ?? params.station;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for route search results page.
|
||||
*/
|
||||
export function buildRouteSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const departureCity = cityNames?.departure ?? params.departure;
|
||||
const arrivalCity = cityNames?.arrival ?? params.arrival;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
|
||||
departureCity,
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
|
||||
departureCity,
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for flight details page.
|
||||
*/
|
||||
export function buildFlightDetailsSeo(
|
||||
t: TFunction,
|
||||
flight: ISimpleFlight,
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const { carrier, flightNumber, suffix, date } = flight.flightId;
|
||||
const flightDisplay = `${carrier} ${flightNumber}${suffix ?? ""}`;
|
||||
const dateDisplay = formatDateForSeo(date);
|
||||
|
||||
const title = t("SEO.BOARD.FLIGHT-DETAILS.TITLE", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
|
||||
const detailsParams: OnlineBoardParams = suffix
|
||||
? { type: "details", carrier, flightNumber, suffix, date }
|
||||
: { type: "details", carrier, flightNumber, date };
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, detailsParams);
|
||||
const hreflangPath = buildPathWithoutLocale(detailsParams);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "article",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
FlightStatus,
|
||||
FlightRequestType,
|
||||
ITimesSet,
|
||||
IAirportInfo,
|
||||
IFlightLeg,
|
||||
IFlightLegDepartureStation,
|
||||
IFlightLegArrivalStation,
|
||||
IParsedFlightId,
|
||||
IDirectFlight,
|
||||
IMultiLegFlight,
|
||||
ISimpleFlight,
|
||||
IBoardResponse,
|
||||
IDaysResponse,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Type-level satisfaction tests. These verify that our interfaces
|
||||
* accept the shapes the API actually sends. If the types are wrong,
|
||||
* these assignments fail at compile time.
|
||||
*/
|
||||
|
||||
describe("online-board types", () => {
|
||||
it("FlightStatus accepts all known API values", () => {
|
||||
const statuses: FlightStatus[] = [
|
||||
"Scheduled",
|
||||
"Sent",
|
||||
"InFlight",
|
||||
"Landed",
|
||||
"Arrived",
|
||||
"Delayed",
|
||||
"Cancelled",
|
||||
"Unknown",
|
||||
];
|
||||
expect(statuses).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("FlightRequestType accepts all search modes", () => {
|
||||
const modes: FlightRequestType[] = [
|
||||
"flight",
|
||||
"departure",
|
||||
"arrival",
|
||||
"route",
|
||||
];
|
||||
expect(modes).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("ITimesSet satisfies the API shape", () => {
|
||||
const times: ITimesSet = {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T10:30:00",
|
||||
localTime: "10:30",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:30:00Z",
|
||||
};
|
||||
expect(times.localTime).toBe("10:30");
|
||||
});
|
||||
|
||||
it("IAirportInfo satisfies the API shape", () => {
|
||||
const info: IAirportInfo = {
|
||||
airport: "Sheremetyevo",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
};
|
||||
expect(info.airportCode).toBe("SVO");
|
||||
});
|
||||
|
||||
it("IParsedFlightId holds parsed URL params", () => {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
};
|
||||
expect(id.carrier).toBe("SU");
|
||||
});
|
||||
|
||||
it("IDirectFlight satisfies a direct flight shape", () => {
|
||||
const depStation: IFlightLegDepartureStation = {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "Scheduled",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T10:30:00",
|
||||
localTime: "10:30",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:30:00Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const arrStation: IFlightLegArrivalStation = {
|
||||
scheduled: {
|
||||
airport: "Pulkovo",
|
||||
airportCode: "LED",
|
||||
city: "St Petersburg",
|
||||
cityCode: "LED",
|
||||
countryCode: "RU",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T12:00:00",
|
||||
localTime: "12:00",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T09:00:00Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const leg: IFlightLeg = {
|
||||
arrival: arrStation,
|
||||
dayChange: 0,
|
||||
departure: depStation,
|
||||
equipment: { name: "Airbus A320", code: "320" },
|
||||
flags: {
|
||||
checkinAvailable: false,
|
||||
returnToAirport: false,
|
||||
routeChanged: false,
|
||||
},
|
||||
flyingTime: "01:30",
|
||||
index: 0,
|
||||
operatingBy: { carrier: "SU", flightNumber: "100" },
|
||||
status: "Scheduled",
|
||||
updated: "2025-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const flight: IDirectFlight = {
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
date: "2025-01-15",
|
||||
flightNumber: "100",
|
||||
suffix: "",
|
||||
},
|
||||
flyingTime: "01:30",
|
||||
operatingBy: { carrier: "SU", flightNumber: "100" },
|
||||
id: "su-100-20250115",
|
||||
status: "Scheduled",
|
||||
routeType: "Direct",
|
||||
leg,
|
||||
};
|
||||
|
||||
expect(flight.routeType).toBe("Direct");
|
||||
});
|
||||
|
||||
it("IMultiLegFlight satisfies a multi-leg shape", () => {
|
||||
const flight: IMultiLegFlight = {
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
date: "2025-01-15",
|
||||
flightNumber: "200",
|
||||
suffix: "",
|
||||
},
|
||||
flyingTime: "05:00",
|
||||
operatingBy: {},
|
||||
id: "su-200-20250115",
|
||||
status: "Scheduled",
|
||||
routeType: "MultiLeg",
|
||||
legs: [],
|
||||
};
|
||||
expect(flight.routeType).toBe("MultiLeg");
|
||||
});
|
||||
|
||||
it("ISimpleFlight is a union of direct and multi-leg", () => {
|
||||
const direct: ISimpleFlight = {
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
date: "2025-01-15",
|
||||
flightNumber: "100",
|
||||
suffix: "",
|
||||
},
|
||||
flyingTime: "01:30",
|
||||
operatingBy: {},
|
||||
id: "su-100",
|
||||
status: "Scheduled",
|
||||
routeType: "Direct",
|
||||
leg: {} as IFlightLeg,
|
||||
};
|
||||
expect(direct.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("IBoardResponse matches API response shape", () => {
|
||||
const resp: IBoardResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: [],
|
||||
daysOfFlight: ["2025-01-15"],
|
||||
},
|
||||
};
|
||||
expect(resp.data.partners).toContain("SU");
|
||||
});
|
||||
|
||||
it("IDaysResponse matches API response shape", () => {
|
||||
const resp: IDaysResponse = {
|
||||
days: "1,2,3,4,5",
|
||||
};
|
||||
expect(resp.days).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Data model types for the Online Board feature.
|
||||
*
|
||||
* Ported from Angular typings (ClientApp/src/typings/) — flattened into
|
||||
* minimal, UI-oriented interfaces with no Angular dependencies.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums & literals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Flight status values returned by the API */
|
||||
export type FlightStatus =
|
||||
| "Scheduled"
|
||||
| "Sent"
|
||||
| "InFlight"
|
||||
| "Landed"
|
||||
| "Arrived"
|
||||
| "Delayed"
|
||||
| "Cancelled"
|
||||
| "Unknown";
|
||||
|
||||
/** Route shape discriminator */
|
||||
export type RouteType = "Direct" | "MultiLeg" | "Connecting";
|
||||
|
||||
/** Search request type — how the user is searching */
|
||||
export type FlightRequestType = "flight" | "departure" | "arrival" | "route";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time & location primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Day-change indicator (e.g. +1, -1 day vs scheduled) */
|
||||
export interface IDayChange {
|
||||
value: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** A single point-in-time with timezone info, as returned by the API */
|
||||
export interface ITimesSet {
|
||||
dayChange: IDayChange;
|
||||
local: string;
|
||||
localTime: string;
|
||||
tzOffset: number;
|
||||
utc: string;
|
||||
}
|
||||
|
||||
/** Airport/city info from the API */
|
||||
export interface IAirportInfo {
|
||||
airport: string;
|
||||
airportCode: string;
|
||||
city: string;
|
||||
cityCode: string;
|
||||
countryCode: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Station (departure / arrival)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IFlightLegStation {
|
||||
scheduled: IAirportInfo;
|
||||
latest?: IAirportInfo;
|
||||
dispatch?: string;
|
||||
gate?: string;
|
||||
terminal?: string;
|
||||
}
|
||||
|
||||
export interface IDepartureStationTimes {
|
||||
scheduledDeparture: ITimesSet;
|
||||
estimatedBlockOff?: ITimesSet;
|
||||
actualBlockOff?: ITimesSet;
|
||||
}
|
||||
|
||||
export interface IArrivalStationTimes {
|
||||
scheduledArrival: ITimesSet;
|
||||
estimatedBlockOn?: ITimesSet;
|
||||
actualBlockOn?: ITimesSet;
|
||||
}
|
||||
|
||||
export interface IFlightLegDepartureStation extends IFlightLegStation {
|
||||
checkingStatus: string;
|
||||
parkingStand?: string;
|
||||
times: IDepartureStationTimes;
|
||||
}
|
||||
|
||||
export interface IFlightLegArrivalStation extends IFlightLegStation {
|
||||
times: IArrivalStationTimes;
|
||||
bagBelt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight leg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IFlightLegFlags {
|
||||
checkinAvailable: boolean;
|
||||
returnToAirport: boolean;
|
||||
routeChanged: boolean;
|
||||
}
|
||||
|
||||
export interface IFlightLeg {
|
||||
arrival: IFlightLegArrivalStation;
|
||||
dayChange: number;
|
||||
departure: IFlightLegDepartureStation;
|
||||
equipment: { name?: string; code?: string };
|
||||
flags: IFlightLegFlags;
|
||||
flyingTime: string;
|
||||
index: number;
|
||||
operatingBy: { carrier?: string; flightNumber?: string };
|
||||
status: FlightStatus;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight ID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IFlightId {
|
||||
carrier: string;
|
||||
date: string;
|
||||
flightNumber: string;
|
||||
suffix: string;
|
||||
dateLT?: string;
|
||||
}
|
||||
|
||||
export interface IParsedFlightId {
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
suffix?: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight (union of route shapes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IFlightBase {
|
||||
flightId: IFlightId;
|
||||
flyingTime: string;
|
||||
operatingBy: { carrier?: string; flightNumber?: string };
|
||||
id: string;
|
||||
status: FlightStatus;
|
||||
}
|
||||
|
||||
export interface IDirectFlight extends IFlightBase {
|
||||
routeType: "Direct";
|
||||
leg: IFlightLeg;
|
||||
}
|
||||
|
||||
export interface IMultiLegFlight extends IFlightBase {
|
||||
routeType: "MultiLeg";
|
||||
legs: IFlightLeg[];
|
||||
}
|
||||
|
||||
export interface IConnectingFlight {
|
||||
flights: ISimpleFlight[];
|
||||
routeType: "Connecting";
|
||||
flyingTime: string;
|
||||
status: FlightStatus;
|
||||
}
|
||||
|
||||
/** A single flight (direct or multi-leg) — the main UI display unit */
|
||||
export type ISimpleFlight = IDirectFlight | IMultiLegFlight;
|
||||
|
||||
/** Any flight shape including connecting */
|
||||
export type IFlight = ISimpleFlight | IConnectingFlight;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IBoardResponse {
|
||||
data: {
|
||||
partners: string[];
|
||||
routes: ISimpleFlight[];
|
||||
daysOfFlight: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDaysResponse {
|
||||
days: string;
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseFlightUrlParams,
|
||||
buildFlightUrlParams,
|
||||
parseStationUrlParams,
|
||||
parseRouteUrlParams,
|
||||
parseOnlineBoardUrl,
|
||||
buildOnlineBoardUrl,
|
||||
type OnlineBoardParams,
|
||||
} from "./url";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseFlightUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseFlightUrlParams", () => {
|
||||
it("parses a standard 4-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU0100-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a flight with suffix", () => {
|
||||
expect(parseFlightUrlParams("SU0100D-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 3-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU100-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 4-digit flight number without padding", () => {
|
||||
expect(parseFlightUrlParams("SU1234-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "1234",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 1-digit flight number", () => {
|
||||
expect(parseFlightUrlParams("SU1-20250115")).toEqual({
|
||||
carrier: "SU",
|
||||
flightNumber: "1",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a 3-char carrier code (3rd char is letter)", () => {
|
||||
// Rare: carrier like "SUA" where 3rd char is a letter
|
||||
expect(parseFlightUrlParams("SUA100-20250115")).toEqual({
|
||||
carrier: "SUA",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseFlightUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date part", () => {
|
||||
expect(parseFlightUrlParams("SU0100")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid date (non-8-digit)", () => {
|
||||
expect(parseFlightUrlParams("SU0100-2025011")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for no flight info", () => {
|
||||
expect(parseFlightUrlParams("-20250115")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildFlightUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildFlightUrlParams", () => {
|
||||
it("pads a 3-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "100", date: "20250115" }),
|
||||
).toBe("SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds with suffix", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("does not pad a 4-digit flight number", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "1234", date: "20250115" }),
|
||||
).toBe("SU1234-20250115");
|
||||
});
|
||||
|
||||
it("pads a 1-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "1", date: "20250115" }),
|
||||
).toBe("SU0001-20250115");
|
||||
});
|
||||
|
||||
it("pads a 2-digit flight number to 4 digits", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SU", flightNumber: "12", date: "20250115" }),
|
||||
).toBe("SU0012-20250115");
|
||||
});
|
||||
|
||||
it("builds with 3-char carrier", () => {
|
||||
expect(
|
||||
buildFlightUrlParams({ carrier: "SUA", flightNumber: "100", date: "20250115" }),
|
||||
).toBe("SUA0100-20250115");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseStationUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseStationUrlParams", () => {
|
||||
it("parses station without time range", () => {
|
||||
expect(parseStationUrlParams("SVO-20250115")).toEqual({
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses station with time range", () => {
|
||||
expect(parseStationUrlParams("SVO-20250115-08001800")).toEqual({
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseStationUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date", () => {
|
||||
expect(parseStationUrlParams("SVO")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid date", () => {
|
||||
expect(parseStationUrlParams("SVO-2025")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseRouteUrlParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseRouteUrlParams", () => {
|
||||
it("parses route without time range", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED-20250115")).toEqual({
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route with time range", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED-20250115-08001800")).toEqual({
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseRouteUrlParams("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing date", () => {
|
||||
expect(parseRouteUrlParams("SVO-LED")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOnlineBoardUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseOnlineBoardUrl", () => {
|
||||
it("parses start page (no trailing slash)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("parses start page (with trailing slash)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("parses flight search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses flight search URL with suffix", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100D-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses departure search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115")).toEqual({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses departure search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses arrival search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115")).toEqual({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses arrival search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route search URL", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115")).toEqual({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses route search URL with time range", () => {
|
||||
expect(
|
||||
parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115-08001800"),
|
||||
).toEqual({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses details URL (no /flight/ prefix)", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/SU0100-20250115")).toEqual({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses details URL with suffix", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/SU0100D-20250115")).toEqual({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-onlineboard path", () => {
|
||||
expect(parseOnlineBoardUrl("/some/other/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseOnlineBoardUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid flight URL params", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/flight/")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid departure URL params", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/departure/")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles path without leading slash", () => {
|
||||
expect(parseOnlineBoardUrl("onlineboard")).toEqual({ type: "start" });
|
||||
});
|
||||
|
||||
it("handles path without leading slash for flight", () => {
|
||||
expect(parseOnlineBoardUrl("onlineboard/flight/SU0100-20250115")).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildOnlineBoardUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildOnlineBoardUrl", () => {
|
||||
it("builds start page URL", () => {
|
||||
expect(buildOnlineBoardUrl({ type: "start" })).toBe("onlineboard");
|
||||
});
|
||||
|
||||
it("builds flight search URL", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/flight/SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds flight search URL with suffix", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/flight/SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("builds departure search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
|
||||
it("builds departure search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds arrival search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/arrival/LED-20250115");
|
||||
});
|
||||
|
||||
it("builds arrival search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/arrival/LED-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds route search URL without time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/route/SVO-LED-20250115");
|
||||
});
|
||||
|
||||
it("builds route search URL with time range", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/route/SVO-LED-20250115-08001800");
|
||||
});
|
||||
|
||||
it("builds details URL", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/SU0100-20250115");
|
||||
});
|
||||
|
||||
it("builds details URL with suffix", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
}),
|
||||
).toBe("onlineboard/SU0100D-20250115");
|
||||
});
|
||||
|
||||
it("omits time range when only timeFrom is provided", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
|
||||
it("omits time range when only timeTo is provided", () => {
|
||||
expect(
|
||||
buildOnlineBoardUrl({
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeTo: "1800",
|
||||
}),
|
||||
).toBe("onlineboard/departure/SVO-20250115");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roundtrip tests: build -> parse -> build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("roundtrip: build -> parse -> build", () => {
|
||||
const cases: Array<{ name: string; params: OnlineBoardParams }> = [
|
||||
{ name: "start", params: { type: "start" } },
|
||||
{
|
||||
name: "flight without suffix",
|
||||
params: { type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "flight with suffix",
|
||||
params: {
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "departure without time",
|
||||
params: { type: "departure", station: "SVO", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "departure with time",
|
||||
params: {
|
||||
type: "departure",
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arrival without time",
|
||||
params: { type: "arrival", station: "LED", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "arrival with time",
|
||||
params: {
|
||||
type: "arrival",
|
||||
station: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0600",
|
||||
timeTo: "2200",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "route without time",
|
||||
params: { type: "route", departure: "SVO", arrival: "LED", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "route with time",
|
||||
params: {
|
||||
type: "route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
date: "20250115",
|
||||
timeFrom: "0800",
|
||||
timeTo: "1800",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "details without suffix",
|
||||
params: { type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" },
|
||||
},
|
||||
{
|
||||
name: "details with suffix",
|
||||
params: {
|
||||
type: "details",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "D",
|
||||
date: "20250115",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, params } of cases) {
|
||||
it(`roundtrips ${name}`, () => {
|
||||
const url = buildOnlineBoardUrl(params);
|
||||
const parsed = parseOnlineBoardUrl(url);
|
||||
expect(parsed).not.toBeNull();
|
||||
if (parsed === null) return; // type guard for TS
|
||||
const rebuilt = buildOnlineBoardUrl(parsed);
|
||||
expect(rebuilt).toBe(url);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("parse returns null for completely invalid path", () => {
|
||||
expect(parseOnlineBoardUrl("/foo/bar")).toBeNull();
|
||||
});
|
||||
|
||||
it("parse returns null for onlineboard with unknown sub-path that doesn't look like a flight", () => {
|
||||
// "xyz" alone doesn't parse as a flight ID (no dash with date)
|
||||
expect(parseOnlineBoardUrl("/onlineboard/xyz")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles flight number build roundtrip: 3-digit input gets padded", () => {
|
||||
const built = buildOnlineBoardUrl({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "100",
|
||||
date: "20250115",
|
||||
});
|
||||
expect(built).toBe("onlineboard/flight/SU0100-20250115");
|
||||
|
||||
const parsed = parseOnlineBoardUrl(built);
|
||||
expect(parsed).toEqual({
|
||||
type: "flight",
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles 3-char carrier in roundtrip", () => {
|
||||
const params: OnlineBoardParams = {
|
||||
type: "flight",
|
||||
carrier: "SUA",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
};
|
||||
const url = buildOnlineBoardUrl(params);
|
||||
const parsed = parseOnlineBoardUrl(url);
|
||||
expect(parsed).toEqual(params);
|
||||
});
|
||||
|
||||
it("parse returns null for route with insufficient segments", () => {
|
||||
expect(parseOnlineBoardUrl("/onlineboard/route/SVO")).toBeNull();
|
||||
});
|
||||
|
||||
it("station time range with partial time returns no time", () => {
|
||||
// Only 4 chars for time (missing second half) -> no timeFrom/timeTo
|
||||
expect(parseStationUrlParams("SVO-20250115-0800")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Online Board URL serializer/parser.
|
||||
*
|
||||
* Pure functions — no side effects, no Angular imports, no Date objects.
|
||||
* Byte-exact parity with Angular's OnlineBoardUrlBuilderService /
|
||||
* OnlineBoardUrlParserService.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { IParsedFlightId } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Discriminated union of all parsed online board URL parameter shapes */
|
||||
export type OnlineBoardParams =
|
||||
| { type: "start" }
|
||||
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
|
||||
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
|
||||
| {
|
||||
type: "route";
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
}
|
||||
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ONLINE_BOARD_PREFIX = "onlineboard";
|
||||
const TIME_RANGE_LENGTH = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isValidDate(date: string): boolean {
|
||||
return /^\d{8}$/.test(date);
|
||||
}
|
||||
|
||||
function isValidTimeRange(timeRange: string): boolean {
|
||||
return /^\d{8}$/.test(timeRange);
|
||||
}
|
||||
|
||||
function isLetter(ch: string): boolean {
|
||||
return /^[A-Za-z]$/.test(ch);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a flight URL parameter string into its constituent parts.
|
||||
* Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
|
||||
*
|
||||
* Carrier detection logic (matching Angular):
|
||||
* - First 2 chars are always carrier.
|
||||
* - If the 3rd char exists and is a letter, it's part of the carrier (3-char carrier code).
|
||||
* - Last char of the flight info (before the dash) that is a letter is the suffix.
|
||||
*/
|
||||
export function parseFlightUrlParams(raw: string): IParsedFlightId | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const dashIdx = raw.indexOf("-");
|
||||
if (dashIdx < 0) return null;
|
||||
|
||||
const flightInfo = raw.slice(0, dashIdx);
|
||||
const dateStr = raw.slice(dashIdx + 1);
|
||||
|
||||
if (!flightInfo || !isValidDate(dateStr)) return null;
|
||||
|
||||
// Carrier: first 2 chars + optional 3rd letter
|
||||
let carrierLen = 2;
|
||||
const thirdChar = flightInfo[2];
|
||||
if (flightInfo.length > 2 && thirdChar !== undefined && isLetter(thirdChar)) {
|
||||
carrierLen = 3;
|
||||
}
|
||||
|
||||
if (flightInfo.length <= carrierLen) return null;
|
||||
|
||||
const carrier = flightInfo.slice(0, carrierLen);
|
||||
|
||||
// Suffix: last char if it's a letter
|
||||
const lastChar = flightInfo.at(-1);
|
||||
if (lastChar === undefined) return null;
|
||||
const hasSuffix = isLetter(lastChar);
|
||||
const suffix = hasSuffix ? lastChar : undefined;
|
||||
|
||||
// Flight number: everything between carrier and optional suffix
|
||||
const numberEnd = hasSuffix ? flightInfo.length - 1 : flightInfo.length;
|
||||
const flightNumber = flightInfo.slice(carrierLen, numberEnd);
|
||||
|
||||
if (!flightNumber) return null;
|
||||
|
||||
const result: IParsedFlightId = {
|
||||
carrier,
|
||||
flightNumber,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (suffix !== undefined) {
|
||||
result.suffix = suffix;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flight URL parameter string from parts.
|
||||
* Output format: {carrier}{paddedFlightNumber}{suffix?}-{yyyyMMdd}
|
||||
*
|
||||
* Flight number is zero-padded so that flightNumber+suffix is at least 5 chars
|
||||
* (matching Angular's `.slice(-5)` logic).
|
||||
*/
|
||||
export function buildFlightUrlParams(id: IParsedFlightId): string {
|
||||
const suffix = id.suffix ?? "";
|
||||
// Pad flight number to 4 digits (standard IATA width).
|
||||
// Angular's `.slice(-5)` on `flightNumber+suffix` is a truncation guard,
|
||||
// not padding — the API already supplies zero-padded numbers. We pad here
|
||||
// to handle cases where the caller passes unpadded numbers (e.g. "100").
|
||||
const paddedNumber = id.flightNumber.padStart(4, "0");
|
||||
// Angular truncation: take last 5 chars of (number + suffix) to cap length
|
||||
const combined = `${paddedNumber}${suffix}`.slice(-5);
|
||||
|
||||
return `${id.carrier}${combined}-${id.date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time range from an 8-char continuous string "HHmmHHmm".
|
||||
* Returns undefined fields if the input is not a valid time range.
|
||||
*/
|
||||
function parseTimeRange(
|
||||
raw: string | undefined,
|
||||
): { timeFrom: string; timeTo: string } | undefined {
|
||||
if (!raw || !isValidTimeRange(raw)) return undefined;
|
||||
return {
|
||||
timeFrom: raw.slice(0, 4),
|
||||
timeTo: raw.slice(4, TIME_RANGE_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build time range string from optional from/to fields.
|
||||
* Returns undefined if either field is missing.
|
||||
*/
|
||||
function buildTimeRange(
|
||||
timeFrom: string | undefined,
|
||||
timeTo: string | undefined,
|
||||
): string | undefined {
|
||||
if (!timeFrom || !timeTo) return undefined;
|
||||
return `${timeFrom}${timeTo}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a station URL parameter string.
|
||||
* Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseStationUrlParams(
|
||||
raw: string,
|
||||
): { station: string; date: string; timeFrom?: string; timeTo?: string } | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.split("-");
|
||||
const station = parts[0];
|
||||
const dateStr = parts[1];
|
||||
if (station === undefined || dateStr === undefined) return null;
|
||||
|
||||
if (!isValidDate(dateStr)) return null;
|
||||
|
||||
const timeRaw = parts[2];
|
||||
const timeRange = parseTimeRange(timeRaw);
|
||||
|
||||
// If there's a 3rd segment but it's not a valid time range, reject
|
||||
if (timeRaw !== undefined && !timeRange) return null;
|
||||
|
||||
const result: { station: string; date: string; timeFrom?: string; timeTo?: string } = {
|
||||
station,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (timeRange) {
|
||||
result.timeFrom = timeRange.timeFrom;
|
||||
result.timeTo = timeRange.timeTo;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a route URL parameter string.
|
||||
* Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
|
||||
*/
|
||||
export function parseRouteUrlParams(
|
||||
raw: string,
|
||||
): {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.split("-");
|
||||
const departure = parts[0];
|
||||
const arrival = parts[1];
|
||||
const dateStr = parts[2];
|
||||
if (departure === undefined || arrival === undefined || dateStr === undefined) return null;
|
||||
|
||||
if (!isValidDate(dateStr)) return null;
|
||||
|
||||
const timeRaw = parts[3];
|
||||
const timeRange = parseTimeRange(timeRaw);
|
||||
|
||||
const result: {
|
||||
departure: string;
|
||||
arrival: string;
|
||||
date: string;
|
||||
timeFrom?: string;
|
||||
timeTo?: string;
|
||||
} = {
|
||||
departure,
|
||||
arrival,
|
||||
date: dateStr,
|
||||
};
|
||||
|
||||
if (timeRange) {
|
||||
result.timeFrom = timeRange.timeFrom;
|
||||
result.timeTo = timeRange.timeTo;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for constructing OnlineBoardParams with exactOptionalPropertyTypes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toFlightParams(
|
||||
type: "flight" | "details",
|
||||
parsed: IParsedFlightId,
|
||||
): OnlineBoardParams {
|
||||
if (parsed.suffix !== undefined) {
|
||||
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, suffix: parsed.suffix, date: parsed.date };
|
||||
}
|
||||
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, date: parsed.date };
|
||||
}
|
||||
|
||||
function toStationParams(
|
||||
type: "departure" | "arrival",
|
||||
parsed: { station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
): OnlineBoardParams {
|
||||
if (parsed.timeFrom !== undefined && parsed.timeTo !== undefined) {
|
||||
return { type, station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo };
|
||||
}
|
||||
return { type, station: parsed.station, date: parsed.date };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level parse / build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a raw URL path into typed online board params.
|
||||
* Returns null if the path does not match any known online board URL shape.
|
||||
*/
|
||||
export function parseOnlineBoardUrl(path: string): OnlineBoardParams | null {
|
||||
if (!path) return null;
|
||||
|
||||
// Strip leading slash
|
||||
const normalized = path.startsWith("/") ? path.slice(1) : path;
|
||||
|
||||
// Must start with "onlineboard"
|
||||
if (!normalized.startsWith(ONLINE_BOARD_PREFIX)) return null;
|
||||
|
||||
// Get the rest after "onlineboard"
|
||||
const rest = normalized.slice(ONLINE_BOARD_PREFIX.length);
|
||||
|
||||
// Start page: empty or just "/"
|
||||
if (!rest || rest === "/") return { type: "start" };
|
||||
|
||||
// Must have "/" after "onlineboard"
|
||||
if (!rest.startsWith("/")) return null;
|
||||
|
||||
const afterPrefix = rest.slice(1); // Remove leading "/"
|
||||
|
||||
// Split into route type and params
|
||||
const slashIdx = afterPrefix.indexOf("/");
|
||||
|
||||
if (slashIdx >= 0) {
|
||||
const routeType = afterPrefix.slice(0, slashIdx);
|
||||
const params = afterPrefix.slice(slashIdx + 1);
|
||||
|
||||
switch (routeType) {
|
||||
case "flight": {
|
||||
const parsed = parseFlightUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toFlightParams("flight", parsed);
|
||||
}
|
||||
|
||||
case "departure": {
|
||||
const parsed = parseStationUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toStationParams("departure", parsed);
|
||||
}
|
||||
|
||||
case "arrival": {
|
||||
const parsed = parseStationUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return toStationParams("arrival", parsed);
|
||||
}
|
||||
|
||||
case "route": {
|
||||
const parsed = parseRouteUrlParams(params);
|
||||
if (!parsed) return null;
|
||||
return parsed.timeFrom !== undefined && parsed.timeTo !== undefined
|
||||
? { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }
|
||||
: { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date };
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// No sub-slash: must be a details URL (flight ID directly after /onlineboard/)
|
||||
const parsed = parseFlightUrlParams(afterPrefix);
|
||||
if (!parsed) return null;
|
||||
return toFlightParams("details", parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL path segment from typed online board params.
|
||||
* Output is byte-exact match with Angular's URL builder.
|
||||
*
|
||||
* @returns the path segment without leading slash (e.g. "onlineboard/flight/SU0100-20250115")
|
||||
*/
|
||||
export function buildOnlineBoardUrl(params: OnlineBoardParams): string {
|
||||
switch (params.type) {
|
||||
case "start":
|
||||
return ONLINE_BOARD_PREFIX;
|
||||
|
||||
case "flight": {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: params.carrier,
|
||||
flightNumber: params.flightNumber,
|
||||
date: params.date,
|
||||
};
|
||||
if (params.suffix !== undefined) id.suffix = params.suffix;
|
||||
return `${ONLINE_BOARD_PREFIX}/flight/${buildFlightUrlParams(id)}`;
|
||||
}
|
||||
|
||||
case "departure": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.station}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/departure/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/departure/${locationDate}`;
|
||||
}
|
||||
|
||||
case "arrival": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.station}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}`;
|
||||
}
|
||||
|
||||
case "route": {
|
||||
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
|
||||
const locationDate = `${params.departure}-${params.arrival}-${params.date}`;
|
||||
return timeRange
|
||||
? `${ONLINE_BOARD_PREFIX}/route/${locationDate}-${timeRange}`
|
||||
: `${ONLINE_BOARD_PREFIX}/route/${locationDate}`;
|
||||
}
|
||||
|
||||
case "details": {
|
||||
const id: IParsedFlightId = {
|
||||
carrier: params.carrier,
|
||||
flightNumber: params.flightNumber,
|
||||
date: params.date,
|
||||
};
|
||||
if (params.suffix !== undefined) id.suffix = params.suffix;
|
||||
return `${ONLINE_BOARD_PREFIX}/${buildFlightUrlParams(id)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import { getPopularRequests } from "./api";
|
||||
import type { PopularRequest } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockClient(responseBody: unknown, status = 200) {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.test.com",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
|
||||
return { client, mockFetch };
|
||||
}
|
||||
|
||||
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
|
||||
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
|
||||
return new URL(call[0]);
|
||||
}
|
||||
|
||||
const POPULAR_RESPONSE: PopularRequest[] = [
|
||||
{
|
||||
mode: "FlightNumber",
|
||||
carrier: "SU",
|
||||
flightNumber: "9027",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "FlightNumber",
|
||||
carrier: "SU",
|
||||
flightNumber: "9006",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "Route",
|
||||
departure: "KUF",
|
||||
arrival: "ABA",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "Route",
|
||||
departure: "RTW",
|
||||
arrival: "ABA",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getPopularRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getPopularRequests", () => {
|
||||
it("calls the correct API path", async () => {
|
||||
const { client, mockFetch } = createMockClient(POPULAR_RESPONSE);
|
||||
|
||||
await getPopularRequests(client);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/Requests/1/getpopular");
|
||||
});
|
||||
|
||||
it("returns the deserialized PopularRequest[]", async () => {
|
||||
const { client } = createMockClient(POPULAR_RESPONSE);
|
||||
|
||||
const result = await getPopularRequests(client);
|
||||
|
||||
expect(result).toEqual(POPULAR_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(getPopularRequests(client)).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 500", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(getPopularRequests(client)).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Popular Requests API functions.
|
||||
*
|
||||
* Pure functions — each takes an `ApiClient` as a parameter (dependency
|
||||
* injection). No React hooks, no context, no side effects.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@/shared/api/client.js";
|
||||
import type { PopularRequest } from "./types.js";
|
||||
|
||||
/**
|
||||
* Fetch popular requests from the API.
|
||||
* Maps to: `GET /Requests/1/getpopular`
|
||||
*
|
||||
* Angular equivalent: `PopularRequestsApiService.getPopularRequests()`
|
||||
* which called `endpointService.buildCommonURL('getpopular', '1', 'Requests')`
|
||||
*/
|
||||
export async function getPopularRequests(
|
||||
client: ApiClient,
|
||||
): Promise<PopularRequest[]> {
|
||||
return client.get<PopularRequest[]>("Requests/1/getpopular");
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Renders a single popular request item, switching display by request mode.
|
||||
*
|
||||
* Angular equivalent: `PopularRequestComponent` + the per-mode components
|
||||
* (ArrivalRequestComponent, DepartureRequestComponent,
|
||||
* FlightNumberRequestComponent, RouteRequestComponent).
|
||||
*
|
||||
* In React, these are inlined as a single component with a switch,
|
||||
* since each mode branch is 2-3 lines of JSX.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { useCityName } from "@/shared/hooks/useDictionaries.js";
|
||||
import { RequestInfo } from "./RequestInfo.js";
|
||||
import type { PopularRequest } from "../types.js";
|
||||
|
||||
export interface PopularRequestItemProps {
|
||||
request: PopularRequest;
|
||||
onClick: (request: PopularRequest) => void;
|
||||
}
|
||||
|
||||
export function PopularRequestItem({
|
||||
request,
|
||||
onClick,
|
||||
}: PopularRequestItemProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(request);
|
||||
};
|
||||
|
||||
switch (request.mode) {
|
||||
case "Arrival": {
|
||||
return (
|
||||
<ArrivalDisplay
|
||||
label={t("BOARD.ARRIVAL")}
|
||||
cityCode={request.arrival}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "Departure": {
|
||||
return (
|
||||
<DepartureDisplay
|
||||
label={t("BOARD.DEPARTURE")}
|
||||
cityCode={request.departure}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "FlightNumber": {
|
||||
const flightInfo = `${request.carrier}\u00a0${request.flightNumber}`;
|
||||
return (
|
||||
<div className="popular-request">
|
||||
{t("BOARD.FLIGHT_NUMBER")}:{" "}
|
||||
<RequestInfo onClick={handleClick}>{flightInfo}</RequestInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Route":
|
||||
case "RouteWithBack": {
|
||||
const label = getRouteLabel(request.mode, request.type, t);
|
||||
return (
|
||||
<RouteDisplay
|
||||
label={label}
|
||||
departureCode={request.departure}
|
||||
arrivalCode={request.arrival}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ArrivalDisplay({
|
||||
label,
|
||||
cityCode,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
cityCode: string;
|
||||
onClick: () => void;
|
||||
}): JSX.Element {
|
||||
const cityName = useCityName(cityCode);
|
||||
return (
|
||||
<div className="popular-request">
|
||||
{label}: <RequestInfo onClick={onClick}>{cityName}</RequestInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DepartureDisplay({
|
||||
label,
|
||||
cityCode,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
cityCode: string;
|
||||
onClick: () => void;
|
||||
}): JSX.Element {
|
||||
const cityName = useCityName(cityCode);
|
||||
return (
|
||||
<div className="popular-request">
|
||||
{label}: <RequestInfo onClick={onClick}>{cityName}</RequestInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteDisplay({
|
||||
label,
|
||||
departureCode,
|
||||
arrivalCode,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
departureCode: string;
|
||||
arrivalCode: string;
|
||||
onClick: () => void;
|
||||
}): JSX.Element {
|
||||
const departureName = useCityName(departureCode);
|
||||
const arrivalName = useCityName(arrivalCode);
|
||||
return (
|
||||
<div className="popular-request">
|
||||
{label}:{" "}
|
||||
<RequestInfo onClick={onClick}>
|
||||
{departureName} - {arrivalName}
|
||||
</RequestInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRouteLabel(
|
||||
mode: "Route" | "RouteWithBack",
|
||||
type: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
t: (key: string) => any,
|
||||
): string {
|
||||
if (mode === "RouteWithBack") {
|
||||
return t("SCHEDULE.SCHEDULE-FULL-ROUTE") as string;
|
||||
}
|
||||
return type === "Onlineboard"
|
||||
? (t("BOARD.ROUTE") as string)
|
||||
: (t("SCHEDULE.SCHEDULE-OUTBOUND") as string);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Tests for PopularRequestsPanel component.
|
||||
*
|
||||
* Covers all 5 request modes, loading/error states, keyboard
|
||||
* accessibility, and the 4-item display limit.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PopularRequestsPanel } from "./PopularRequestsPanel";
|
||||
import type { PopularRequest } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockUsePopularRequests = vi.fn<
|
||||
() => { requests: PopularRequest[]; loading: boolean; error: Error | null }
|
||||
>();
|
||||
|
||||
vi.mock("../hooks/usePopularRequests.js", () => ({
|
||||
usePopularRequests: () => mockUsePopularRequests(),
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
|
||||
useCityName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockRequests: PopularRequest[] = [
|
||||
{
|
||||
mode: "FlightNumber",
|
||||
carrier: "SU",
|
||||
flightNumber: "9027",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "FlightNumber",
|
||||
carrier: "SU",
|
||||
flightNumber: "9006",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "Route",
|
||||
departure: "KUF",
|
||||
arrival: "ABA",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
{
|
||||
mode: "Route",
|
||||
departure: "RTW",
|
||||
arrival: "ABA",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PopularRequestsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing while loading", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing on error (graceful degradation)", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [],
|
||||
loading: false,
|
||||
error: new Error("API failure"),
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when requests array is empty", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders the title and up to 4 request items", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: mockRequests,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<PopularRequestsPanel onRequestClick={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("BOARD.POPULAR-CHAPTERS")).toBeTruthy();
|
||||
const items = screen.getAllByRole("button");
|
||||
expect(items).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("calls onRequestClick when a flight number item is clicked", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [mockRequests[0] as PopularRequest],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const onRequestClick = vi.fn();
|
||||
render(<PopularRequestsPanel onRequestClick={onRequestClick} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onRequestClick).toHaveBeenCalledWith(mockRequests[0]);
|
||||
});
|
||||
|
||||
it("renders flight number with carrier and number", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [mockRequests[0] as PopularRequest],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
// The span contains "SU\u00a09027" — check the button text
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.textContent).toContain("SU");
|
||||
expect(button.textContent).toContain("9027");
|
||||
// Also check the container has the FLIGHT_NUMBER label
|
||||
expect(container.textContent).toContain("BOARD.FLIGHT_NUMBER");
|
||||
});
|
||||
|
||||
it("renders route request with departure and arrival", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [mockRequests[2] as PopularRequest],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("KUF - ABA");
|
||||
});
|
||||
|
||||
it("limits display to 4 items even with more data", () => {
|
||||
const manyRequests: PopularRequest[] = [
|
||||
...mockRequests,
|
||||
{
|
||||
mode: "Arrival",
|
||||
arrival: "SVO",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
];
|
||||
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: manyRequests,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<PopularRequestsPanel onRequestClick={vi.fn()} />);
|
||||
|
||||
const items = screen.getAllByRole("button");
|
||||
expect(items).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders arrival request mode", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [
|
||||
{
|
||||
mode: "Arrival",
|
||||
arrival: "SVO",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("BOARD.ARRIVAL");
|
||||
expect(screen.getByRole("button").textContent).toBe("SVO");
|
||||
});
|
||||
|
||||
it("renders departure request mode", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [
|
||||
{
|
||||
mode: "Departure",
|
||||
departure: "DME",
|
||||
type: "Onlineboard",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("BOARD.DEPARTURE");
|
||||
expect(screen.getByRole("button").textContent).toBe("DME");
|
||||
});
|
||||
|
||||
it("renders schedule route with correct label", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [
|
||||
{
|
||||
mode: "Route",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
type: "Schedule",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("SCHEDULE.SCHEDULE-OUTBOUND");
|
||||
});
|
||||
|
||||
it("renders RouteWithBack with full-route label", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [
|
||||
{
|
||||
mode: "RouteWithBack",
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
type: "Schedule",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<PopularRequestsPanel onRequestClick={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("SCHEDULE.SCHEDULE-FULL-ROUTE");
|
||||
});
|
||||
|
||||
it("supports keyboard activation (Enter key)", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [mockRequests[0] as PopularRequest],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const onRequestClick = vi.fn();
|
||||
render(<PopularRequestsPanel onRequestClick={onRequestClick} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
|
||||
expect(onRequestClick).toHaveBeenCalledWith(mockRequests[0]);
|
||||
});
|
||||
|
||||
it("supports keyboard activation (Space key)", () => {
|
||||
mockUsePopularRequests.mockReturnValue({
|
||||
requests: [mockRequests[0] as PopularRequest],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const onRequestClick = vi.fn();
|
||||
render(<PopularRequestsPanel onRequestClick={onRequestClick} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
|
||||
expect(onRequestClick).toHaveBeenCalledWith(mockRequests[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Container component for the Popular Requests panel.
|
||||
*
|
||||
* Angular equivalent: `PopularRequestsComponent` (popular-requests.component.ts)
|
||||
* — fetches popular requests on mount, renders up to 4 items in a 2-column
|
||||
* grid, and handles click navigation.
|
||||
*
|
||||
* This component is embedded in start pages (OnlineBoard, Schedule),
|
||||
* not rendered as a standalone route.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { usePopularRequests } from "../hooks/usePopularRequests.js";
|
||||
import { PopularRequestItem } from "./PopularRequestItem.js";
|
||||
import type { PopularRequest } from "../types.js";
|
||||
|
||||
export interface PopularRequestsPanelProps {
|
||||
/** Callback invoked when a user clicks a popular request. The host page
|
||||
* handles navigation based on the request mode and type. */
|
||||
onRequestClick: (request: PopularRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Popular sections" panel with up to 4 popular request items.
|
||||
* Shows nothing while loading, nothing on error (graceful degradation).
|
||||
*/
|
||||
export function PopularRequestsPanel({
|
||||
onRequestClick,
|
||||
}: PopularRequestsPanelProps): JSX.Element | null {
|
||||
const { t } = useTranslation();
|
||||
const { requests, loading, error } = usePopularRequests();
|
||||
|
||||
// Gracefully degrade: don't render anything on loading or error
|
||||
if (loading || error || requests.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Angular renders exactly 4 items (requests[0]..requests[3])
|
||||
const visibleRequests = requests.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="popular-requests">
|
||||
<h3 className="popular-requests__title">
|
||||
{t("BOARD.POPULAR-CHAPTERS")}
|
||||
</h3>
|
||||
{visibleRequests.map((request, index) => (
|
||||
<div key={index} className="popular-requests__item">
|
||||
<PopularRequestItem
|
||||
request={request}
|
||||
onClick={onRequestClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Styled clickable text wrapper for popular request items.
|
||||
*
|
||||
* Angular equivalent: `RequestInfoComponent` (request-info.component.ts)
|
||||
* — a simple styled `<span>` with blue link color and pointer cursor.
|
||||
*/
|
||||
|
||||
import type { ReactNode, MouseEvent } from "react";
|
||||
|
||||
export interface RequestInfoProps {
|
||||
children: ReactNode;
|
||||
onClick: (e: MouseEvent<HTMLSpanElement>) => void;
|
||||
}
|
||||
|
||||
export function RequestInfo({ children, onClick }: RequestInfoProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className="popular-request__link"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick(e as unknown as MouseEvent<HTMLSpanElement>);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
color: "var(--color-blue-link, #0645ad)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* React hook for fetching popular requests.
|
||||
*
|
||||
* Calls `getPopularRequests` on mount, manages loading/error/data state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getPopularRequests } from "../api.js";
|
||||
import type { PopularRequest } from "../types.js";
|
||||
import type { ApiError } from "@/shared/api/errors.js";
|
||||
|
||||
export interface UsePopularRequestsResult {
|
||||
requests: PopularRequest[];
|
||||
loading: boolean;
|
||||
error: ApiError | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that fetches popular requests on mount. Returns loading/error/data.
|
||||
*/
|
||||
export function usePopularRequests(): UsePopularRequestsResult {
|
||||
const client = useApiClient();
|
||||
const [requests, setRequests] = useState<PopularRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getPopularRequests(client)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRequests(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return { requests, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Public barrel for the popular-requests feature. See frozen-barrels.md.
|
||||
|
||||
// 5A — Types
|
||||
export type {
|
||||
RequestMode,
|
||||
PopularRequestType,
|
||||
PopularRequest,
|
||||
PopularRouteRequest,
|
||||
PopularArrivalRequest,
|
||||
PopularDepartureRequest,
|
||||
PopularFlightNumberRequest,
|
||||
} from "./types.js";
|
||||
|
||||
// 5A — API functions
|
||||
export { getPopularRequests } from "./api.js";
|
||||
|
||||
// 5A — React hooks
|
||||
export { usePopularRequests } from "./hooks/usePopularRequests.js";
|
||||
export type { UsePopularRequestsResult } from "./hooks/usePopularRequests.js";
|
||||
|
||||
// 5B — Components
|
||||
export { PopularRequestsPanel } from "./components/PopularRequestsPanel.js";
|
||||
export type { PopularRequestsPanelProps } from "./components/PopularRequestsPanel.js";
|
||||
export { PopularRequestItem } from "./components/PopularRequestItem.js";
|
||||
export type { PopularRequestItemProps } from "./components/PopularRequestItem.js";
|
||||
export { RequestInfo } from "./components/RequestInfo.js";
|
||||
export type { RequestInfoProps } from "./components/RequestInfo.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Data model types for the Popular Requests feature.
|
||||
*
|
||||
* Ported from Angular typings (ClientApp/src/typings/popular-request.ts,
|
||||
* ClientApp/src/typings/enums.ts) — flattened into minimal, UI-oriented
|
||||
* types with no Angular dependencies.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums & literals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Request mode — discriminator for the popular request union */
|
||||
export type RequestMode =
|
||||
| "FlightNumber"
|
||||
| "Route"
|
||||
| "RouteWithBack"
|
||||
| "Departure"
|
||||
| "Arrival";
|
||||
|
||||
/** Which feature area the request navigates to */
|
||||
export type PopularRequestType = "Schedule" | "Onlineboard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request sub-types (discriminated by `mode`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PopularRouteRequest {
|
||||
mode: "Route" | "RouteWithBack";
|
||||
departure: string;
|
||||
arrival: string;
|
||||
type: PopularRequestType;
|
||||
}
|
||||
|
||||
export interface PopularArrivalRequest {
|
||||
mode: "Arrival";
|
||||
arrival: string;
|
||||
type: "Onlineboard";
|
||||
}
|
||||
|
||||
export interface PopularDepartureRequest {
|
||||
mode: "Departure";
|
||||
departure: string;
|
||||
type: "Onlineboard";
|
||||
}
|
||||
|
||||
export interface PopularFlightNumberRequest {
|
||||
mode: "FlightNumber";
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
type: "Onlineboard";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Union type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Any popular request returned by the API */
|
||||
export type PopularRequest =
|
||||
| PopularRouteRequest
|
||||
| PopularArrivalRequest
|
||||
| PopularDepartureRequest
|
||||
| PopularFlightNumberRequest;
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ApiClient } from "@/shared/api/client";
|
||||
import {
|
||||
searchSchedule,
|
||||
getScheduleDetails,
|
||||
getScheduleCalendarDays,
|
||||
} from "./api";
|
||||
import type {
|
||||
IScheduleSearchRequest,
|
||||
IScheduleResponse,
|
||||
IScheduleDetailsResponse,
|
||||
IScheduleDaysResponse,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockClient(responseBody: unknown, status = 200) {
|
||||
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.test.com",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
|
||||
return { client, mockFetch };
|
||||
}
|
||||
|
||||
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
|
||||
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
|
||||
return new URL(call[0]);
|
||||
}
|
||||
|
||||
function extractBody(mockFetch: ReturnType<typeof vi.fn>): unknown {
|
||||
const call = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return JSON.parse(call[1].body as string);
|
||||
}
|
||||
|
||||
function extractMethod(mockFetch: ReturnType<typeof vi.fn>): string {
|
||||
const call = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
return call[1].method ?? "GET";
|
||||
}
|
||||
|
||||
/** Minimal valid schedule response for testing */
|
||||
const SCHEDULE_RESPONSE: IScheduleResponse = [];
|
||||
|
||||
const DETAILS_RESPONSE: IScheduleDetailsResponse = {
|
||||
data: {
|
||||
partners: ["SU"],
|
||||
routes: [],
|
||||
daysOfFlight: ["2025-01-15"],
|
||||
},
|
||||
};
|
||||
|
||||
const DAYS_RESPONSE: IScheduleDaysResponse = {
|
||||
days: "2025-01-15,2025-01-16,2025-01-17",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchSchedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchSchedule", () => {
|
||||
it("sends POST to schedule/1", async () => {
|
||||
const { client, mockFetch } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const params: IScheduleSearchRequest = {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
};
|
||||
|
||||
await searchSchedule(client, params);
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/schedule/1");
|
||||
expect(extractMethod(mockFetch)).toBe("POST");
|
||||
});
|
||||
|
||||
it("sends params as JSON body", async () => {
|
||||
const { client, mockFetch } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const params: IScheduleSearchRequest = {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
connections: 1,
|
||||
};
|
||||
|
||||
await searchSchedule(client, params);
|
||||
|
||||
const body = extractBody(mockFetch);
|
||||
expect(body).toEqual(params);
|
||||
});
|
||||
|
||||
it("returns the deserialized response", async () => {
|
||||
const { client } = createMockClient(SCHEDULE_RESPONSE);
|
||||
|
||||
const result = await searchSchedule(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
});
|
||||
|
||||
expect(result).toEqual(SCHEDULE_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
searchSchedule(client, {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "2025-01-15",
|
||||
dateTo: "2025-01-16",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getScheduleDetails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getScheduleDetails", () => {
|
||||
it("sends indexed flights and dates as query params", async () => {
|
||||
const { client, mockFetch } = createMockClient(DETAILS_RESPONSE);
|
||||
|
||||
await getScheduleDetails(client, {
|
||||
flights: ["SU0012", "SU0013"],
|
||||
dates: ["2025-01-15", "2025-01-16"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/schedule/details");
|
||||
expect(url.searchParams.get("flights[0]")).toBe("SU0012");
|
||||
expect(url.searchParams.get("flights[1]")).toBe("SU0013");
|
||||
expect(url.searchParams.get("dates[0]")).toBe("2025-01-15");
|
||||
expect(url.searchParams.get("dates[1]")).toBe("2025-01-16");
|
||||
expect(url.searchParams.get("departure")).toBe("SVO");
|
||||
expect(url.searchParams.get("arrival")).toBe("LED");
|
||||
});
|
||||
|
||||
it("returns the deserialized response", async () => {
|
||||
const { client } = createMockClient(DETAILS_RESPONSE);
|
||||
|
||||
const result = await getScheduleDetails(client, {
|
||||
flights: ["SU0012"],
|
||||
dates: ["2025-01-15"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
});
|
||||
|
||||
expect(result).toEqual(DETAILS_RESPONSE);
|
||||
});
|
||||
|
||||
it("throws on 404", async () => {
|
||||
const { client } = createMockClient({ error: "not found" }, 404);
|
||||
|
||||
await expect(
|
||||
getScheduleDetails(client, {
|
||||
flights: ["SU999"],
|
||||
dates: ["2025-01-15"],
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
}),
|
||||
).rejects.toThrow("HTTP 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getScheduleCalendarDays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getScheduleCalendarDays", () => {
|
||||
it("builds correct path for route without connections", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/382/route/SVO-LED/schedule/");
|
||||
});
|
||||
|
||||
it("builds correct path for route with connections", async () => {
|
||||
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: true,
|
||||
});
|
||||
|
||||
const url = extractUrl(mockFetch);
|
||||
expect(url.pathname).toBe("/v1/days/2025-01-15/382/connections/SVO-LED-1/schedule/");
|
||||
});
|
||||
|
||||
it("parses comma-separated days string into array", async () => {
|
||||
const { client } = createMockClient(DAYS_RESPONSE);
|
||||
|
||||
const result = await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty days string", async () => {
|
||||
const { client } = createMockClient({ days: "" });
|
||||
|
||||
const result = await getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
const { client } = createMockClient({ error: "internal" }, 500);
|
||||
|
||||
await expect(
|
||||
getScheduleCalendarDays(client, {
|
||||
date: "2025-01-15",
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
connections: false,
|
||||
}),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user