91 KiB
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_STATUSsetvisibility/buyTicketVisibility.tsvisibility/registrationVisibility.tsvisibility/flightStatusVisibility.tsOperatorLogo.tsx+OperatorLogo.scssFlightEvents.tsx+ inline SVG iconsLastUpdate.tsxBuyTicketButton.tsxRegistrationButton.tsxFlightStatusButton.tsxShareButton.tsx+SharePanel.tsxPrintButton.tsxDetailsHeaderBadge.tsxFlightActions.tsxBoardDetailsHeader.tsx+BoardDetailsHeader.scssactions.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— adddate-fnssrc/shared/hooks/useAppSettings.ts— addflightStatusAvailableFromHours,buyTicketMinHours,buyTicketMaxHourssrc/shared/hooks/useAppSettings.test.ts— add buttons config testssrc/features/online-board/components/OnlineBoardDetailsPage.tsx— replace inline header with<BoardDetailsHeader>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:
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
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(extendAppSettingsResponsetype) -
Step 1: Extend
AppSettingsResponsetype
Edit src/shared/api/appSettings.ts. Replace the AppSettingsResponse interface:
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):
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:
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<Omit<UseAppSettingsResult, "loading" | "error">>(DEFAULTS);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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
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
// 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
export interface AirlineConfig {
name: string;
registrationUrl?: string;
hasNativeStatus: boolean;
statusUrl?: string;
}
export const AIRLINES: Record<string, AirlineConfig> = {
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.
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
// 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 (<minHours before)", () => {
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
// 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.
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
// 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
// registrationVisibility.ts
import type { ISimpleFlight } from "../../../types.js";
import type { AirlineConfig } from "../airlines.js";
export function canRegister(
flight: ISimpleFlight,
airlineConfig: Record<string, AirlineConfig>,
): 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.
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
// 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
// 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<string>,
): 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.
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:
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.scsswith 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:
$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
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
// @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(<OperatorLogo flight={makeFlight("SU")} locale="en" />);
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(<OperatorLogo flight={makeFlight("SU")} locale="ru" />);
const el = screen.getByTestId("operator-logo");
expect(el.className).toContain("ru");
});
it("adds large class when large=true", () => {
render(<OperatorLogo flight={makeFlight("SU")} locale="en" large />);
const el = screen.getByTestId("operator-logo");
expect(el.className).toContain("large");
});
it("adds round class when round=true", () => {
render(<OperatorLogo flight={makeFlight("SU")} locale="en" round />);
const el = screen.getByTestId("operator-logo");
expect(el.className).toContain("round");
});
it("renders caption when caption=true", () => {
render(<OperatorLogo flight={makeFlight("SU")} locale="en" caption />);
expect(screen.getByText("SHARED.AVIACOMPANY")).toBeTruthy();
});
it("does not render caption by default", () => {
render(<OperatorLogo flight={makeFlight("SU")} locale="en" />);
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
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<OperatorLogoProps> = ({
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 (
<div>
{caption && <div className="operator-logo__caption">{t("SHARED.AVIACOMPANY")}</div>}
<div
className={classes}
data-testid="operator-logo"
title={carrier}
/>
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx
Expected: 6 tests pass.
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
// 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
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
// @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<typeof vi.fn>;
beforeEach(() => {
openSpy = vi.fn();
Object.defineProperty(window, "open", { value: openSpy, writable: true });
});
it("renders the translated label", () => {
render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
expect(screen.getByText("SHARED.BUY-TICKET")).toBeTruthy();
});
it("has data-testid=buy-ticket-button", () => {
render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
expect(screen.getByTestId("buy-ticket-button")).toBeTruthy();
});
it("opens the Aeroflot booking URL on click", () => {
render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
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
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<BuyTicketButtonProps> = ({ flight, locale }) => {
const { t } = useTranslation();
const handleClick = () => {
const url = buildBuyTicketUrl(flight, locale);
window.open(url, "_blank", "noopener,noreferrer");
};
return (
<button
type="button"
className="flight-action-btn flight-action-btn--orange"
data-testid="buy-ticket-button"
onClick={handleClick}
>
{t("SHARED.BUY-TICKET")}
</button>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx
Expected: 3 tests pass.
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
// @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<typeof vi.fn>;
beforeEach(() => {
openSpy = vi.fn();
Object.defineProperty(window, "open", { value: openSpy, writable: true });
});
it("renders label", () => {
render(<RegistrationButton flight={makeFlight("SU")} />);
expect(screen.getByText("SHARED.ONLINE-REGISTRATION")).toBeTruthy();
});
it("has data-testid", () => {
render(<RegistrationButton flight={makeFlight("SU")} />);
expect(screen.getByTestId("registration-button")).toBeTruthy();
});
it("opens aeroflot.ru registration URL for SU", () => {
render(<RegistrationButton flight={makeFlight("SU")} />);
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(<RegistrationButton flight={makeFlight("AF")} />);
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
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<RegistrationButtonProps> = ({ 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 (
<button
type="button"
className="flight-action-btn flight-action-btn--blue-light"
data-testid="registration-button"
onClick={handleClick}
>
{t("SHARED.ONLINE-REGISTRATION")}
</button>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx
Expected: 4 tests pass.
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
// @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<typeof vi.fn>;
beforeEach(() => {
openSpy = vi.fn();
Object.defineProperty(window, "open", { value: openSpy, writable: true });
});
it("renders label", () => {
render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
expect(screen.getByText("SHARED.DETAILS")).toBeTruthy();
});
it("has data-testid", () => {
render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
expect(screen.getByTestId("flight-status-button")).toBeTruthy();
});
it("opens native details URL for SU", () => {
render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
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(<FlightStatusButton flight={makeFlight("HZ")} locale="ru" />);
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(<FlightStatusButton flight={makeFlight("SU")} locale="ru" small />);
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
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<FlightStatusButtonProps> = ({ 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 (
<button
type="button"
className={classes}
data-testid="flight-status-button"
title={t("SHARED.DETAILS-TOOLTIP")}
onClick={handleClick}
>
{t("SHARED.DETAILS")}
</button>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx
Expected: 5 tests pass.
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
// @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(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
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(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
expect(screen.queryByTestId("share-weibo")).toBeNull();
});
it("renders Weibo for zh locale", () => {
render(<SharePanel url="https://example.com/flight" locale="zh" onClose={() => {}} />);
expect(screen.getByTestId("share-weibo")).toBeTruthy();
});
it("Facebook link points to sharer.php with encoded URL", () => {
render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
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(<SharePanel url="https://example.com/flight" locale="en" onClose={onClose} />);
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
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<SharePanelProps> = ({ 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 (
<div className="share-panel" data-testid="share-panel">
<a
data-testid="share-facebook"
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
Facebook
</a>
<a
data-testid="share-vk"
href={`https://vk.com/share.php?url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
VK
</a>
<a
data-testid="share-twitter"
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
Twitter
</a>
{locale === "zh" && (
<a
data-testid="share-weibo"
href={`https://service.weibo.com/share/share.php?url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
Weibo
</a>
)}
<button
type="button"
data-testid="share-copy"
onClick={handleCopy}
>
{t("SHARED.COPY") || "Copy"}
</button>
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx
Expected: 5 tests pass.
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
// @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(<ShareButton url="https://example.com" locale="en" />);
expect(screen.getByTestId("share-button")).toBeTruthy();
});
it("panel is closed by default", () => {
render(<ShareButton url="https://example.com" locale="en" />);
expect(screen.queryByTestId("share-panel")).toBeNull();
});
it("opens panel on click", () => {
render(<ShareButton url="https://example.com" locale="en" />);
fireEvent.click(screen.getByTestId("share-button"));
expect(screen.getByTestId("share-panel")).toBeTruthy();
});
it("closes panel when clicked again", () => {
render(<ShareButton url="https://example.com" locale="en" />);
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
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<ShareButtonProps> = ({ url, locale }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="share-button-wrap">
<button
type="button"
className="flight-action-btn flight-action-btn--transparent"
data-testid="share-button"
title={t("BOARD.SHARE")}
onClick={() => setOpen((v) => !v)}
aria-label={t("BOARD.SHARE")}
>
{/* Inline share icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{open && <SharePanel url={url} locale={locale} onClose={() => setOpen(false)} />}
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx
Expected: 4 tests pass.
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).
// 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<PrintButtonProps> = () => {
return (
<a
className="flight-action-btn flight-action-btn--transparent"
data-testid="print-button"
href=""
aria-label="Print"
>
{/* Inline print icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
</a>
);
};
- Step 2: Commit
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
// @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(<FlightActions flight={makeFlight()} locale="ru" />);
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(<FlightActions flight={makeFlight()} locale="ru" showStatus={false} />);
expect(screen.queryByTestId("flight-status-button")).toBeNull();
});
it("shows status button when showStatus=true", () => {
render(<FlightActions flight={makeFlight()} locale="ru" showStatus={true} />);
expect(screen.getByTestId("flight-status-button")).toBeTruthy();
});
it("hides share button when showShare=false", () => {
render(<FlightActions flight={makeFlight()} locale="ru" showShare={false} />);
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(<FlightActions flight={makeFlight()} locale="ru" />);
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
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<FlightActionsProps> = ({
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 (
<div className="flight-actions" data-testid="flight-actions">
{showPrint && <PrintButton flight={flight} />}
{showShare && <ShareButton url={shareUrl} locale={locale} />}
{canBuy && <BuyTicketButton flight={flight} locale={locale} />}
{canReg && <RegistrationButton flight={flight} />}
{canStatus && <FlightStatusButton flight={flight} locale={locale} />}
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx
Expected: 5 tests pass.
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
// @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(<DetailsHeaderBadge flight={makeDirect("SU", "0022")} locale="ru" />);
expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
});
it("renders OperatorLogo", () => {
render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
expect(screen.getByTestId("operator-logo")).toBeTruthy();
});
it("renders codesharing list for multi-leg flight with distinct carriers", () => {
render(<DetailsHeaderBadge flight={makeMultiLeg()} locale="ru" />);
// Shows "AF 209, UA 456" or similar
expect(screen.getByTestId("codesharing")).toBeTruthy();
});
it("does not render codesharing for a direct flight", () => {
render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
expect(screen.queryByTestId("codesharing")).toBeNull();
});
it("renders small status button when showStatus=true", () => {
render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" showStatus />);
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
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<DetailsHeaderBadgeProps> = ({
flight,
locale,
large = true,
round = false,
showStatus = false,
}) => {
const codeshareLegs = getCodeshareLegs(flight);
const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
return (
<div className="details-header-badge">
<div className="details-header-badge__flight-number">
<div className="details-header-badge__primary">{primaryNumber}</div>
{codeshareLegs.length > 0 && (
<div className="details-header-badge__codesharing" data-testid="codesharing">
{codeshareLegs
.map((l) => `${l.operatingBy.carrier} ${l.operatingBy.flightNumber}`)
.join(", ")}
</div>
)}
</div>
<OperatorLogo flight={flight} locale={locale} large={large} round={round} />
{showStatus && <FlightStatusButton flight={flight} locale={locale} small />}
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx
Expected: 5 tests pass.
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
// @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(<FlightEvents changeRoute={false} reroute={false} />);
expect(container.firstChild).toBeNull();
});
it("renders route change icon when changeRoute=true", () => {
render(<FlightEvents changeRoute={true} reroute={false} showDescription />);
expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
expect(screen.getByText("SHARED.ROUTE-CHANGE")).toBeTruthy();
});
it("renders reroute icon when reroute=true", () => {
render(<FlightEvents changeRoute={false} reroute={true} showDescription />);
expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
expect(screen.getByText("SHARED.RETURN")).toBeTruthy();
});
it("renders both when both flags are set", () => {
render(<FlightEvents changeRoute={true} reroute={true} showDescription />);
expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
});
it("hides descriptions when showDescription=false", () => {
render(<FlightEvents changeRoute={true} reroute={true} />);
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
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
export interface FlightEventsProps {
changeRoute: boolean;
reroute: boolean;
showDescription?: boolean;
}
export const FlightEvents: FC<FlightEventsProps> = ({ changeRoute, reroute, showDescription }) => {
const { t } = useTranslation();
if (!changeRoute && !reroute) return null;
return (
<div className="flight-events">
{changeRoute && (
<div className="flight-events__event" data-testid="flight-event-change-route">
{/* Change icon (ported from Angular) */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="12" viewBox="0 0 20 12" className="change-route-svg">
<g fill="none" stroke="#f37b09" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.356 3.497H.5" />
<path d="M3.29 6.286.5 3.496 3.29.707" />
<path d="M6.864 9.076H18.72" />
<path d="m15.93 6.286 2.79 2.79-2.79 2.79" />
</g>
</svg>
{showDescription && (
<span className="flight-events__description">{t("SHARED.ROUTE-CHANGE")}</span>
)}
</div>
)}
{reroute && (
<div className="flight-events__event" data-testid="flight-event-reroute">
{/* Return icon (ported from Angular) */}
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="6" viewBox="0 0 13 6" className="return-svg">
<path
d="M6.125.583A6.1 6.1 0 0 0 2.1 2.1L0 0v5.25h5.25L3.138 3.138a4.65 4.65 0 0 1 7.42 2.112l1.382-.455A6.133 6.133 0 0 0 6.125.583Z"
fill="#c8102e"
/>
</svg>
{showDescription && (
<span className="flight-events__description">{t("SHARED.RETURN")}</span>
)}
</div>
)}
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx
Expected: 5 tests pass.
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
// @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(<LastUpdate flight={makeFlight("2026-04-17T10:30:00Z")} locale="en" />);
expect(screen.getByText(/SHARED\.LAST-UPDATE/)).toBeTruthy();
});
it("renders timestamp in HH:mm DD.MM.YYYY format", () => {
render(<LastUpdate flight={makeFlight("2026-04-17T10:30:00Z")} locale="en" />);
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(<LastUpdate flight={makeFlight("")} locale="en" />);
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
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<LastUpdateProps> = ({ flight, locale }) => {
const { t } = useTranslation();
const timestamp = formatUpdated(getUpdated(flight));
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
return (
<div className="last-update">
<ShareButton url={shareUrl} locale={locale} />
<span className="last-update__description">
<span>{t("SHARED.LAST-UPDATE")}:</span>
<span className="last-update__time" data-testid="last-update-timestamp"> {timestamp}</span>
</span>
</div>
);
};
- Step 4: Verify pass + commit
Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx
Expected: 3 tests pass.
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
// @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(<BoardDetailsHeader flight={makeFlight()} locale="ru" />);
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(<BoardDetailsHeader flight={makeFlight({ routeChanged: true })} locale="ru" />);
expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
});
it("renders reroute event when leg.flags.returnToAirport=true", () => {
render(<BoardDetailsHeader flight={makeFlight({ returnToAirport: true })} locale="ru" />);
expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
});
it("has data-testid=board-details-header", () => {
render(<BoardDetailsHeader flight={makeFlight()} locale="ru" />);
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
// 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
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<BoardDetailsHeaderProps> = ({ flight, locale }) => {
const changeRoute = anyLegFlag(flight, "routeChanged");
const reroute = anyLegFlag(flight, "returnToAirport");
return (
<div className="board-details-header" data-testid="board-details-header">
<div className="board-details-header__badge">
<DetailsHeaderBadge flight={flight} locale={locale} large />
</div>
<div className="board-details-header__actions-row">
<FlightActions flight={flight} locale={locale} />
</div>
<div className="board-details-header__events-row">
<FlightEvents changeRoute={changeRoute} reroute={reroute} showDescription />
<LastUpdate flight={flight} locale={locale} />
</div>
</div>
);
};
- Step 5: Create
index.ts
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.
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:
describe("board details header integration", () => {
it("renders BoardDetailsHeader at top of details", () => {
mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null };
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
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:
import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js";
Inside the main return (happy path, after <div className="flight-details"> opens), replace the existing <div className="flight-details__header"> block with:
<BoardDetailsHeader flight={displayFlight} locale={locale} />
- 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
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
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