diff --git a/docs/superpowers/plans/2026-04-16-flight-details-accordion.md b/docs/superpowers/plans/2026-04-16-flight-details-accordion.md new file mode 100644 index 00000000..e5ad9249 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-flight-details-accordion.md @@ -0,0 +1,1890 @@ +# Flight Details Accordion (B.1) 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 per-leg details accordion on the React Online Board details page — six conditionally-rendered panels (Registration, Boarding, Deboarding, Aircraft, Meal, On-board Services) matching Angular's structure. + +**Architecture:** Extend `src/features/online-board/types.ts` with Angular-matching shapes for `transition` and full `equipment`. Add seven new components in `src/features/online-board/components/details-panels/`: one container + six panel components. Wire the container into `OnlineBoardDetailsPage.tsx` per leg. Each panel is self-contained and independently testable. + +**Tech Stack:** React 18, TypeScript, PrimeReact `Accordion` (already in `package.json`), Vitest + React Testing Library for unit tests. i18next for translation keys. + +--- + +## File Structure + +### New files (all under `src/features/online-board/components/details-panels/`) + +- `shared.ts` — Visibility helpers, service icon mapping, meal link constants +- `FlightDetailsAccordion.tsx` — Container component orchestrating the 6 panels +- `FlightDetailsAccordion.scss` — Container styling +- `FlightDetailsAccordion.test.tsx` +- `RegistrationPanel.tsx` — Registration transition display +- `RegistrationPanel.test.tsx` +- `BoardingPanel.tsx` — Boarding transition display +- `BoardingPanel.test.tsx` +- `DeboardingPanel.tsx` — Deboarding transition + arrival gate/belt +- `DeboardingPanel.test.tsx` +- `AircraftPanel.tsx` — Aircraft type, configuration +- `AircraftPanel.test.tsx` +- `MealPanel.tsx` — Meal-type icons with aeroflot.ru links +- `MealPanel.test.tsx` +- `ServicesPanel.tsx` — On-board service icons +- `ServicesPanel.test.tsx` +- `panels.scss` — Shared panel styling (imported by each panel's SCSS) + +### Assets to copy + +From `ClientApp/src/assets/img/service-and-food-icons/` to `src/features/online-board/components/details-panels/icons/`: +- `shopping.svg`, `space.svg`, `taxi.svg`, `wifi.svg`, `gsm.svg`, `entertaintment.svg`, `seat_reservation.svg`, `comfort-plus.svg` (fallback) +- `econom.svg`, `comfort.svg`, `business.svg` (meal type icons) + +### Modified files + +- `src/features/online-board/types.ts` — Add transition, equipment, meal, service types +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — Wire `` per leg +- `src/i18n/locales/en/common.json` — Add DETAILS.* keys +- `src/i18n/locales/ru/common.json` — Add DETAILS.* keys + +--- + +### Task 1: Extend Types for Transition and Full Equipment + +**Files:** +- Modify: `src/features/online-board/types.ts:103-114` (IFlightLeg) + +- [ ] **Step 1: Write the failing test** + +Create `src/features/online-board/types.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import type { + IFlightLeg, + IFlightTransitions, + IFlightTransitionItem, + FlightTransitionStatus, + IMealItem, + MealType, + IOnBoardService, + IAircraftInfo, + IEquipmentFull, +} from "./types.js"; + +describe("online-board types extension", () => { + it("IFlightTransitionItem has start/end/status/isActual", () => { + const item: IFlightTransitionItem = { + start: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: "+03:00", utc: "07:00" }, + end: { dayChange: { value: 0, title: "" }, local: "10:30", localTime: "10:30", tzOffset: "+03:00", utc: "07:30" }, + status: "InProgress", + isActual: true, + }; + expect(item.status).toBe("InProgress"); + expect(item.isActual).toBe(true); + }); + + it("FlightTransitionStatus accepts all 5 values", () => { + const statuses: FlightTransitionStatus[] = [ + "Finished", "Expected", "InProgress", "Specified", "Scheduled", + ]; + expect(statuses).toHaveLength(5); + }); + + it("IFlightTransitions has optional registration/boarding/deboarding", () => { + const t: IFlightTransitions = {}; + expect(t.registration).toBeUndefined(); + expect(t.boarding).toBeUndefined(); + expect(t.deboarding).toBeUndefined(); + }); + + it("MealType accepts Economy/Comfort/Business/Special", () => { + const meals: MealType[] = ["Economy", "Comfort", "Business", "Special"]; + expect(meals).toHaveLength(4); + }); + + it("IMealItem has type field", () => { + const m: IMealItem = { type: "Economy" }; + expect(m.type).toBe("Economy"); + }); + + it("IOnBoardService has id, optional title, optional url", () => { + const svc: IOnBoardService = { id: 4 }; + expect(svc.id).toBe(4); + expect(svc.title).toBeUndefined(); + expect(svc.url).toBeUndefined(); + }); + + it("IAircraftInfo has optional title and onBoardServices", () => { + const a: IAircraftInfo = { title: "Boeing 737", onBoardServices: [{ id: 1 }] }; + expect(a.title).toBe("Boeing 737"); + expect(a.onBoardServices).toHaveLength(1); + }); + + it("IEquipmentFull nests aircraft.scheduled/actual/configuration and meal[]", () => { + const eq: IEquipmentFull = { + aircraft: { + scheduled: { title: "A320" }, + actual: { title: "A321", onBoardServices: [] }, + configuration: "C12Y138", + }, + meal: [{ type: "Business" }], + }; + expect(eq.aircraft?.scheduled?.title).toBe("A320"); + expect(eq.aircraft?.actual?.title).toBe("A321"); + expect(eq.aircraft?.configuration).toBe("C12Y138"); + expect(eq.meal?.[0]?.type).toBe("Business"); + }); + + it("IFlightLeg includes transition and extended equipment", () => { + const leg: Partial = { + transition: { registration: { start: {} as never, end: {} as never, status: "Finished", isActual: true } }, + equipment: { name: "A320", aircraft: { actual: { title: "A320" } } }, + }; + expect(leg.transition?.registration?.status).toBe("Finished"); + expect(leg.equipment?.aircraft?.actual?.title).toBe("A320"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: FAIL — types `IFlightTransitions`, `IFlightTransitionItem`, `FlightTransitionStatus`, `MealType`, `IMealItem`, `IOnBoardService`, `IAircraftInfo`, `IEquipmentFull` not exported. + +- [ ] **Step 3: Extend `src/features/online-board/types.ts`** + +Add these exports after the existing `IFlightLegFlags` interface (after line 101). The file already imports `ITimesSet`: + +```typescript +// --------------------------------------------------------------------------- +// Transition (registration/boarding/deboarding) +// --------------------------------------------------------------------------- + +export type FlightTransitionStatus = + | "Finished" + | "Expected" + | "InProgress" + | "Specified" + | "Scheduled"; + +export interface IFlightTransitionItem { + end: ITimesSet; + start: ITimesSet; + status: FlightTransitionStatus; + isActual: boolean; +} + +export interface IFlightTransitions { + registration?: IFlightTransitionItem; + boarding?: IFlightTransitionItem; + deboarding?: IFlightTransitionItem; +} + +// --------------------------------------------------------------------------- +// Equipment (aircraft + meal + on-board services) +// --------------------------------------------------------------------------- + +export type MealType = "Economy" | "Comfort" | "Business" | "Special"; + +export interface IMealItem { + type: MealType; +} + +export interface IOnBoardService { + id: number; + title?: string; + url?: string; +} + +export interface IAircraftInfo { + title?: string; + onBoardServices?: IOnBoardService[]; +} + +export interface IEquipmentFull { + aircraft?: { + scheduled?: IAircraftInfo; + actual?: IAircraftInfo; + configuration?: string; + }; + meal?: IMealItem[]; +} +``` + +Then replace the existing `IFlightLeg` interface (lines 103-114) with: + +```typescript +export interface IFlightLeg { + arrival: IFlightLegArrivalStation; + dayChange: number; + departure: IFlightLegDepartureStation; + equipment: { name?: string; code?: string } & IEquipmentFull; + flags: IFlightLegFlags; + flyingTime: string; + index: number; + operatingBy: { carrier?: string; flightNumber?: string }; + status: FlightStatus; + updated: string; + transition?: IFlightTransitions; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: PASS — all 8 type assertions pass. + +- [ ] **Step 5: Verify no regressions** + +Run: `pnpm typecheck` + +Expected: No new type errors. Existing code that uses `equipment.name`/`equipment.code` still compiles (they're preserved in the intersection). + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/types.ts src/features/online-board/types.test.ts +git commit -m "Extend IFlightLeg with transition and full equipment types" +``` + +--- + +### Task 2: Shared Helpers + i18n Keys + +**Files:** +- Create: `src/features/online-board/components/details-panels/shared.ts` +- Create: `src/features/online-board/components/details-panels/shared.test.ts` +- Modify: `src/i18n/locales/en/common.json` +- Modify: `src/i18n/locales/ru/common.json` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/shared.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { + shouldShowTransition, + shouldShowAircraft, + SERVICE_ICON_MAP, + MEAL_LINKS, +} from "./shared.js"; +import type { IFlightTransitionItem } from "../../types.js"; + +describe("shouldShowTransition", () => { + const validItem: IFlightTransitionItem = { + start: {} as never, + end: {} as never, + status: "InProgress", + isActual: true, + }; + + it("returns false for Schedule viewType", () => { + expect(shouldShowTransition(validItem, "Scheduled", "Schedule")).toBe(false); + }); + + it("returns false when leg is Cancelled", () => { + expect(shouldShowTransition(validItem, "Cancelled", "Onlineboard")).toBe(false); + }); + + it("returns false when item is undefined", () => { + expect(shouldShowTransition(undefined, "Scheduled", "Onlineboard")).toBe(false); + }); + + it("returns false when item.status is Scheduled", () => { + const scheduled = { ...validItem, status: "Scheduled" as const }; + expect(shouldShowTransition(scheduled, "Scheduled", "Onlineboard")).toBe(false); + }); + + it("returns true for active transition on Onlineboard", () => { + expect(shouldShowTransition(validItem, "Scheduled", "Onlineboard")).toBe(true); + }); +}); + +describe("shouldShowAircraft", () => { + it("returns true when actual.title exists", () => { + expect(shouldShowAircraft({ aircraft: { actual: { title: "A320" } } })).toBe(true); + }); + + it("returns true when scheduled.title exists", () => { + expect(shouldShowAircraft({ aircraft: { scheduled: { title: "A320" } } })).toBe(true); + }); + + it("returns true when configuration exists", () => { + expect(shouldShowAircraft({ aircraft: { configuration: "C12Y138" } })).toBe(true); + }); + + it("returns false when no aircraft info", () => { + expect(shouldShowAircraft({})).toBe(false); + expect(shouldShowAircraft({ aircraft: {} })).toBe(false); + }); + + it("returns false when titles are empty strings", () => { + expect(shouldShowAircraft({ aircraft: { actual: { title: "" }, scheduled: { title: "" } } })).toBe(false); + }); +}); + +describe("SERVICE_ICON_MAP", () => { + it("maps all 8 service IDs", () => { + expect(SERVICE_ICON_MAP[1]).toBe("shopping"); + expect(SERVICE_ICON_MAP[2]).toBe("space"); + expect(SERVICE_ICON_MAP[3]).toBe("taxi"); + expect(SERVICE_ICON_MAP[4]).toBe("wifi"); + expect(SERVICE_ICON_MAP[5]).toBe("gsm"); + expect(SERVICE_ICON_MAP[6]).toBe("entertaintment"); + expect(SERVICE_ICON_MAP[7]).toBe("entertaintment"); + expect(SERVICE_ICON_MAP[8]).toBe("seat_reservation"); + }); +}); + +describe("MEAL_LINKS", () => { + it("has links for Economy, Comfort, Business", () => { + expect(MEAL_LINKS.Economy).toContain("meal-type_0"); + expect(MEAL_LINKS.Comfort).toContain("meal-type_1"); + expect(MEAL_LINKS.Business).toContain("meal-type_2"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/shared.test.ts` + +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Create the shared helpers file** + +Create `src/features/online-board/components/details-panels/shared.ts`: + +```typescript +import type { + FlightStatus, + IEquipmentFull, + IFlightTransitionItem, + MealType, +} from "../../types.js"; + +export type DetailsViewType = "Onlineboard" | "Schedule"; + +/** + * Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts. + * Transition panels are hidden for Schedule mode, Cancelled flights, + * missing data, or when the transition hasn't started. + */ +export function shouldShowTransition( + item: IFlightTransitionItem | undefined, + legStatus: FlightStatus, + viewType: DetailsViewType, +): boolean { + if (viewType === "Schedule") return false; + if (legStatus === "Cancelled") return false; + if (!item) return false; + if (item.status === "Scheduled") return false; + return true; +} + +/** + * Matches Angular's `showAircraft` getter. Shows when actual or scheduled + * title exists, or when configuration string is present. + */ +export function shouldShowAircraft(equipment: Pick): boolean { + const aircraft = equipment.aircraft; + if (!aircraft) return false; + const title = aircraft.actual?.title || aircraft.scheduled?.title; + if (title) return true; + if (aircraft.configuration) return true; + return false; +} + +/** + * Service ID → icon filename mapping. + * From Angular flight-details-services.component.ts. + * IDs 6 and 7 both use the entertaintment icon (typo in original is preserved + * because that's how Angular ships the filename). + */ +export const SERVICE_ICON_MAP: Record = { + 1: "shopping", + 2: "space", + 3: "taxi", + 4: "wifi", + 5: "gsm", + 6: "entertaintment", + 7: "entertaintment", + 8: "seat_reservation", +}; + +/** Fallback icon for unknown service IDs. */ +export const SERVICE_ICON_FALLBACK = "comfort-plus"; + +/** + * Meal type → aeroflot.ru info page link. + * From Angular flight-details-meal.component.ts. + */ +export const MEAL_LINKS: Record, string> = { + Economy: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_0", + Comfort: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_1", + Business: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_2", +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/shared.test.ts` + +Expected: PASS — all helper tests pass. + +- [ ] **Step 5: Add DETAILS i18n keys (en)** + +Edit `src/i18n/locales/en/common.json`. Find the top-level `"BOARD"` object (around line 9). After the closing `}` of `BOARD` (and before `BOARDING-STATUSES`), add a new top-level `DETAILS` object: + +```json + "DETAILS": { + "REGISTRATION": "Check-in", + "BOARDING": "Boarding", + "DEBOARDING": "Deboarding", + "AIRCRAFT": "Aircraft", + "MEAL": "Meal", + "ON_BOARD_SERVICES": "On-board services", + "SCHEDULED": "Scheduled", + "ACTUAL": "Actual", + "MEAL_ECONOMY": "Economy", + "MEAL_COMFORT": "Comfort", + "MEAL_BUSINESS": "Business", + "STATUS_FINISHED": "Finished", + "STATUS_EXPECTED": "Expected", + "STATUS_IN_PROGRESS": "In progress", + "STATUS_SPECIFIED": "Specified", + "STATUS_SCHEDULED": "Scheduled", + "TERMINAL": "Terminal", + "GATE": "Gate", + "BAG_BELT": "Baggage belt", + "CONFIGURATION": "Configuration" + }, +``` + +- [ ] **Step 6: Add DETAILS i18n keys (ru)** + +Edit `src/i18n/locales/ru/common.json`. Add the same structure with Russian translations: + +```json + "DETAILS": { + "REGISTRATION": "Регистрация", + "BOARDING": "Посадка", + "DEBOARDING": "Высадка", + "AIRCRAFT": "Воздушное судно", + "MEAL": "Питание", + "ON_BOARD_SERVICES": "Услуги на борту", + "SCHEDULED": "По расписанию", + "ACTUAL": "Фактически", + "MEAL_ECONOMY": "Эконом", + "MEAL_COMFORT": "Комфорт", + "MEAL_BUSINESS": "Бизнес", + "STATUS_FINISHED": "Завершена", + "STATUS_EXPECTED": "Ожидается", + "STATUS_IN_PROGRESS": "В процессе", + "STATUS_SPECIFIED": "Назначена", + "STATUS_SCHEDULED": "По расписанию", + "TERMINAL": "Терминал", + "GATE": "Выход", + "BAG_BELT": "Лента выдачи багажа", + "CONFIGURATION": "Компоновка" + }, +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/details-panels/shared.ts src/features/online-board/components/details-panels/shared.test.ts src/i18n/locales/en/common.json src/i18n/locales/ru/common.json +git commit -m "Add shared helpers and DETAILS i18n keys for flight details panels" +``` + +--- + +### Task 3: Copy Service Icon Assets + +**Files:** +- Create: `src/features/online-board/components/details-panels/icons/` (directory with 11 SVGs) + +- [ ] **Step 1: Create icons directory and copy SVGs** + +Run: + +```bash +mkdir -p src/features/online-board/components/details-panels/icons +cp ClientApp/src/assets/img/service-and-food-icons/shopping.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/space.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/taxi.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/wifi.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/gsm.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/entertaintment.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/seat_reservation.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/comfort-plus.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/econom.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/comfort.svg src/features/online-board/components/details-panels/icons/ +cp ClientApp/src/assets/img/service-and-food-icons/business.svg src/features/online-board/components/details-panels/icons/ +``` + +Verify 11 files present: + +```bash +ls src/features/online-board/components/details-panels/icons/ | wc -l +``` + +Expected: `11` + +- [ ] **Step 2: Commit** + +```bash +git add src/features/online-board/components/details-panels/icons/ +git commit -m "Copy service and meal icon SVGs from Angular to React" +``` + +--- + +### Task 4: RegistrationPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/RegistrationPanel.tsx` +- Create: `src/features/online-board/components/details-panels/RegistrationPanel.test.tsx` +- Create: `src/features/online-board/components/details-panels/panels.scss` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/RegistrationPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RegistrationPanel } from "./RegistrationPanel.js"; +import type { IFlightTransitionItem } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +const baseItem: IFlightTransitionItem = { + start: { + dayChange: { value: 0, title: "" }, + local: "10:00", + localTime: "10:00", + tzOffset: "+03:00", + utc: "07:00", + }, + end: { + dayChange: { value: 0, title: "" }, + local: "10:30", + localTime: "10:30", + tzOffset: "+03:00", + utc: "07:30", + }, + status: "InProgress", + isActual: true, +}; + +describe("RegistrationPanel", () => { + it("renders start and end times", () => { + render(); + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.getByText("10:30")).toBeTruthy(); + }); + + it("renders status label", () => { + render(); + expect(screen.getByText("DETAILS.STATUS_IN_PROGRESS")).toBeTruthy(); + }); + + it("renders only start time when end is missing", () => { + const noEnd: IFlightTransitionItem = { + ...baseItem, + end: { + dayChange: { value: 0, title: "" }, + local: "", + localTime: "", + tzOffset: "", + utc: "", + }, + }; + render(); + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.queryByText("10:30")).toBeNull(); + }); + + it("has data-testid for targeting in integration tests", () => { + render(); + expect(screen.getByTestId("registration-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/RegistrationPanel.test.tsx` + +Expected: FAIL — `RegistrationPanel` not defined. + +- [ ] **Step 3: Create panels shared stylesheet** + +Create `src/features/online-board/components/details-panels/panels.scss`: + +```scss +.details-panel { + padding: 12px 0; + font-size: 14px; + line-height: 1.5; +} + +.details-panel__row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid #eee; + + &:last-child { + border-bottom: none; + } +} + +.details-panel__label { + color: #666; +} + +.details-panel__value { + color: #000; + font-weight: 500; +} + +.details-panel__status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background: #f0f4f8; + color: #2060c0; + font-size: 12px; +} + +.details-panel__icons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.details-panel__icon { + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + color: inherit; + + img { + width: 24px; + height: 24px; + } +} +``` + +- [ ] **Step 4: Create RegistrationPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IFlightTransitionItem, FlightTransitionStatus } from "../../types.js"; +import "./panels.scss"; + +const STATUS_KEYS: Record = { + Finished: "DETAILS.STATUS_FINISHED", + Expected: "DETAILS.STATUS_EXPECTED", + InProgress: "DETAILS.STATUS_IN_PROGRESS", + Specified: "DETAILS.STATUS_SPECIFIED", + Scheduled: "DETAILS.STATUS_SCHEDULED", +}; + +export interface RegistrationPanelProps { + item: IFlightTransitionItem; +} + +export const RegistrationPanel: FC = ({ item }) => { + const { t } = useTranslation(); + const hasEnd = Boolean(item.end?.local); + + return ( +
+
+ {t("DETAILS.STATUS")} + {t(STATUS_KEYS[item.status])} +
+
+ {t("DETAILS.SCHEDULED")} + {item.start.local} +
+ {hasEnd && ( +
+ {t("DETAILS.ACTUAL")} + {item.end.local} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/RegistrationPanel.test.tsx` + +Expected: PASS — all 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/details-panels/RegistrationPanel.tsx src/features/online-board/components/details-panels/RegistrationPanel.test.tsx src/features/online-board/components/details-panels/panels.scss +git commit -m "Add RegistrationPanel component for flight details accordion" +``` + +--- + +### Task 5: BoardingPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/BoardingPanel.tsx` +- Create: `src/features/online-board/components/details-panels/BoardingPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/BoardingPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BoardingPanel } from "./BoardingPanel.js"; +import type { IFlightTransitionItem } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +const baseItem: IFlightTransitionItem = { + start: { + dayChange: { value: 0, title: "" }, + local: "11:00", + localTime: "11:00", + tzOffset: "+03:00", + utc: "08:00", + }, + end: { + dayChange: { value: 0, title: "" }, + local: "11:30", + localTime: "11:30", + tzOffset: "+03:00", + utc: "08:30", + }, + status: "Finished", + isActual: true, +}; + +describe("BoardingPanel", () => { + it("renders start and end times", () => { + render(); + expect(screen.getByText("11:00")).toBeTruthy(); + expect(screen.getByText("11:30")).toBeTruthy(); + }); + + it("renders status label", () => { + render(); + expect(screen.getByText("DETAILS.STATUS_FINISHED")).toBeTruthy(); + }); + + it("has data-testid", () => { + render(); + expect(screen.getByTestId("boarding-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/BoardingPanel.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create BoardingPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IFlightTransitionItem, FlightTransitionStatus } from "../../types.js"; +import "./panels.scss"; + +const STATUS_KEYS: Record = { + Finished: "DETAILS.STATUS_FINISHED", + Expected: "DETAILS.STATUS_EXPECTED", + InProgress: "DETAILS.STATUS_IN_PROGRESS", + Specified: "DETAILS.STATUS_SPECIFIED", + Scheduled: "DETAILS.STATUS_SCHEDULED", +}; + +export interface BoardingPanelProps { + item: IFlightTransitionItem; +} + +export const BoardingPanel: FC = ({ item }) => { + const { t } = useTranslation(); + const hasEnd = Boolean(item.end?.local); + + return ( +
+
+ {t("DETAILS.STATUS")} + {t(STATUS_KEYS[item.status])} +
+
+ {t("DETAILS.SCHEDULED")} + {item.start.local} +
+ {hasEnd && ( +
+ {t("DETAILS.ACTUAL")} + {item.end.local} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/BoardingPanel.test.tsx` + +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/details-panels/BoardingPanel.tsx src/features/online-board/components/details-panels/BoardingPanel.test.tsx +git commit -m "Add BoardingPanel component for flight details accordion" +``` + +--- + +### Task 6: DeboardingPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/DeboardingPanel.tsx` +- Create: `src/features/online-board/components/details-panels/DeboardingPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/DeboardingPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DeboardingPanel } from "./DeboardingPanel.js"; +import type { IFlightTransitionItem, IFlightLegArrivalStation } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +const baseItem: IFlightTransitionItem = { + start: { + dayChange: { value: 0, title: "" }, + local: "13:00", + localTime: "13:00", + tzOffset: "+03:00", + utc: "10:00", + }, + end: { + dayChange: { value: 0, title: "" }, + local: "13:30", + localTime: "13:30", + tzOffset: "+03:00", + utc: "10:30", + }, + status: "InProgress", + isActual: true, +}; + +const arrival: IFlightLegArrivalStation = { + scheduled: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + dispatch: "Terminal D", + gate: "B23", + terminal: "D", + bagBelt: "5", + times: { + scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: "", utc: "" }, + }, +}; + +describe("DeboardingPanel", () => { + it("renders transition times and status", () => { + render(); + expect(screen.getByText("13:00")).toBeTruthy(); + expect(screen.getByText("DETAILS.STATUS_IN_PROGRESS")).toBeTruthy(); + }); + + it("renders terminal, gate, baggage belt from arrival", () => { + render(); + expect(screen.getByText("D")).toBeTruthy(); + expect(screen.getByText("5")).toBeTruthy(); + }); + + it("omits terminal row when arrival has no terminal", () => { + const noTerminal = { ...arrival, terminal: undefined }; + render(); + expect(screen.queryByText("DETAILS.TERMINAL")).toBeNull(); + }); + + it("omits bag belt row when arrival has no bagBelt", () => { + const noBelt = { ...arrival, bagBelt: undefined }; + render(); + expect(screen.queryByText("DETAILS.BAG_BELT")).toBeNull(); + }); + + it("has data-testid", () => { + render(); + expect(screen.getByTestId("deboarding-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/DeboardingPanel.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create DeboardingPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { + IFlightTransitionItem, + IFlightLegArrivalStation, + FlightTransitionStatus, +} from "../../types.js"; +import "./panels.scss"; + +const STATUS_KEYS: Record = { + Finished: "DETAILS.STATUS_FINISHED", + Expected: "DETAILS.STATUS_EXPECTED", + InProgress: "DETAILS.STATUS_IN_PROGRESS", + Specified: "DETAILS.STATUS_SPECIFIED", + Scheduled: "DETAILS.STATUS_SCHEDULED", +}; + +export interface DeboardingPanelProps { + item: IFlightTransitionItem; + arrival: IFlightLegArrivalStation; +} + +export const DeboardingPanel: FC = ({ item, arrival }) => { + const { t } = useTranslation(); + const hasEnd = Boolean(item.end?.local); + + return ( +
+
+ {t("DETAILS.STATUS")} + {t(STATUS_KEYS[item.status])} +
+
+ {t("DETAILS.SCHEDULED")} + {item.start.local} +
+ {hasEnd && ( +
+ {t("DETAILS.ACTUAL")} + {item.end.local} +
+ )} + {arrival.terminal && ( +
+ {t("DETAILS.TERMINAL")} + {arrival.terminal} +
+ )} + {arrival.gate && ( +
+ {t("DETAILS.GATE")} + {arrival.gate} +
+ )} + {arrival.bagBelt && ( +
+ {t("DETAILS.BAG_BELT")} + {arrival.bagBelt} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/DeboardingPanel.test.tsx` + +Expected: PASS — 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/details-panels/DeboardingPanel.tsx src/features/online-board/components/details-panels/DeboardingPanel.test.tsx +git commit -m "Add DeboardingPanel component for flight details accordion" +``` + +--- + +### Task 7: AircraftPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/AircraftPanel.tsx` +- Create: `src/features/online-board/components/details-panels/AircraftPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/AircraftPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AircraftPanel } from "./AircraftPanel.js"; +import type { IEquipmentFull } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("AircraftPanel", () => { + it("renders actual title when present", () => { + const eq: IEquipmentFull = { aircraft: { actual: { title: "Airbus A321" } } }; + render(); + expect(screen.getByText("Airbus A321")).toBeTruthy(); + }); + + it("falls back to scheduled when actual.title is empty", () => { + const eq: IEquipmentFull = { + aircraft: { actual: { title: "" }, scheduled: { title: "Boeing 737" } }, + }; + render(); + expect(screen.getByText("Boeing 737")).toBeTruthy(); + }); + + it("renders both actual and scheduled when they differ", () => { + const eq: IEquipmentFull = { + aircraft: { + actual: { title: "Airbus A321" }, + scheduled: { title: "Airbus A320" }, + }, + }; + render(); + expect(screen.getByText("Airbus A321")).toBeTruthy(); + expect(screen.getByText("Airbus A320")).toBeTruthy(); + }); + + it("renders configuration when present", () => { + const eq: IEquipmentFull = { + aircraft: { actual: { title: "A320" }, configuration: "C12Y138" }, + }; + render(); + expect(screen.getByText("C12Y138")).toBeTruthy(); + }); + + it("has data-testid", () => { + const eq: IEquipmentFull = { aircraft: { actual: { title: "A320" } } }; + render(); + expect(screen.getByTestId("aircraft-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/AircraftPanel.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create AircraftPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IEquipmentFull } from "../../types.js"; +import "./panels.scss"; + +export interface AircraftPanelProps { + equipment: IEquipmentFull; +} + +export const AircraftPanel: FC = ({ equipment }) => { + const { t } = useTranslation(); + const aircraft = equipment.aircraft; + const actualTitle = aircraft?.actual?.title; + const scheduledTitle = aircraft?.scheduled?.title; + const configuration = aircraft?.configuration; + + // If actual has content and differs from scheduled, show both; otherwise + // fall back to whichever is present. + const showBoth = Boolean(actualTitle && scheduledTitle && actualTitle !== scheduledTitle); + const primaryTitle = actualTitle || scheduledTitle; + + return ( +
+ {showBoth ? ( + <> +
+ {t("DETAILS.ACTUAL")} + {actualTitle} +
+
+ {t("DETAILS.SCHEDULED")} + {scheduledTitle} +
+ + ) : ( + primaryTitle && ( +
+ {t("DETAILS.AIRCRAFT")} + {primaryTitle} +
+ ) + )} + {configuration && ( +
+ {t("DETAILS.CONFIGURATION")} + {configuration} +
+ )} +
+ ); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/AircraftPanel.test.tsx` + +Expected: PASS — 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/details-panels/AircraftPanel.tsx src/features/online-board/components/details-panels/AircraftPanel.test.tsx +git commit -m "Add AircraftPanel component for flight details accordion" +``` + +--- + +### Task 8: MealPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/MealPanel.tsx` +- Create: `src/features/online-board/components/details-panels/MealPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/MealPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MealPanel } from "./MealPanel.js"; +import type { IMealItem } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("MealPanel", () => { + it("renders an icon for Economy meal", () => { + const meals: IMealItem[] = [{ type: "Economy" }]; + render(); + expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy(); + }); + + it("renders icons for multiple meal types", () => { + const meals: IMealItem[] = [ + { type: "Economy" }, { type: "Comfort" }, { type: "Business" }, + ]; + render(); + expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy(); + expect(screen.getByTestId("meal-icon-Comfort")).toBeTruthy(); + expect(screen.getByTestId("meal-icon-Business")).toBeTruthy(); + }); + + it("skips Special type (no link defined)", () => { + const meals: IMealItem[] = [{ type: "Special" }, { type: "Economy" }]; + render(); + expect(screen.queryByTestId("meal-icon-Special")).toBeNull(); + expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy(); + }); + + it("meal icon is wrapped in a link to aeroflot.ru", () => { + const meals: IMealItem[] = [{ type: "Economy" }]; + render(); + const link = screen.getByTestId("meal-icon-Economy").closest("a"); + expect(link?.getAttribute("href")).toContain("aeroflot.ru"); + expect(link?.getAttribute("href")).toContain("meal-type_0"); + }); + + it("has panel data-testid", () => { + const meals: IMealItem[] = [{ type: "Economy" }]; + render(); + expect(screen.getByTestId("meal-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/MealPanel.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create MealPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IMealItem, MealType } from "../../types.js"; +import { MEAL_LINKS } from "./shared.js"; +import "./panels.scss"; + +const MEAL_ICON_FILES: Record, string> = { + Economy: "econom", + Comfort: "comfort", + Business: "business", +}; + +const MEAL_LABEL_KEYS: Record, string> = { + Economy: "DETAILS.MEAL_ECONOMY", + Comfort: "DETAILS.MEAL_COMFORT", + Business: "DETAILS.MEAL_BUSINESS", +}; + +export interface MealPanelProps { + meals: IMealItem[]; +} + +export const MealPanel: FC = ({ meals }) => { + const { t } = useTranslation(); + const types = new Set(meals.map((m) => m.type)); + + return ( +
+
+ {(["Economy", "Comfort", "Business"] as const).map((type) => + types.has(type) ? ( + + {t(MEAL_LABEL_KEYS[type])} + {t(MEAL_LABEL_KEYS[type])} + + ) : null, + )} +
+
+ ); +}; +``` + +Note: SVG import via relative path assumes Modern.js/Rspack handles SVG as file assets. If it doesn't, the task will fail the next step — swap to `import econom from "./icons/econom.svg"` style imports and use the imported URLs. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/MealPanel.test.tsx` + +Expected: PASS — 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/details-panels/MealPanel.tsx src/features/online-board/components/details-panels/MealPanel.test.tsx +git commit -m "Add MealPanel component with meal-type icons and aeroflot.ru links" +``` + +--- + +### Task 9: ServicesPanel Component + +**Files:** +- Create: `src/features/online-board/components/details-panels/ServicesPanel.tsx` +- Create: `src/features/online-board/components/details-panels/ServicesPanel.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/ServicesPanel.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ServicesPanel } from "./ServicesPanel.js"; +import type { IOnBoardService } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("ServicesPanel", () => { + it("renders an icon for each service", () => { + const services: IOnBoardService[] = [{ id: 1 }, { id: 4 }, { id: 8 }]; + render(); + expect(screen.getByTestId("service-icon-1")).toBeTruthy(); + expect(screen.getByTestId("service-icon-4")).toBeTruthy(); + expect(screen.getByTestId("service-icon-8")).toBeTruthy(); + }); + + it("maps service id 1 to shopping icon", () => { + render(); + const img = screen.getByTestId("service-icon-1") as HTMLImageElement; + expect(img.src).toContain("shopping"); + }); + + it("maps service id 4 to wifi icon", () => { + render(); + const img = screen.getByTestId("service-icon-4") as HTMLImageElement; + expect(img.src).toContain("wifi"); + }); + + it("uses fallback icon for unknown id", () => { + render(); + const img = screen.getByTestId("service-icon-99") as HTMLImageElement; + expect(img.src).toContain("comfort-plus"); + }); + + it("wraps icon in a link when service has url", () => { + const services: IOnBoardService[] = [ + { id: 4, url: "https://wifi.example" }, + ]; + render(); + const link = screen.getByTestId("service-icon-4").closest("a"); + expect(link?.getAttribute("href")).toBe("https://wifi.example"); + }); + + it("does not wrap icon in link when url is missing", () => { + render(); + const link = screen.getByTestId("service-icon-4").closest("a"); + expect(link).toBeNull(); + }); + + it("has panel data-testid", () => { + render(); + expect(screen.getByTestId("services-panel")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/ServicesPanel.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create ServicesPanel.tsx** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IOnBoardService } from "../../types.js"; +import { SERVICE_ICON_MAP, SERVICE_ICON_FALLBACK } from "./shared.js"; +import "./panels.scss"; + +export interface ServicesPanelProps { + services: IOnBoardService[]; +} + +export const ServicesPanel: FC = ({ services }) => { + const { t } = useTranslation(); + + return ( +
+
+ {services.map((svc) => { + const iconName = SERVICE_ICON_MAP[svc.id] ?? SERVICE_ICON_FALLBACK; + const iconSrc = `./icons/${iconName}.svg`; + const alt = svc.title ?? `service-${svc.id}`; + const img = ( + {alt} + ); + + return svc.url ? ( + + {img} + {svc.title && {svc.title}} + + ) : ( + + {img} + {svc.title && {svc.title}} + + ); + })} +
+
+ ); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/ServicesPanel.test.tsx` + +Expected: PASS — 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/details-panels/ServicesPanel.tsx src/features/online-board/components/details-panels/ServicesPanel.test.tsx +git commit -m "Add ServicesPanel component for on-board service icons" +``` + +--- + +### Task 10: FlightDetailsAccordion Container + +**Files:** +- Create: `src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx` +- Create: `src/features/online-board/components/details-panels/FlightDetailsAccordion.scss` +- Create: `src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightDetailsAccordion } from "./FlightDetailsAccordion.js"; +import type { IFlightLeg } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeLeg(overrides: Partial = {}): IFlightLeg { + const base: IFlightLeg = { + arrival: { + scheduled: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + dispatch: "", + gate: "", + terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: "", utc: "" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "JFK", airportCode: "JFK", city: "New York", cityCode: "NYC", countryCode: "US" }, + latest: { airport: "JFK", airportCode: "JFK", city: "New York", cityCode: "NYC", countryCode: "US" }, + dispatch: "", + gate: "", + terminal: "", + checkingStatus: "Scheduled", + parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: "", utc: "" } }, + }, + equipment: { name: "A320" }, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h 30m", + index: 0, + operatingBy: {}, + status: "Scheduled", + updated: "", + }; + return { ...base, ...overrides }; +} + +describe("FlightDetailsAccordion", () => { + it("returns null when no panels should be visible", () => { + const leg = makeLeg(); // no transition, no extended equipment + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders registration tab when transition.registration is active", () => { + const leg = makeLeg({ + transition: { + registration: { + start: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: "", utc: "" }, + end: { dayChange: { value: 0, title: "" }, local: "10:30", localTime: "10:30", tzOffset: "", utc: "" }, + status: "InProgress", + isActual: true, + }, + }, + }); + render(); + expect(screen.getByTestId("flight-details-accordion")).toBeTruthy(); + expect(screen.getByText("DETAILS.REGISTRATION")).toBeTruthy(); + }); + + it("hides transition panels when viewType is Schedule", () => { + const leg = makeLeg({ + transition: { + registration: { + start: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "", tzOffset: "", utc: "" }, + end: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: "", utc: "" }, + status: "InProgress", + isActual: true, + }, + }, + }); + const { container } = render(); + // No panels visible -> container returns null + expect(container.firstChild).toBeNull(); + }); + + it("renders aircraft tab when equipment.aircraft has title", () => { + const leg = makeLeg({ + equipment: { name: "A320", aircraft: { actual: { title: "Airbus A320" } } }, + }); + render(); + expect(screen.getByText("DETAILS.AIRCRAFT")).toBeTruthy(); + }); + + it("renders meal tab when equipment.meal has items", () => { + const leg = makeLeg({ + equipment: { name: "A320", meal: [{ type: "Economy" }] }, + }); + render(); + expect(screen.getByText("DETAILS.MEAL")).toBeTruthy(); + }); + + it("renders services tab when aircraft.actual.onBoardServices has items", () => { + const leg = makeLeg({ + equipment: { name: "A320", aircraft: { actual: { title: "A320", onBoardServices: [{ id: 1 }] } } }, + }); + render(); + expect(screen.getByText("DETAILS.ON_BOARD_SERVICES")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create FlightDetailsAccordion.scss** + +```scss +.flight-details-accordion { + margin-top: 16px; + + .p-accordion-tab { + border: 1px solid #e0e0e0; + border-radius: 4px; + margin-bottom: 8px; + overflow: hidden; + } + + .p-accordion-header { + padding: 12px 16px; + background: #f8f9fa; + cursor: pointer; + font-weight: 500; + display: flex; + justify-content: space-between; + align-items: center; + + &:hover { + background: #eef1f4; + } + } + + .p-accordion-content { + padding: 0 16px 12px; + background: #fff; + } +} +``` + +- [ ] **Step 4: Create FlightDetailsAccordion.tsx** + +```tsx +import { type FC, useState } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import type { IFlightLeg } from "../../types.js"; +import { shouldShowTransition, shouldShowAircraft, type DetailsViewType } from "./shared.js"; +import { RegistrationPanel } from "./RegistrationPanel.js"; +import { BoardingPanel } from "./BoardingPanel.js"; +import { DeboardingPanel } from "./DeboardingPanel.js"; +import { AircraftPanel } from "./AircraftPanel.js"; +import { MealPanel } from "./MealPanel.js"; +import { ServicesPanel } from "./ServicesPanel.js"; +import "./FlightDetailsAccordion.scss"; + +export interface FlightDetailsAccordionProps { + leg: IFlightLeg; + viewType: DetailsViewType; +} + +interface PanelDef { + id: string; + header: string; + content: JSX.Element; +} + +export const FlightDetailsAccordion: FC = ({ leg, viewType }) => { + const { t } = useTranslation(); + const [openIds, setOpenIds] = useState>(new Set()); + + const panels: PanelDef[] = []; + + if (shouldShowTransition(leg.transition?.registration, leg.status, viewType)) { + panels.push({ + id: "registration", + header: t("DETAILS.REGISTRATION"), + content: , + }); + } + if (shouldShowTransition(leg.transition?.boarding, leg.status, viewType)) { + panels.push({ + id: "boarding", + header: t("DETAILS.BOARDING"), + content: , + }); + } + if (shouldShowTransition(leg.transition?.deboarding, leg.status, viewType)) { + panels.push({ + id: "deboarding", + header: t("DETAILS.DEBOARDING"), + content: , + }); + } + if (shouldShowAircraft(leg.equipment)) { + panels.push({ + id: "aircraft", + header: t("DETAILS.AIRCRAFT"), + content: , + }); + } + if ((leg.equipment.meal?.length ?? 0) > 0) { + panels.push({ + id: "meal", + header: t("DETAILS.MEAL"), + content: , + }); + } + if ((leg.equipment.aircraft?.actual?.onBoardServices?.length ?? 0) > 0) { + panels.push({ + id: "services", + header: t("DETAILS.ON_BOARD_SERVICES"), + content: , + }); + } + + if (panels.length === 0) return null; + + const toggle = (id: string) => { + setOpenIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+ {panels.map((panel) => { + const isOpen = openIds.has(panel.id); + return ( +
+
toggle(panel.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(panel.id); + } + }} + > + {panel.header} + +
+ {isOpen &&
{panel.content}
} +
+ ); + })} +
+ ); +}; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx` + +Expected: PASS — 6 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx src/features/online-board/components/details-panels/FlightDetailsAccordion.scss src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx +git commit -m "Add FlightDetailsAccordion container orchestrating 6 panel components" +``` + +--- + +### Task 11: Wire Accordion Into OnlineBoardDetailsPage + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` (inside `FlightLegs` component around lines 35-119) + +- [ ] **Step 1: Update OnlineBoardDetailsPage.test.tsx to verify accordion integration** + +Add this test to the existing `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` describe block (append at end): + +```tsx +describe("accordion integration", () => { + it("does not render accordion when leg has no transition or extended equipment", () => { + render(); + expect(screen.queryByTestId("flight-details-accordion")).toBeNull(); + }); + + it("renders accordion when leg has transition data", () => { + vi.mocked(useFlightDetails).mockReturnValueOnce({ + flight: { + ...mockFlight, + leg: { + ...mockFlight.leg, + transition: { + registration: { + start: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "", tzOffset: "", utc: "" }, + end: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: "", utc: "" }, + status: "InProgress", + isActual: true, + }, + }, + }, + }, + loading: false, + error: null, + }); + render(); + expect(screen.getByTestId("flight-details-accordion")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: FAIL — the 2 new tests fail because the accordion isn't wired in yet. + +- [ ] **Step 3: Wire the accordion into FlightLegs** + +Edit `src/features/online-board/components/OnlineBoardDetailsPage.tsx`: + +Add import near the top (after existing component imports, around line 18): + +```tsx +import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js"; +``` + +In the `FlightLegs` function, replace the final inline aircraft block (currently lines 109-114): + +```tsx + {leg.equipment.name && ( +
+ Aircraft: {leg.equipment.name} + {leg.equipment.code ? ` (${leg.equipment.code})` : ""} +
+ )} +``` + +With the accordion call: + +```tsx + {leg.equipment.name && ( +
+ Aircraft: {leg.equipment.name} + {leg.equipment.code ? ` (${leg.equipment.code})` : ""} +
+ )} + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: PASS — all existing tests still pass plus the 2 new accordion integration tests. + +- [ ] **Step 5: Run full test suite to check for regressions** + +Run: `pnpm test` + +Expected: No new test failures beyond any pre-existing ones. + +- [ ] **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 FlightDetailsAccordion into OnlineBoardDetailsPage per leg" +``` + +--- + +### Task 12: Manual Browser Verification + +**Files:** None — verification only. + +- [ ] **Step 1: Start dev server with API proxy** + +Run: `pnpm dev:full` + +Wait for "Dev server: http://localhost:8080" message. + +- [ ] **Step 2: Open a flight details page in the browser** + +Navigate to: `http://localhost:8080/ru/onlineboard/SU0022-16042026` + +Even if the real API returns empty `transition`/`equipment` fields (WAF-blocked dev environment), the page should render without errors and the accordion should either render nothing (no data) or render the Aircraft panel if `equipment.aircraft.actual.title` is populated. + +Acceptance: +- Page loads without console errors +- If flight data has accordion-worthy fields, the accordion is visible with clickable panel headers +- Clicking a panel expands it to show the panel content +- Clicking again collapses it + +- [ ] **Step 3: Run visual comparison pipeline against Angular** + +Run: `pnpm compare:visual` + +Check `comparison-report/visual/report.html` for `onlineboard-details` rows. The diff percentage for the details page should be lower than the previous run (baseline was 36-72% depending on viewport). Any reduction confirms the accordion is contributing content closer to Angular's layout. + +If Angular's dev server isn't running on port 4200, start it: `cd ClientApp && NODE_OPTIONS=--openssl-legacy-provider npx ng serve --port 4200`. + +- [ ] **Step 4: No commit — verification is observational**