diff --git a/docs/superpowers/plans/2026-04-17-board-details-header.md b/docs/superpowers/plans/2026-04-17-board-details-header.md new file mode 100644 index 00000000..e663759b --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-board-details-header.md @@ -0,0 +1,2837 @@ +# Board Details Header + Action Buttons (B.4) 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:** Build the Online Board flight details header with airline logo, flight number, codesharing, action buttons (Buy Ticket, Register, Flight Status, Share, Print), event indicators (route change, reroute), and last-update timestamp — matching Angular parity including time-based visibility. + +**Architecture:** Four layers. (1) Add `date-fns`, extend `useAppSettings` with buttons config. (2) Build pure visibility functions in `visibility/` directory — tested independently. (3) Build leaf components (OperatorLogo, FlightEvents, LastUpdate, 5 action buttons, SharePanel) and mid-level composites (DetailsHeaderBadge, FlightActions). (4) Compose into `BoardDetailsHeader`, wire into `OnlineBoardDetailsPage`. + +**Tech Stack:** React 18, TypeScript, `date-fns` (new dep), Rspack for SVG/PNG asset imports, existing Modern.js router, Vitest + React Testing Library. + +--- + +## File Structure + +All new files live under `src/features/online-board/components/BoardDetailsHeader/`. Each task creates one focused file + its tests. + +### New source files + +- `airlines.ts` — Airline URL/capability config + `AIRLINES_WITH_STATUS` set +- `visibility/buyTicketVisibility.ts` +- `visibility/registrationVisibility.ts` +- `visibility/flightStatusVisibility.ts` +- `OperatorLogo.tsx` + `OperatorLogo.scss` +- `FlightEvents.tsx` + inline SVG icons +- `LastUpdate.tsx` +- `BuyTicketButton.tsx` +- `RegistrationButton.tsx` +- `FlightStatusButton.tsx` +- `ShareButton.tsx` + `SharePanel.tsx` +- `PrintButton.tsx` +- `DetailsHeaderBadge.tsx` +- `FlightActions.tsx` +- `BoardDetailsHeader.tsx` + `BoardDetailsHeader.scss` +- `actions.scss` (shared button styles) +- `icons.scss` (share/print icons) +- `index.ts` (barrel exports) + +### Assets copied from Angular + +- 35 airline logo directories from `ClientApp/src/assets/img/airlines-logo/` → `src/features/online-board/components/BoardDetailsHeader/airlines-logo/` + +### Modified files + +- `package.json` — add `date-fns` +- `src/shared/hooks/useAppSettings.ts` — add `flightStatusAvailableFromHours`, `buyTicketMinHours`, `buyTicketMaxHours` +- `src/shared/hooks/useAppSettings.test.ts` — add buttons config tests +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — replace inline header with `` +- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — update mocks + +--- + +### Task 1: Add `date-fns` Dependency + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add the dependency** + +Run: + +```bash +pnpm add date-fns +``` + +- [ ] **Step 2: Verify** + +Run: `grep '"date-fns"' package.json` + +Expected: matches the line `"date-fns": "^N.N.N"` under `dependencies`. + +- [ ] **Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "Add date-fns for flight details time-window logic" +``` + +--- + +### Task 2: Extend `useAppSettings` with Buttons Config + +**Files:** +- Modify: `src/shared/hooks/useAppSettings.ts` +- Modify: `src/shared/hooks/useAppSettings.test.ts` +- Modify: `src/shared/api/appSettings.ts` (extend `AppSettingsResponse` type) + +- [ ] **Step 1: Extend `AppSettingsResponse` type** + +Edit `src/shared/api/appSettings.ts`. Replace the `AppSettingsResponse` interface: + +```typescript +export interface AppSettingsButtonsBuyTicketPeriod { + min?: string; // e.g. "2h" + max?: string; // e.g. "72h" +} + +export interface AppSettingsButtonsBuyTicket { + period?: AppSettingsButtonsBuyTicketPeriod; +} + +export interface AppSettingsButtonsFlightStatus { + availableFrom?: string; // e.g. "24h" + visible?: string; +} + +export interface AppSettingsButtons { + flightStatus?: AppSettingsButtonsFlightStatus; + buyTicket?: AppSettingsButtonsBuyTicket; +} + +export interface AppSettingsResponse { + showDebugVersion?: string; + uiOptions?: { + isTestVersion?: string; + filter?: { + onlineboard?: AppSettingsFilterOptions; + schedule?: AppSettingsFilterOptions; + }; + buttons?: AppSettingsButtons; + }; +} +``` + +- [ ] **Step 2: Write failing test** + +Add to `src/shared/hooks/useAppSettings.test.ts` (append to existing describe block): + +```typescript + it("parses buttons config into hour numbers", async () => { + const response: AppSettingsResponse = { + uiOptions: { + buttons: { + flightStatus: { availableFrom: "24h" }, + buyTicket: { period: { min: "2h", max: "72h" } }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.flightStatusAvailableFromHours).toBe(24); + expect(result.current.buyTicketMinHours).toBe(2); + expect(result.current.buyTicketMaxHours).toBe(72); + }); + + it("returns button-config defaults when fields are missing", async () => { + mockGetAppSettings.mockResolvedValue({}); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.flightStatusAvailableFromHours).toBe(24); + expect(result.current.buyTicketMinHours).toBe(2); + expect(result.current.buyTicketMaxHours).toBe(72); + }); +``` + +- [ ] **Step 3: Verify fail** + +Run: `pnpm vitest run src/shared/hooks/useAppSettings.test.ts` + +Expected: the 2 new tests fail with "property does not exist". + +- [ ] **Step 4: Extend the hook** + +Edit `src/shared/hooks/useAppSettings.ts`. Replace the whole file with: + +```typescript +import { useEffect, useState } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getAppSettings } from "@/shared/api/appSettings.js"; + +const DAYS_PATTERN = /^(\d+)d$/; +const HOURS_PATTERN = /^(\d+)h$/; + +const DEFAULTS = { + onlineboardSearchFrom: 2, + onlineboardSearchTo: 14, + scheduleSearchFrom: 30, + scheduleSearchTo: 30, + flightStatusAvailableFromHours: 24, + buyTicketMinHours: 2, + buyTicketMaxHours: 72, +} as const; + +function parsePattern(value: string | undefined, pattern: RegExp, fallback: number): number { + if (!value) return fallback; + const match = pattern.exec(value); + if (!match) return fallback; + return parseInt(match[1]!, 10); +} + +function parseDays(value: string | undefined, fallback: number): number { + return parsePattern(value, DAYS_PATTERN, fallback); +} + +function parseHours(value: string | undefined, fallback: number): number { + return parsePattern(value, HOURS_PATTERN, fallback); +} + +export interface UseAppSettingsResult { + onlineboardSearchFrom: number; + onlineboardSearchTo: number; + scheduleSearchFrom: number; + scheduleSearchTo: number; + flightStatusAvailableFromHours: number; + buyTicketMinHours: number; + buyTicketMaxHours: number; + loading: boolean; + error: Error | null; +} + +/** + * Fetches the global app settings and exposes day-range and button-config numbers. + * On error or parse failure, returns defaults. + */ +export function useAppSettings(): UseAppSettingsResult { + const client = useApiClient(); + const [state, setState] = useState>(DEFAULTS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + getAppSettings(client) + .then((response) => { + if (cancelled) return; + const ob = response.uiOptions?.filter?.onlineboard; + const sc = response.uiOptions?.filter?.schedule; + const fs = response.uiOptions?.buttons?.flightStatus; + const bt = response.uiOptions?.buttons?.buyTicket; + + setState({ + onlineboardSearchFrom: parseDays(ob?.searchFrom, DEFAULTS.onlineboardSearchFrom), + onlineboardSearchTo: parseDays(ob?.searchTo, DEFAULTS.onlineboardSearchTo), + scheduleSearchFrom: parseDays(sc?.searchFrom, DEFAULTS.scheduleSearchFrom), + scheduleSearchTo: parseDays(sc?.searchTo, DEFAULTS.scheduleSearchTo), + flightStatusAvailableFromHours: parseHours(fs?.availableFrom, DEFAULTS.flightStatusAvailableFromHours), + buyTicketMinHours: parseHours(bt?.period?.min, DEFAULTS.buyTicketMinHours), + buyTicketMaxHours: parseHours(bt?.period?.max, DEFAULTS.buyTicketMaxHours), + }); + setLoading(false); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [client]); + + return { ...state, loading, error }; +} +``` + +- [ ] **Step 5: Verify pass** + +Run: `pnpm vitest run src/shared/hooks/useAppSettings.test.ts` + +Expected: all tests pass (existing + 2 new). + +- [ ] **Step 6: Commit** + +```bash +git add src/shared/hooks/useAppSettings.ts src/shared/hooks/useAppSettings.test.ts src/shared/api/appSettings.ts +git commit -m "Extend useAppSettings with flightStatus and buyTicket button config" +``` + +--- + +### Task 3: Airline Config + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/airlines.ts` +- Create: `src/features/online-board/components/BoardDetailsHeader/airlines.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// airlines.test.ts +import { describe, it, expect } from "vitest"; +import { AIRLINES, AIRLINES_WITH_STATUS } from "./airlines.js"; + +describe("AIRLINES", () => { + it("has SU Aeroflot with registration URL and native status", () => { + const su = AIRLINES.SU; + expect(su).toBeDefined(); + expect(su?.name).toBe("Aeroflot"); + expect(su?.registrationUrl).toContain("aeroflot.ru"); + expect(su?.hasNativeStatus).toBe(true); + }); + + it("has HZ Aurora with external status URL", () => { + const hz = AIRLINES.HZ; + expect(hz?.hasNativeStatus).toBe(false); + expect(hz?.statusUrl).toContain("flyaurora"); + }); + + it("has AF AirFrance with no registration URL", () => { + const af = AIRLINES.AF; + expect(af).toBeDefined(); + expect(af?.registrationUrl).toBeUndefined(); + }); +}); + +describe("AIRLINES_WITH_STATUS", () => { + it("contains SU, HZ, FV, DP", () => { + expect(AIRLINES_WITH_STATUS.has("SU")).toBe(true); + expect(AIRLINES_WITH_STATUS.has("HZ")).toBe(true); + expect(AIRLINES_WITH_STATUS.has("FV")).toBe(true); + expect(AIRLINES_WITH_STATUS.has("DP")).toBe(true); + }); + + it("does not contain AF", () => { + expect(AIRLINES_WITH_STATUS.has("AF")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/airlines.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `airlines.ts`** + +```typescript +export interface AirlineConfig { + name: string; + registrationUrl?: string; + hasNativeStatus: boolean; + statusUrl?: string; +} + +export const AIRLINES: Record = { + SU: { + name: "Aeroflot", + registrationUrl: "https://www.aeroflot.ru/sb/ckin/app/ru-ru", + hasNativeStatus: true, + }, + FV: { + name: "Rossiya", + registrationUrl: + "https://www.rossiya-airlines.com/flight-with-us/before_flight/the_ways_of_check-in/", + hasNativeStatus: true, + }, + HZ: { + name: "Aurora", + registrationUrl: "https://www.flyaurora.ru", + hasNativeStatus: false, + statusUrl: "https://www.flyaurora.ru", + }, + DP: { + name: "Pobeda", + registrationUrl: "https://www.pobeda.aero", + hasNativeStatus: false, + statusUrl: "https://www.pobeda.aero", + }, + AF: { + name: "AirFrance", + hasNativeStatus: false, + }, +}; + +export const AIRLINES_WITH_STATUS = new Set(["SU", "HZ", "FV", "DP"]); +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/airlines.test.ts` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/airlines.ts src/features/online-board/components/BoardDetailsHeader/airlines.test.ts +git commit -m "Add airline config for B.4 action buttons" +``` + +--- + +### Task 4: Visibility Logic — `canBuyTicket` + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.ts` +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// buyTicketVisibility.test.ts +import { describe, it, expect } from "vitest"; +import { canBuyTicket } from "./buyTicketVisibility.js"; +import type { ISimpleFlight } from "../../../types.js"; + +function makeFlight(status: "Scheduled" | "Cancelled" | "InFlight", depUtc: string): ISimpleFlight { + return { + id: "SU0022-X", + routeType: "Direct", + flyingTime: "1h", + status, + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + arrival: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", + index: 0, + operatingBy: {}, + status, + updated: "", + }, + } as ISimpleFlight; +} + +describe("canBuyTicket", () => { + it("returns false when status is Cancelled", () => { + const now = new Date("2026-04-17T10:00:00Z"); + const dep = "2026-04-18T10:00:00Z"; // 24h away, inside window + expect(canBuyTicket(makeFlight("Cancelled", dep), now, 2, 72)).toBe(false); + }); + + it("returns false when status is InFlight", () => { + const now = new Date("2026-04-17T10:00:00Z"); + const dep = "2026-04-18T10:00:00Z"; + expect(canBuyTicket(makeFlight("InFlight", dep), now, 2, 72)).toBe(false); + }); + + it("returns true when now is in window (24h before)", () => { + const now = new Date("2026-04-17T10:00:00Z"); + const dep = "2026-04-18T10:00:00Z"; // 24h after now, inside [now+2h, now+72h] + expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(true); + }); + + it("returns false when now is too close ( { + const now = new Date("2026-04-17T09:00:00Z"); + const dep = "2026-04-17T10:00:00Z"; // only 1h away, minHours=2 + expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(false); + }); + + it("returns false when now is too far (>maxHours before)", () => { + const now = new Date("2026-04-17T00:00:00Z"); + const dep = "2026-04-25T00:00:00Z"; // 192h away, maxHours=72 + expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(false); + }); + + it("returns false when UTC departure is empty", () => { + const now = new Date(); + expect(canBuyTicket(makeFlight("Scheduled", ""), now, 2, 72)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```typescript +// buyTicketVisibility.ts +import { parseISO, subHours, isAfter, isBefore } from "date-fns"; +import type { ISimpleFlight } from "../../../types.js"; + +/** + * Buy Ticket button is visible when: + * - flight is NOT Cancelled or InFlight + * - now falls within [departure - maxHours, departure - minHours] + */ +export function canBuyTicket( + flight: ISimpleFlight, + now: Date, + minHours: number, + maxHours: number, +): boolean { + if (flight.status === "Cancelled" || flight.status === "InFlight") return false; + + const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + if (!leg) return false; + + const depUtc = leg.departure.times.scheduledDeparture.utc; + if (!depUtc) return false; + + const departure = parseISO(depUtc); + if (isNaN(departure.getTime())) return false; + + const showFrom = subHours(departure, maxHours); + const showUntil = subHours(departure, minHours); + + return isAfter(now, showFrom) && isBefore(now, showUntil); +} +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts` + +Expected: 6 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts +git commit -m "Add canBuyTicket visibility logic" +``` + +--- + +### Task 5: Visibility Logic — `canRegister` + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.ts` +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// registrationVisibility.test.ts +import { describe, it, expect } from "vitest"; +import { canRegister } from "./registrationVisibility.js"; +import { AIRLINES } from "../airlines.js"; +import type { ISimpleFlight, FlightTransitionStatus } from "../../../types.js"; + +function makeFlight(carrier: string, regStatus?: FlightTransitionStatus): ISimpleFlight { + return { + id: "X-1", + routeType: "Direct", + flyingTime: "1h", + status: "Scheduled", + flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: "0022" }, + leg: { + arrival: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", + index: 0, + operatingBy: {}, + status: "Scheduled", + updated: "", + ...(regStatus + ? { + transition: { + registration: { + start: {} as never, + end: {} as never, + status: regStatus, + isActual: true, + }, + }, + } + : {}), + }, + } as ISimpleFlight; +} + +describe("canRegister", () => { + it("returns true for SU with InProgress registration", () => { + expect(canRegister(makeFlight("SU", "InProgress"), AIRLINES)).toBe(true); + }); + + it("returns false for SU without InProgress registration", () => { + expect(canRegister(makeFlight("SU", "Scheduled"), AIRLINES)).toBe(false); + }); + + it("returns false for SU with no registration transition", () => { + expect(canRegister(makeFlight("SU"), AIRLINES)).toBe(false); + }); + + it("returns false for AF (no registration URL)", () => { + expect(canRegister(makeFlight("AF", "InProgress"), AIRLINES)).toBe(false); + }); + + it("returns false for unknown carrier", () => { + expect(canRegister(makeFlight("XX", "InProgress"), AIRLINES)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```typescript +// registrationVisibility.ts +import type { ISimpleFlight } from "../../../types.js"; +import type { AirlineConfig } from "../airlines.js"; + +export function canRegister( + flight: ISimpleFlight, + airlineConfig: Record, +): boolean { + const carrier = flight.operatingBy.carrier; + if (!carrier) return false; + const config = airlineConfig[carrier]; + if (!config?.registrationUrl) return false; + + const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + if (!leg) return false; + + return leg.transition?.registration?.status === "InProgress"; +} +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts +git commit -m "Add canRegister visibility logic" +``` + +--- + +### Task 6: Visibility Logic — `canViewFlightStatus` + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.ts` +- Create: `src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// flightStatusVisibility.test.ts +import { describe, it, expect } from "vitest"; +import { canViewFlightStatus } from "./flightStatusVisibility.js"; +import { AIRLINES_WITH_STATUS } from "../airlines.js"; +import type { ISimpleFlight } from "../../../types.js"; + +function makeFlight(carrier: string, depUtc: string): ISimpleFlight { + return { + id: "X-1", + routeType: "Direct", + flyingTime: "1h", + status: "Scheduled", + flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: "0022" }, + leg: { + arrival: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", + index: 0, + operatingBy: {}, + status: "Scheduled", + updated: "", + }, + } as ISimpleFlight; +} + +describe("canViewFlightStatus", () => { + it("returns false for carrier not in AIRLINES_WITH_STATUS", () => { + const now = new Date("2026-04-17T10:00:00Z"); + expect(canViewFlightStatus(makeFlight("AF", "2026-04-17T12:00:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(false); + }); + + it("returns false when departure is not same day as now", () => { + const now = new Date("2026-04-17T23:59:59Z"); + // Departure on different UTC day + expect(canViewFlightStatus(makeFlight("SU", "2026-04-18T00:30:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(false); + }); + + it("returns true when SU same-day and within availableFromHours before departure", () => { + const now = new Date("2026-04-17T08:00:00Z"); + // Departure 2h later, availableFromHours=24 → now > dep-24h → true + expect(canViewFlightStatus(makeFlight("SU", "2026-04-17T10:00:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(true); + }); + + it("returns false when now is before availableFrom window", () => { + const now = new Date("2026-04-17T00:00:00Z"); + // Departure 23:00 same day, availableFromHours=1 → available only from 22:00 + expect(canViewFlightStatus(makeFlight("SU", "2026-04-17T23:00:00Z"), now, 1, AIRLINES_WITH_STATUS)).toBe(false); + }); + + it("returns false when departure UTC is empty", () => { + const now = new Date(); + expect(canViewFlightStatus(makeFlight("SU", ""), now, 24, AIRLINES_WITH_STATUS)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```typescript +// flightStatusVisibility.ts +import { parseISO, subHours, isAfter, isSameDay } from "date-fns"; +import type { ISimpleFlight } from "../../../types.js"; + +export function canViewFlightStatus( + flight: ISimpleFlight, + now: Date, + availableFromHours: number, + airlinesWithStatus: Set, +): boolean { + const carrier = flight.operatingBy.carrier; + if (!carrier || !airlinesWithStatus.has(carrier)) return false; + + const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + if (!leg) return false; + + const depUtc = leg.departure.times.scheduledDeparture.utc; + if (!depUtc) return false; + + const departure = parseISO(depUtc); + if (isNaN(departure.getTime())) return false; + + if (!isSameDay(now, departure)) return false; + + const availableFrom = subHours(departure, availableFromHours); + return isAfter(now, availableFrom); +} +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts +git commit -m "Add canViewFlightStatus visibility logic" +``` + +--- + +### Task 7: Copy Airline Logo Assets + SCSS + +**Files:** +- Create: 35 directories under `src/features/online-board/components/BoardDetailsHeader/airlines-logo/` +- Create: `src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss` + +- [ ] **Step 1: Copy assets** + +Run: + +```bash +cp -r ClientApp/src/assets/img/airlines-logo src/features/online-board/components/BoardDetailsHeader/ +ls src/features/online-board/components/BoardDetailsHeader/airlines-logo | wc -l +``` + +Expected: `35`. + +- [ ] **Step 2: Create `OperatorLogo.scss` with all logo rules** + +Port Angular's `_logos.scss`, rewriting paths from `~src/assets/img/airlines-logo/` to `./airlines-logo/`. Create `src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss`: + +```scss +$width-base: 120px; +$height-base: 36px; + +@mixin scale($base, $ratio: 0.25, $scale: 1.5) { + width: $base; + height: $base * $ratio; + + &.large { + width: $base * $scale; + height: $base * $scale * $ratio; + } + + @media (max-width: 768px) { + width: $base * 0.75; + height: $base * $ratio * 0.75; + + &.large { + width: $base * $scale * 0.75; + height: $base * $scale * $ratio * 0.75; + } + } +} + +.company-logo { + display: inline-block; + background-repeat: no-repeat; + background-size: contain; + background-position: left top; + + @include scale($width-base); + + &.round { + width: $height-base !important; + height: $height-base !important; + } + + &--SU { + @include scale($width-base, 0.258); + background-image: url('./airlines-logo/aeroflot/large/en.png') !important; + + &.ru { background-image: url('./airlines-logo/aeroflot/large/ru.png') !important; } + &.round { background-image: url('./airlines-logo/aeroflot/round.png') !important; } + } + + &--HZ { + @include scale(80px, 0.5556); + background-image: url('./airlines-logo/aurora/large/en.svg') !important; + + &.ru { background-image: url('./airlines-logo/aurora/large/ru.svg') !important; } + &.round { background-image: url('./airlines-logo/aurora/round.png') !important; } + } + + &--F7 { + @include scale($width-base, 0.258); + background-image: url('./airlines-logo/aeroflot/large/en.png') !important; + + &.ru { background-image: url('./airlines-logo/aeroflot/large/ru.png') !important; } + &.round { background-image: url('./airlines-logo/aeroflot/round.png') !important; } + } + + &--FV { + @include scale(90px, 0.1667); + background-image: url('./airlines-logo/rossiya/large/en.svg') !important; + + &.ru { background-image: url('./airlines-logo/rossiya/large/ru.svg') !important; } + &.round { background-image: url('./airlines-logo/rossiya/round.png') !important; } + } + + &--RO { + @include scale($width-base, 0.3334); + background-image: url('./airlines-logo/tarom/large.png') !important; + &.round { background-image: url('./airlines-logo/tarom/round.svg') !important; } + } + + &--DP { + @include scale($width-base, 0.1889); + background-image: url('./airlines-logo/pobeda/large.svg') !important; + &.round { background-image: url('./airlines-logo/pobeda/round.png') !important; } + } + + &--OM { + background-image: url('./airlines-logo/miat/large.svg') !important; + &.round { background-image: url('./airlines-logo/miat/round.svg') !important; } + } + + &--KL { + @include scale($width-base, 0.4444); + background-image: url('./airlines-logo/klm/large.png') !important; + &.round { background-image: url('./airlines-logo/klm/round.png') !important; } + } + + &--AY { + background-image: url('./airlines-logo/finnair/large.svg') !important; + &.round { background-image: url('./airlines-logo/finnair/round.png') !important; } + } + + &--DL { + background-image: url('./airlines-logo/delta/large.svg') !important; + &.round { background-image: url('./airlines-logo/delta/round.png') !important; } + } + + &--OK { + background-image: url('./airlines-logo/czech-airline/large.png') !important; + &.round { background-image: url('./airlines-logo/czech-airline/round.svg') !important; } + } + + &--JU { + background-image: url('./airlines-logo/air-serbia/large.svg') !important; + &.round { background-image: url('./airlines-logo/air-serbia/round.svg') !important; } + } + + &--UX { + background-image: url('./airlines-logo/air-europa/large.svg') !important; + &.round { background-image: url('./airlines-logo/air-europa/round.svg') !important; } + } + + &--BT { + background-image: url('./airlines-logo/air-baltic/large.svg') !important; + &.round { background-image: url('./airlines-logo/air-baltic/round.svg') !important; } + } + + &--AM { + background-image: url('./airlines-logo/aeromexico/large.svg') !important; + &.round { background-image: url('./airlines-logo/aeromexico/round.svg') !important; } + } + + &--AR { + background-image: url('./airlines-logo/aerolineas-argentinas/large.png') !important; + &.round { background-image: url('./airlines-logo/aerolineas-argentinas/round.svg') !important; } + } + + &--KM { + background-image: url('./airlines-logo/airmalta/large.svg') !important; + } + + &--AF { + @include scale($width-base, 0.1222); + background-image: url('./airlines-logo/airfrance/large.svg') !important; + } + + &--AZ { + @include scale($width-base, 0.2444); + background-image: url('./airlines-logo/alitalia/large.svg') !important; + } + + &--PG { + background-image: url('./airlines-logo/bangkok-airways/large.png') !important; + } + + &--SN { + @include scale($width-base, 0.1667); + background-image: url('./airlines-logo/brussels-airlines/large.png') !important; + } + + &--FB { + @include scale($width-base, 0.296); + background-image: url('./airlines-logo/bulgaria-air/large.png') !important; + } + + &--CI { + @include scale($width-base, 0.1556); + background-image: url('./airlines-logo/china-airlines/large.png') !important; + } + + &--MU { background-image: url('./airlines-logo/china-eastern/large.svg') !important; } + &--CZ { background-image: url('./airlines-logo/china-southern/large.svg') !important; } + &--GA { background-image: url('./airlines-logo/garuda-indonesia/large.png') !important; } + &--FI { background-image: url('./airlines-logo/icelandair/large.svg') !important; } + &--KO { background-image: url('./airlines-logo/kenya-airways/large.svg') !important; } + &--KE { background-image: url('./airlines-logo/korean-air/large.svg') !important; } + &--JL { background-image: url('./airlines-logo/japan-airlines/large.svg') !important; } + &--LO { background-image: url('./airlines-logo/polish-airlines/large.png') !important; } + &--ME { background-image: url('./airlines-logo/mea/large.png') !important; } + + &--S7 { + @include scale($width-base, 0.3333); + background-image: url('./airlines-logo/s7/large.svg') !important; + } + + &--SV { background-image: url('./airlines-logo/saudi-arabian-airlines/large.png') !important; } + &--VN { background-image: url('./airlines-logo/vietnam-airlines/large.png') !important; } + &--MF { background-image: url('./airlines-logo/vietnam-airlines/large.png') !important; } +} + +.operator-logo__caption { + font-size: 12px; + color: #666; + margin-bottom: 2px; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/features/online-board/components/BoardDetailsHeader/airlines-logo/ src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss +git commit -m "Copy airline logos and SCSS from Angular to React" +``` + +--- + +### Task 8: `OperatorLogo` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/OperatorLogo.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { OperatorLogo } from "./OperatorLogo.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(carrier: string): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: "0022" }, + leg: {} as never, + } as ISimpleFlight; +} + +describe("OperatorLogo", () => { + it("renders with company-logo and company-logo--SU classes", () => { + render(); + const el = screen.getByTestId("operator-logo"); + expect(el.className).toContain("company-logo"); + expect(el.className).toContain("company-logo--SU"); + }); + + it("adds ru class when locale is ru", () => { + render(); + const el = screen.getByTestId("operator-logo"); + expect(el.className).toContain("ru"); + }); + + it("adds large class when large=true", () => { + render(); + const el = screen.getByTestId("operator-logo"); + expect(el.className).toContain("large"); + }); + + it("adds round class when round=true", () => { + render(); + const el = screen.getByTestId("operator-logo"); + expect(el.className).toContain("round"); + }); + + it("renders caption when caption=true", () => { + render(); + expect(screen.getByText("SHARED.AVIACOMPANY")).toBeTruthy(); + }); + + it("does not render caption by default", () => { + render(); + expect(screen.queryByText("SHARED.AVIACOMPANY")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `OperatorLogo.tsx`** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import "./OperatorLogo.scss"; + +export interface OperatorLogoProps { + flight: ISimpleFlight; + locale: string; + large?: boolean; + round?: boolean; + caption?: boolean; +} + +export const OperatorLogo: FC = ({ + flight, + locale, + large, + round, + caption, +}) => { + const { t } = useTranslation(); + const carrier = flight.operatingBy.carrier ?? flight.flightId.carrier; + + const classes = [ + "company-logo", + `company-logo--${carrier}`, + large ? "large" : "", + round ? "round" : "", + locale === "ru" ? "ru" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+ {caption &&
{t("SHARED.AVIACOMPANY")}
} +
+
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx` + +Expected: 6 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/OperatorLogo.tsx src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx +git commit -m "Add OperatorLogo component for airline branding" +``` + +--- + +### Task 9: Action Button Shared Styles + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/actions.scss` + +- [ ] **Step 1: Create the shared stylesheet** + +```scss +// actions.scss +.flight-action-btn { + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + border: none; + font-family: inherit; + transition: opacity 0.15s, background-color 0.15s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--orange { + background: #ff9000; + color: #fff; + + &:hover:not(:disabled) { + background: #e68200; + } + } + + &--blue-light { + background: #e3f0ff; + color: #1a3a5c; + + &:hover:not(:disabled) { + background: #c7dff5; + } + } + + &--transparent { + background: transparent; + padding: 8px; + color: #2060c0; + + &:hover:not(:disabled) { + background: rgba(32, 96, 192, 0.08); + } + } + + &--small { + font-size: 12px; + padding: 4px 8px; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/features/online-board/components/BoardDetailsHeader/actions.scss +git commit -m "Add shared action button styles" +``` + +--- + +### Task 10: `BuyTicketButton` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BuyTicketButton } from "./BuyTicketButton.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(depUtc: string, dep: string, arr: string): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + arrival: { + scheduled: { airport: "", airportCode: arr, city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: arr, city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: dep, city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: dep, city: "", cityCode: "", countryCode: "" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", index: 0, operatingBy: {}, status: "Scheduled", updated: "", + }, + } as ISimpleFlight; +} + +describe("BuyTicketButton", () => { + let openSpy: ReturnType; + + beforeEach(() => { + openSpy = vi.fn(); + Object.defineProperty(window, "open", { value: openSpy, writable: true }); + }); + + it("renders the translated label", () => { + render(); + expect(screen.getByText("SHARED.BUY-TICKET")).toBeTruthy(); + }); + + it("has data-testid=buy-ticket-button", () => { + render(); + expect(screen.getByTestId("buy-ticket-button")).toBeTruthy(); + }); + + it("opens the Aeroflot booking URL on click", () => { + render(); + fireEvent.click(screen.getByTestId("buy-ticket-button")); + expect(openSpy).toHaveBeenCalledTimes(1); + const url = openSpy.mock.calls[0][0] as string; + expect(url).toContain("aeroflot.ru/sb/app/ru-ru"); + expect(url).toContain("routes=SVO.20260420.LED"); + expect(url).toContain("autosearch=Y"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { parseISO, format } from "date-fns"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import "./actions.scss"; + +export interface BuyTicketButtonProps { + flight: ISimpleFlight; + locale: string; +} + +function buildBuyTicketUrl(flight: ISimpleFlight, locale: string): string { + const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs; + const firstLeg = legs[0]!; + const lastLeg = legs[legs.length - 1]!; + const dep = firstLeg.departure.scheduled.airportCode; + const arr = lastLeg.arrival.scheduled.airportCode; + const depDate = parseISO(firstLeg.departure.times.scheduledDeparture.utc); + const date = format(depDate, "yyyyMMdd"); + return `https://www.aeroflot.ru/sb/app/${locale}-${locale}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${date}.${arr}&autosearch=Y`; +} + +export const BuyTicketButton: FC = ({ flight, locale }) => { + const { t } = useTranslation(); + + const handleClick = () => { + const url = buildBuyTicketUrl(flight, locale); + window.open(url, "_blank", "noopener,noreferrer"); + }; + + return ( + + ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx` + +Expected: 3 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx +git commit -m "Add BuyTicketButton component" +``` + +--- + +### Task 11: `RegistrationButton` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/RegistrationButton.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { RegistrationButton } from "./RegistrationButton.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(carrier: string): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: "0022" }, + leg: {} as never, + } as ISimpleFlight; +} + +describe("RegistrationButton", () => { + let openSpy: ReturnType; + + beforeEach(() => { + openSpy = vi.fn(); + Object.defineProperty(window, "open", { value: openSpy, writable: true }); + }); + + it("renders label", () => { + render(); + expect(screen.getByText("SHARED.ONLINE-REGISTRATION")).toBeTruthy(); + }); + + it("has data-testid", () => { + render(); + expect(screen.getByTestId("registration-button")).toBeTruthy(); + }); + + it("opens aeroflot.ru registration URL for SU", () => { + render(); + fireEvent.click(screen.getByTestId("registration-button")); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toContain("aeroflot.ru/sb/ckin"); + }); + + it("does not open URL when carrier has no registrationUrl", () => { + render(); + fireEvent.click(screen.getByTestId("registration-button")); + expect(openSpy).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import { AIRLINES } from "./airlines.js"; +import "./actions.scss"; + +export interface RegistrationButtonProps { + flight: ISimpleFlight; +} + +export const RegistrationButton: FC = ({ flight }) => { + const { t } = useTranslation(); + + const handleClick = () => { + const carrier = flight.operatingBy.carrier; + if (!carrier) return; + const config = AIRLINES[carrier]; + if (!config?.registrationUrl) return; + window.open(config.registrationUrl, "_blank", "noopener,noreferrer"); + }; + + return ( + + ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx` + +Expected: 4 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/RegistrationButton.tsx src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx +git commit -m "Add RegistrationButton component" +``` + +--- + +### Task 12: `FlightStatusButton` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FlightStatusButton } from "./FlightStatusButton.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(carrier: string): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: "0022" }, + leg: {} as never, + } as ISimpleFlight; +} + +describe("FlightStatusButton", () => { + let openSpy: ReturnType; + + beforeEach(() => { + openSpy = vi.fn(); + Object.defineProperty(window, "open", { value: openSpy, writable: true }); + }); + + it("renders label", () => { + render(); + expect(screen.getByText("SHARED.DETAILS")).toBeTruthy(); + }); + + it("has data-testid", () => { + render(); + expect(screen.getByTestId("flight-status-button")).toBeTruthy(); + }); + + it("opens native details URL for SU", () => { + render(); + fireEvent.click(screen.getByTestId("flight-status-button")); + expect(openSpy).toHaveBeenCalledTimes(1); + const url = openSpy.mock.calls[0][0] as string; + expect(url).toBe("/ru/onlineboard/SU0022-20260417"); + }); + + it("opens external status URL for HZ (Aurora)", () => { + render(); + fireEvent.click(screen.getByTestId("flight-status-button")); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toContain("flyaurora"); + }); + + it("applies small modifier when small=true", () => { + render(); + const el = screen.getByTestId("flight-status-button"); + expect(el.className).toContain("--small"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import { AIRLINES } from "./airlines.js"; +import { buildOnlineBoardUrl } from "../../url.js"; +import "./actions.scss"; + +export interface FlightStatusButtonProps { + flight: ISimpleFlight; + locale: string; + small?: boolean; +} + +export const FlightStatusButton: FC = ({ flight, locale, small }) => { + const { t } = useTranslation(); + + const handleClick = () => { + const carrier = flight.operatingBy.carrier; + if (!carrier) return; + const config = AIRLINES[carrier]; + if (!config) return; + + if (config.hasNativeStatus) { + const path = buildOnlineBoardUrl({ + type: "details", + carrier: flight.flightId.carrier, + flightNumber: flight.flightId.flightNumber, + ...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}), + date: flight.flightId.date, + }); + window.open(`/${locale}/${path}`, "_blank", "noopener,noreferrer"); + } else if (config.statusUrl) { + window.open(config.statusUrl, "_blank", "noopener,noreferrer"); + } + }; + + const classes = [ + "flight-action-btn", + "flight-action-btn--blue-light", + small ? "flight-action-btn--small" : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.tsx src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx +git commit -m "Add FlightStatusButton component" +``` + +--- + +### Task 13: `SharePanel` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { SharePanel } from "./SharePanel.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("SharePanel", () => { + it("renders Facebook, VK, Twitter links", () => { + render( {}} />); + expect(screen.getByTestId("share-facebook")).toBeTruthy(); + expect(screen.getByTestId("share-vk")).toBeTruthy(); + expect(screen.getByTestId("share-twitter")).toBeTruthy(); + }); + + it("does not render Weibo for non-zh locale", () => { + render( {}} />); + expect(screen.queryByTestId("share-weibo")).toBeNull(); + }); + + it("renders Weibo for zh locale", () => { + render( {}} />); + expect(screen.getByTestId("share-weibo")).toBeTruthy(); + }); + + it("Facebook link points to sharer.php with encoded URL", () => { + render( {}} />); + const a = screen.getByTestId("share-facebook") as HTMLAnchorElement; + expect(a.href).toContain("facebook.com/sharer/sharer.php"); + expect(a.href).toContain("https%3A%2F%2Fexample.com%2Fflight"); + }); + + it("copy button calls navigator.clipboard.writeText and onClose", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true }); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("share-copy")); + expect(writeText).toHaveBeenCalledWith("https://example.com/flight"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import "./actions.scss"; + +export interface SharePanelProps { + url: string; + locale: string; + onClose: () => void; +} + +export const SharePanel: FC = ({ url, locale, onClose }) => { + const { t } = useTranslation(); + const encoded = encodeURIComponent(url); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(url); + onClose(); + } catch { + // ignore — copy failures are silent + } + }; + + return ( +
+ + Facebook + + + VK + + + Twitter + + {locale === "zh" && ( + + Weibo + + )} + +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx +git commit -m "Add SharePanel component with social links and copy-to-clipboard" +``` + +--- + +### Task 14: `ShareButton` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/ShareButton.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ShareButton } from "./ShareButton.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("ShareButton", () => { + it("renders button with data-testid", () => { + render(); + expect(screen.getByTestId("share-button")).toBeTruthy(); + }); + + it("panel is closed by default", () => { + render(); + expect(screen.queryByTestId("share-panel")).toBeNull(); + }); + + it("opens panel on click", () => { + render(); + fireEvent.click(screen.getByTestId("share-button")); + expect(screen.getByTestId("share-panel")).toBeTruthy(); + }); + + it("closes panel when clicked again", () => { + render(); + fireEvent.click(screen.getByTestId("share-button")); + fireEvent.click(screen.getByTestId("share-button")); + expect(screen.queryByTestId("share-panel")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import { type FC, useState } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import { SharePanel } from "./SharePanel.js"; +import "./actions.scss"; + +export interface ShareButtonProps { + url: string; + locale: string; +} + +export const ShareButton: FC = ({ url, locale }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( +
+ + {open && setOpen(false)} />} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx` + +Expected: 4 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/ShareButton.tsx src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx +git commit -m "Add ShareButton component with toggle behavior" +``` + +--- + +### Task 15: `PrintButton` Component (Stub) + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx` + +- [ ] **Step 1: Create PrintButton** + +No failing test needed — this matches Angular's stub behavior (empty URL, hidden via `print=false` at the parent level). + +```tsx +// PrintButton.tsx +import type { FC } from "react"; +import "./actions.scss"; + +export interface PrintButtonProps { + flight: unknown; // matches Angular prop but unused (URL is empty) +} + +export const PrintButton: FC = () => { + return ( + + {/* Inline print icon */} + + + + + + + ); +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx +git commit -m "Add PrintButton stub component (hidden on details page)" +``` + +--- + +### Task 16: `FlightActions` Container + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightActions } from "./FlightActions.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +// Mock visibility functions so we control what's shown +vi.mock("./visibility/buyTicketVisibility.js", () => ({ canBuyTicket: vi.fn(() => true) })); +vi.mock("./visibility/registrationVisibility.js", () => ({ canRegister: vi.fn(() => true) })); +vi.mock("./visibility/flightStatusVisibility.js", () => ({ canViewFlightStatus: vi.fn(() => true) })); + +function makeFlight(): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + departure: { scheduled: { airportCode: "SVO" } as never, latest: {} as never, times: { scheduledDeparture: { utc: "2026-04-20T10:00:00Z" } as never } as never, dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "" } as never, + arrival: { scheduled: { airportCode: "LED" } as never, latest: {} as never, times: { scheduledArrival: {} as never } as never, dispatch: "", gate: "", terminal: "" } as never, + } as never, + } as ISimpleFlight; +} + +describe("FlightActions", () => { + it("renders all enabled buttons by default", () => { + render(); + expect(screen.getByTestId("share-button")).toBeTruthy(); + expect(screen.getByTestId("buy-ticket-button")).toBeTruthy(); + expect(screen.getByTestId("registration-button")).toBeTruthy(); + }); + + it("hides status button when showStatus=false", () => { + render(); + expect(screen.queryByTestId("flight-status-button")).toBeNull(); + }); + + it("shows status button when showStatus=true", () => { + render(); + expect(screen.getByTestId("flight-status-button")).toBeTruthy(); + }); + + it("hides share button when showShare=false", () => { + render(); + expect(screen.queryByTestId("share-button")).toBeNull(); + }); + + it("hides buy ticket when canBuyTicket returns false", async () => { + const mod = await import("./visibility/buyTicketVisibility.js"); + vi.mocked(mod.canBuyTicket).mockReturnValue(false); + render(); + expect(screen.queryByTestId("buy-ticket-button")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { useAppSettings } from "@/shared/hooks/useAppSettings.js"; +import type { ISimpleFlight } from "../../types.js"; +import { AIRLINES, AIRLINES_WITH_STATUS } from "./airlines.js"; +import { canBuyTicket } from "./visibility/buyTicketVisibility.js"; +import { canRegister } from "./visibility/registrationVisibility.js"; +import { canViewFlightStatus } from "./visibility/flightStatusVisibility.js"; +import { BuyTicketButton } from "./BuyTicketButton.js"; +import { RegistrationButton } from "./RegistrationButton.js"; +import { FlightStatusButton } from "./FlightStatusButton.js"; +import { ShareButton } from "./ShareButton.js"; +import { PrintButton } from "./PrintButton.js"; + +export interface FlightActionsProps { + flight: ISimpleFlight; + locale: string; + showStatus?: boolean; // default false + showPrint?: boolean; // default false + showShare?: boolean; // default true + showRegister?: boolean; // default true + showBuy?: boolean; // default true +} + +export const FlightActions: FC = ({ + flight, + locale, + showStatus = false, + showPrint = false, + showShare = true, + showRegister = true, + showBuy = true, +}) => { + const { flightStatusAvailableFromHours, buyTicketMinHours, buyTicketMaxHours } = useAppSettings(); + const now = new Date(); + + const canBuy = showBuy && canBuyTicket(flight, now, buyTicketMinHours, buyTicketMaxHours); + const canReg = showRegister && canRegister(flight, AIRLINES); + const canStatus = + showStatus && + canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS); + + const shareUrl = typeof window !== "undefined" ? window.location.href : ""; + + return ( +
+ {showPrint && } + {showShare && } + {canBuy && } + {canReg && } + {canStatus && } +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx +git commit -m "Add FlightActions container" +``` + +--- + +### Task 17: `DetailsHeaderBadge` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DetailsHeaderBadge } from "./DetailsHeaderBadge.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeDirect(carrier = "SU", num = "0022"): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier, flightNumber: num, suffix: "", date: "20260417" }, + operatingBy: { carrier, flightNumber: num }, + leg: {} as never, + } as ISimpleFlight; +} + +function makeMultiLeg(): ISimpleFlight { + return { + id: "Y", routeType: "MultiLeg", flyingTime: "2h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + legs: [ + { operatingBy: { carrier: "AF", flightNumber: "209" } } as never, + { operatingBy: { carrier: "UA", flightNumber: "456" } } as never, + ], + } as ISimpleFlight; +} + +describe("DetailsHeaderBadge", () => { + it("renders flight number 'SU 0022'", () => { + render(); + expect(screen.getByText(/SU\s*0022/)).toBeTruthy(); + }); + + it("renders OperatorLogo", () => { + render(); + expect(screen.getByTestId("operator-logo")).toBeTruthy(); + }); + + it("renders codesharing list for multi-leg flight with distinct carriers", () => { + render(); + // Shows "AF 209, UA 456" or similar + expect(screen.getByTestId("codesharing")).toBeTruthy(); + }); + + it("does not render codesharing for a direct flight", () => { + render(); + expect(screen.queryByTestId("codesharing")).toBeNull(); + }); + + it("renders small status button when showStatus=true", () => { + render(); + expect(screen.getByTestId("flight-status-button")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import type { ISimpleFlight, IFlightLeg } from "../../types.js"; +import { OperatorLogo } from "./OperatorLogo.js"; +import { FlightStatusButton } from "./FlightStatusButton.js"; + +export interface DetailsHeaderBadgeProps { + flight: ISimpleFlight; + locale: string; + large?: boolean; + round?: boolean; + showStatus?: boolean; +} + +function getCodeshareLegs(flight: ISimpleFlight): IFlightLeg[] { + if (flight.routeType !== "MultiLeg") return []; + return flight.legs.filter((l) => l.operatingBy?.carrier && l.operatingBy.carrier !== flight.flightId.carrier); +} + +export const DetailsHeaderBadge: FC = ({ + flight, + locale, + large = true, + round = false, + showStatus = false, +}) => { + const codeshareLegs = getCodeshareLegs(flight); + const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`; + + return ( +
+
+
{primaryNumber}
+ {codeshareLegs.length > 0 && ( +
+ {codeshareLegs + .map((l) => `${l.operatingBy.carrier} ${l.operatingBy.flightNumber}`) + .join(", ")} +
+ )} +
+ + {showStatus && } +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.tsx src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx +git commit -m "Add DetailsHeaderBadge with flight number and codesharing" +``` + +--- + +### Task 18: `FlightEvents` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightEvents.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightEvents } from "./FlightEvents.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("FlightEvents", () => { + it("renders nothing when no events", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders route change icon when changeRoute=true", () => { + render(); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + expect(screen.getByText("SHARED.ROUTE-CHANGE")).toBeTruthy(); + }); + + it("renders reroute icon when reroute=true", () => { + render(); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + expect(screen.getByText("SHARED.RETURN")).toBeTruthy(); + }); + + it("renders both when both flags are set", () => { + render(); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + }); + + it("hides descriptions when showDescription=false", () => { + render(); + expect(screen.queryByText("SHARED.ROUTE-CHANGE")).toBeNull(); + expect(screen.queryByText("SHARED.RETURN")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; + +export interface FlightEventsProps { + changeRoute: boolean; + reroute: boolean; + showDescription?: boolean; +} + +export const FlightEvents: FC = ({ changeRoute, reroute, showDescription }) => { + const { t } = useTranslation(); + if (!changeRoute && !reroute) return null; + + return ( +
+ {changeRoute && ( +
+ {/* Change icon (ported from Angular) */} + + + + + + + + + {showDescription && ( + {t("SHARED.ROUTE-CHANGE")} + )} +
+ )} + {reroute && ( +
+ {/* Return icon (ported from Angular) */} + + + + {showDescription && ( + {t("SHARED.RETURN")} + )} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx` + +Expected: 5 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/FlightEvents.tsx src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx +git commit -m "Add FlightEvents component with route-change and reroute indicators" +``` + +--- + +### Task 19: `LastUpdate` Component + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { LastUpdate } from "./LastUpdate.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeFlight(updated: string): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { updated } as never, + } as ISimpleFlight; +} + +describe("LastUpdate", () => { + it("renders LAST-UPDATE label", () => { + render(); + expect(screen.getByText(/SHARED\.LAST-UPDATE/)).toBeTruthy(); + }); + + it("renders timestamp in HH:mm DD.MM.YYYY format", () => { + render(); + expect(screen.getByTestId("last-update-timestamp")).toBeTruthy(); + const text = screen.getByTestId("last-update-timestamp").textContent ?? ""; + expect(text).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/); + }); + + it("renders nothing for timestamp when leg.updated is empty", () => { + render(); + const ts = screen.queryByTestId("last-update-timestamp"); + expect(ts?.textContent?.trim() ?? "").toBe(""); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```tsx +import type { FC } from "react"; +import { parseISO, format, isValid } from "date-fns"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import { ShareButton } from "./ShareButton.js"; + +export interface LastUpdateProps { + flight: ISimpleFlight; + locale: string; +} + +function getUpdated(flight: ISimpleFlight): string | undefined { + const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + return leg?.updated; +} + +function formatUpdated(updated: string | undefined): string { + if (!updated) return ""; + const d = parseISO(updated); + if (!isValid(d)) return ""; + return format(d, "HH:mm dd.MM.yyyy"); +} + +export const LastUpdate: FC = ({ flight, locale }) => { + const { t } = useTranslation(); + const timestamp = formatUpdated(getUpdated(flight)); + const shareUrl = typeof window !== "undefined" ? window.location.href : ""; + + return ( +
+ + + {t("SHARED.LAST-UPDATE")}: +  {timestamp} + +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx` + +Expected: 3 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx +git commit -m "Add LastUpdate component with timestamp and mobile share" +``` + +--- + +### Task 20: `BoardDetailsHeader` Orchestrator + `index.ts` + +**Files:** +- Create: `src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss` +- Create: `src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx` +- Create: `src/features/online-board/components/BoardDetailsHeader/index.ts` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BoardDetailsHeader } from "./BoardDetailsHeader.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@/shared/hooks/useAppSettings.js", () => ({ + useAppSettings: () => ({ + onlineboardSearchFrom: 2, onlineboardSearchTo: 14, + scheduleSearchFrom: 30, scheduleSearchTo: 30, + flightStatusAvailableFromHours: 24, + buyTicketMinHours: 2, buyTicketMaxHours: 72, + loading: false, error: null, + }), +})); + +function makeFlight(flagsOverrides = {}): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + departure: { scheduled: { airportCode: "SVO" } as never, latest: {} as never, times: { scheduledDeparture: { utc: "2026-04-20T10:00:00Z" } as never } as never, dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "" } as never, + arrival: { scheduled: { airportCode: "LED" } as never, latest: {} as never, times: { scheduledArrival: {} as never } as never, dispatch: "", gate: "", terminal: "" } as never, + updated: "2026-04-17T10:00:00Z", + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false, ...flagsOverrides }, + } as never, + } as ISimpleFlight; +} + +describe("BoardDetailsHeader", () => { + it("renders badge, actions, last-update", () => { + render(); + expect(screen.getByTestId("operator-logo")).toBeTruthy(); + expect(screen.getByTestId("flight-actions")).toBeTruthy(); + expect(screen.getByTestId("last-update-timestamp")).toBeTruthy(); + }); + + it("renders change-route event when leg.flags.routeChanged=true", () => { + render(); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + }); + + it("renders reroute event when leg.flags.returnToAirport=true", () => { + render(); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + }); + + it("has data-testid=board-details-header", () => { + render(); + expect(screen.getByTestId("board-details-header")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `BoardDetailsHeader.scss`** + +```scss +// BoardDetailsHeader.scss +.board-details-header { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 16px; + padding: 24px; + background: #fff; + border-radius: 8px; + + &__badge { grid-column: 1; } + &__actions-row { grid-column: 2; display: flex; justify-content: flex-end; } + &__events-row { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + padding: 16px; + } +} + +.details-header-badge { + display: flex; + align-items: center; + gap: 12px; + + &__flight-number { + display: flex; + flex-direction: column; + } + + &__primary { + font-size: 24px; + font-weight: 600; + color: #1a3a5c; + } + + &__codesharing { + font-size: 12px; + color: #666; + margin-top: 2px; + } +} + +.flight-events { + display: flex; + gap: 12px; + align-items: center; + + &__event { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid #ddd; + border-radius: 14px; + height: 28px; + font-size: 12px; + } +} + +.last-update { + display: flex; + align-items: center; + gap: 8px; + + &__description { + font-size: 12px; + color: #666; + } + + .share-button-wrap { + @media (min-width: 769px) { + display: none; + } + } +} + +.share-button-wrap { + position: relative; + + .share-panel { + position: absolute; + top: 100%; + right: 0; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + min-width: 140px; + z-index: 10; + + a, button { + padding: 6px 10px; + font-size: 14px; + color: #1a3a5c; + text-decoration: none; + background: none; + border: none; + text-align: left; + cursor: pointer; + + &:hover { + background: #f0f4f8; + } + } + } +} + +.flight-actions { + display: flex; + gap: 8px; + align-items: center; +} +``` + +- [ ] **Step 4: Create `BoardDetailsHeader.tsx`** + +```tsx +import type { FC } from "react"; +import type { ISimpleFlight, IFlightLeg } from "../../types.js"; +import { DetailsHeaderBadge } from "./DetailsHeaderBadge.js"; +import { FlightActions } from "./FlightActions.js"; +import { FlightEvents } from "./FlightEvents.js"; +import { LastUpdate } from "./LastUpdate.js"; +import "./BoardDetailsHeader.scss"; + +export interface BoardDetailsHeaderProps { + flight: ISimpleFlight; + locale: string; +} + +function getLegs(flight: ISimpleFlight): IFlightLeg[] { + if (flight.routeType === "Direct") return [flight.leg]; + return flight.legs; +} + +function anyLegFlag(flight: ISimpleFlight, key: "routeChanged" | "returnToAirport"): boolean { + return getLegs(flight).some((l) => l.flags[key]); +} + +export const BoardDetailsHeader: FC = ({ flight, locale }) => { + const changeRoute = anyLegFlag(flight, "routeChanged"); + const reroute = anyLegFlag(flight, "returnToAirport"); + + return ( +
+
+ +
+
+ +
+
+ + +
+
+ ); +}; +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { BoardDetailsHeader } from "./BoardDetailsHeader.js"; +export type { BoardDetailsHeaderProps } from "./BoardDetailsHeader.js"; +``` + +- [ ] **Step 6: Verify pass + commit** + +Run: `pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx` + +Expected: 4 tests pass. + +```bash +git add src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx src/features/online-board/components/BoardDetailsHeader/index.ts +git commit -m "Add BoardDetailsHeader orchestrator component" +``` + +--- + +### Task 21: Wire into `OnlineBoardDetailsPage` + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +- [ ] **Step 1: Add integration test** + +Append to the outer describe block in `OnlineBoardDetailsPage.test.tsx`: + +```tsx +describe("board details header integration", () => { + it("renders BoardDetailsHeader at top of details", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.getByTestId("board-details-header")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: the new test fails. + +- [ ] **Step 3: Wire in the component** + +Edit `src/features/online-board/components/OnlineBoardDetailsPage.tsx`. Add import near other component imports: + +```tsx +import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js"; +``` + +Inside the main return (happy path, after `
` opens), replace the existing `
` block with: + +```tsx + +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: all tests pass. + +- [ ] **Step 5: Full suite + typecheck** + +Run: `pnpm test` +Run: `pnpm typecheck` + +Expected: no new failures or type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +git commit -m "Wire BoardDetailsHeader into OnlineBoardDetailsPage" +``` + +--- + +### Task 22: Manual Browser Verification + +**Files:** None — observational. + +- [ ] **Step 1: Ensure dev:full is running** + +Run `pnpm dev:full`. Verify `/api/appSettings` returns 200. + +- [ ] **Step 2: Run Playwright verification script** + +```bash +cat << 'SCRIPT' | npx tsx --input-type=module - +import { chromium } from "@playwright/test"; +import { mockAngularAPIs } from "./tests/e2e-angular/support/angular-api-mock.js"; + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); +await mockAngularAPIs(page); + +// Mock a flight far enough in the future to trigger Buy Ticket visibility +const depUtc = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); +await page.route("**/onlineboard/details*", (route) => { + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + data: { + partners: [], + routes: [{ + id: "SU0022-X", routeType: "Direct", flyingTime: "1h 30m", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260418", dateLT: "20260418" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + index: 0, flyingTime: "1h 30m", status: "Scheduled", updated: "2026-04-17T10:00:00Z", dayChange: 0, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + departure: { + scheduled: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: 3, utc: depUtc } }, + }, + arrival: { + scheduled: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" }, + latest: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "" } }, + }, + equipment: {}, + }, + }], + daysOfFlight: ["20260418"], + }, + }), + }); +}); + +await page.goto("http://localhost:8080/ru/onlineboard/SU0022-20260418", { waitUntil: "networkidle" }); +await page.waitForTimeout(3000); + +const header = await page.locator('[data-testid="board-details-header"]').count(); +const logo = await page.locator('[data-testid="operator-logo"]').count(); +const actions = await page.locator('[data-testid="flight-actions"]').count(); +const buyBtn = await page.locator('[data-testid="buy-ticket-button"]').count(); +const lastUpdate = await page.locator('[data-testid="last-update-timestamp"]').count(); + +console.log("header:", header, "logo:", logo, "actions:", actions, "buyBtn:", buyBtn, "lastUpdate:", lastUpdate); + +// Click Share → panel appears +await page.locator('[data-testid="share-button"]').first().click(); +await page.waitForTimeout(500); +const panel = await page.locator('[data-testid="share-panel"]').count(); +console.log("share panel open:", panel); + +await page.screenshot({ path: "/tmp/b4-header-verify.png", fullPage: true }); +console.log("Screenshot: /tmp/b4-header-verify.png"); + +await browser.close(); +SCRIPT +``` + +Expected: `header: 1, logo: 1, actions: 1, buyBtn: 1, lastUpdate: 1, share panel open: 1`. + +- [ ] **Step 3: No commit — observational**