From 8a2ece333aaf43d855486a87e04b00ee2ee9d58e Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 16 Apr 2026 23:20:00 +0300 Subject: [PATCH] Add flights mini-list (B.2) implementation plan --- .../plans/2026-04-16-flights-mini-list.md | 1162 +++++++++++++++++ 1 file changed, 1162 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-flights-mini-list.md diff --git a/docs/superpowers/plans/2026-04-16-flights-mini-list.md b/docs/superpowers/plans/2026-04-16-flights-mini-list.md new file mode 100644 index 00000000..714a142a --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-flights-mini-list.md @@ -0,0 +1,1162 @@ +# Flights Mini-List Sidebar (B.2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a left sidebar on the Online Board details page showing all flights sharing the same flight number, with click-to-navigate and auto-scroll-into-view for the selected flight, wrapped in the shared `PageLayout`. + +**Architecture:** Three changes in order: (1) extend `useFlightDetails` to expose the full `routes[]` array as `allFlights`; (2) add `FlightsMiniList` + `FlightsMiniListItem` components with `scrollIntoView` behavior; (3) refactor `OnlineBoardDetailsPage` to use `PageLayout` with the mini-list in `contentLeft`. + +**Tech Stack:** React 18, React Router v6 (Modern.js), Vitest + React Testing Library, existing `buildOnlineBoardUrl` helper, existing `PageLayout` component. + +--- + +## File Structure + +### New files +- `src/features/online-board/components/FlightsMiniList/FlightsMiniList.tsx` — Container (returns null for ≤1 flight, manages scroll) +- `src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss` +- `src/features/online-board/components/FlightsMiniList/FlightsMiniList.test.tsx` +- `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx` — Single flight row +- `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx` +- `src/features/online-board/components/FlightsMiniList/index.ts` — Re-exports + +### Modified files +- `src/features/online-board/hooks/useFlightDetails.ts` — Return `allFlights` in addition to `flight` +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — Wrap content in `PageLayout`, pass mini-list to `contentLeft` +- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — Update existing tests for new layout + +### No changes needed +- `src/features/online-board/url.ts` — Existing `buildOnlineBoardUrl({ type: "details", ... })` already produces the correct URL +- `src/ui/layout/PageLayout.tsx` — Already accepts `contentLeft` + +--- + +### Task 1: Extend `useFlightDetails` to Return `allFlights` + +**Files:** +- Modify: `src/features/online-board/hooks/useFlightDetails.ts` + +- [ ] **Step 1: Write failing test** + +Create `src/features/online-board/hooks/useFlightDetails.test.ts`: + +```typescript +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import React, { type ReactNode } from "react"; +import { useFlightDetails } from "./useFlightDetails.js"; +import { ApiContext } from "@/shared/api/provider.js"; +import type { ApiClient } from "@/shared/api/client.js"; +import type { IBoardResponse } from "../types.js"; + +function makeFlight(id: string) { + return { + id, + routeType: "Direct" as const, + flyingTime: "1h", + status: "Scheduled" as const, + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260416" }, + operatingBy: {}, + 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" as const, + updated: "", + }, + }; +} + +function makeWrapper(client: ApiClient) { + return ({ children }: { children: ReactNode }) => React.createElement( + ApiContext.Provider, + { value: client }, + children, + ); +} + +describe("useFlightDetails", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("returns first flight and full array in allFlights", async () => { + const response: IBoardResponse = { + data: { + partners: [], + routes: [makeFlight("SU0022-20260416"), makeFlight("SU0022-20260417"), makeFlight("SU0022-20260418")], + daysOfFlight: [], + }, + }; + const client = { + get: vi.fn().mockResolvedValue(response), + } as unknown as ApiClient; + + const { result } = renderHook( + () => useFlightDetails({ flights: "SU0022", dates: "2026-04-16" }), + { wrapper: makeWrapper(client) }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.flight?.id).toBe("SU0022-20260416"); + expect(result.current.allFlights).toHaveLength(3); + expect(result.current.allFlights[0]?.id).toBe("SU0022-20260416"); + }); + + it("returns empty allFlights array when response has no routes", async () => { + const response: IBoardResponse = { + data: { partners: [], routes: [], daysOfFlight: [] }, + }; + const client = { + get: vi.fn().mockResolvedValue(response), + } as unknown as ApiClient; + + const { result } = renderHook( + () => useFlightDetails({ flights: "SU0022", dates: "2026-04-16" }), + { wrapper: makeWrapper(client) }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.flight).toBeNull(); + expect(result.current.allFlights).toEqual([]); + }); +}); +``` + +**Note:** If `ApiContext` isn't exported from `@/shared/api/provider.js`, check how existing hook tests wire up the API client. If there's no `ApiContext`, wrap the hook with whatever provider the existing `useApiClient()` expects. Adjust imports to match. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/hooks/useFlightDetails.test.ts` + +Expected: FAIL — `allFlights` property does not exist on the return type. + +- [ ] **Step 3: Update the hook** + +Edit `src/features/online-board/hooks/useFlightDetails.ts`. Replace the entire file content with: + +```typescript +/** + * React hook for the flight details page. + * + * Calls `getFlightDetails` on param change, manages loading/error/data state. + * Returns both the first flight (for primary display) and the full routes + * array (for the mini-list sidebar). + * + * @module + */ + +import { useState, useEffect, useRef } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getFlightDetails } from "../api.js"; +import type { FlightDetailsParams } from "../api.js"; +import type { ISimpleFlight } from "../types.js"; +import type { ApiError } from "@/shared/api/errors.js"; + +export interface UseFlightDetailsResult { + flight: ISimpleFlight | null; + allFlights: ISimpleFlight[]; + loading: boolean; + error: ApiError | null; +} + +/** + * Hook for the flight details page. Fetches the details response and exposes + * both the first route (primary flight) and the full routes array. + */ +export function useFlightDetails( + params: FlightDetailsParams, +): UseFlightDetailsResult { + const client = useApiClient(); + const [allFlights, setAllFlights] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const paramsRef = useRef(params); + paramsRef.current = params; + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + getFlightDetails(client, paramsRef.current) + .then((response) => { + if (!cancelled) { + setAllFlights(response.data.routes); + setLoading(false); + } + }) + .catch((err: ApiError) => { + if (!cancelled) { + setError(err); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [client, params.flights, params.dates]); + + return { + flight: allFlights[0] ?? null, + allFlights, + loading, + error, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/features/online-board/hooks/useFlightDetails.test.ts` + +Expected: PASS — both tests pass. + +- [ ] **Step 5: Check for regressions** + +Run: `pnpm typecheck` + +Expected: Existing consumers of `useFlightDetails` still compile (they only destructure `flight`, `loading`, `error`; adding `allFlights` doesn't break them). + +Run: `pnpm test` + +Expected: No new regressions. `OnlineBoardDetailsPage.test.tsx` should still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/hooks/useFlightDetails.ts src/features/online-board/hooks/useFlightDetails.test.ts +git commit -m "Expose allFlights array from useFlightDetails for mini-list sidebar" +``` + +--- + +### Task 2: FlightsMiniListItem Component + +**Files:** +- Create: `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx` +- Create: `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx` +- Create: `src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "@modern-js/runtime/router"; +import { FlightsMiniListItem } from "./FlightsMiniListItem.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +function makeDirectFlight(overrides: Partial = {}): ISimpleFlight { + return { + id: "SU0022-20260416", + routeType: "Direct" as const, + flyingTime: "1h 30m", + status: "Scheduled" as const, + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260416" }, + operatingBy: {}, + leg: { + arrival: { + scheduled: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" }, + latest: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "09:30" } }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", 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: "07:00" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h 30m", + index: 0, + operatingBy: {}, + status: "Scheduled" as const, + updated: "", + }, + ...overrides, + } as ISimpleFlight; +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe("FlightsMiniListItem", () => { + it("renders flight number", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + expect(screen.getByText("SU 0022")).toBeTruthy(); + }); + + it("renders departure and arrival times", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.getByText("12:30")).toBeTruthy(); + }); + + it("renders departure and arrival station codes", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + expect(screen.getByText("SVO")).toBeTruthy(); + expect(screen.getByText("LED")).toBeTruthy(); + }); + + it("has data-testid based on flight id", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy(); + }); + + it("applies selected modifier when isSelected", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + const item = screen.getByTestId("mini-list-item-SU0022-20260416"); + expect(item.className).toMatch(/--selected/); + }); + + it("does not apply selected modifier when not selected", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + const item = screen.getByTestId("mini-list-item-SU0022-20260416"); + expect(item.className).not.toMatch(/--selected/); + }); + + it("link points to the flight details URL", () => { + const flight = makeDirectFlight(); + renderWithRouter(); + const link = screen.getByTestId("mini-list-item-SU0022-20260416") as HTMLAnchorElement; + expect(link.getAttribute("href")).toBe("/ru/onlineboard/SU0022-20260416"); + }); + + it("uses first leg departure and last leg arrival for multi-leg flights", () => { + const multiLeg: ISimpleFlight = { + ...makeDirectFlight(), + routeType: "MultiLeg" as const, + legs: [ + { + ...(makeDirectFlight().leg!), + 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: "08:00", localTime: "08:00", tzOffset: 0, utc: "" } }, + }, + }, + { + ...(makeDirectFlight().leg!), + arrival: { + scheduled: { airport: "CDG", airportCode: "CDG", city: "Paris", cityCode: "PAR", countryCode: "FR" }, + latest: { airport: "CDG", airportCode: "CDG", city: "Paris", cityCode: "PAR", countryCode: "FR" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "14:00", localTime: "14:00", tzOffset: 0, utc: "" } }, + }, + }, + ], + } as unknown as ISimpleFlight; + delete (multiLeg as { leg?: unknown }).leg; + renderWithRouter(); + expect(screen.getByText("JFK")).toBeTruthy(); + expect(screen.getByText("CDG")).toBeTruthy(); + }); +}); +``` + +**Note:** If `MemoryRouter` is not exported from `@modern-js/runtime/router`, check how other tests render components with routing. Common alternatives include wrapping with a custom router provider. Adjust imports to match existing patterns. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the shared stylesheet** + +Create `src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss`: + +```scss +.mini-list { + display: flex; + flex-direction: column; + max-height: calc(100vh - 170px); + overflow-y: auto; + background: #fff; + border-radius: 8px; + + &__item { + padding: 12px; + border-bottom: 1px solid #e0e0e0; + text-decoration: none; + color: inherit; + display: block; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f8f9fa; + } + + &--selected { + border: 2px solid #2060c0; + border-radius: 4px; + } + } + + &__flight-number { + font-size: 12px; + color: #666; + margin-bottom: 4px; + } + + &__content { + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto auto; + gap: 8px 12px; + } + + &__dep-time, + &__arr-time { + font-size: 16px; + font-weight: 500; + color: #1a3a5c; + } + + &__arr-time { + text-align: right; + } + + &__status-icon { + grid-column: 2; + grid-row: 1; + align-self: center; + } + + &__dep-station { + grid-column: 1; + grid-row: 2; + font-size: 14px; + color: #333; + } + + &__arr-station { + grid-column: 3; + grid-row: 2; + text-align: right; + font-size: 14px; + color: #333; + } +} +``` + +- [ ] **Step 4: Create FlightsMiniListItem.tsx** + +Create `src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx`: + +```tsx +import { forwardRef } from "react"; +import { Link } from "@modern-js/runtime/router"; +import type { ISimpleFlight, IFlightLeg } from "../../types.js"; +import { buildOnlineBoardUrl } from "../../url.js"; +import "./FlightsMiniList.scss"; + +export interface FlightsMiniListItemProps { + flight: ISimpleFlight; + isSelected: boolean; + lang: string; +} + +/** + * Extract first-leg departure and last-leg arrival for display. + * Direct flights: just use the single leg. + * MultiLeg flights: first leg's departure, last leg's arrival. + */ +function getEndpoints(flight: ISimpleFlight): { dep: IFlightLeg["departure"]; arr: IFlightLeg["arrival"] } { + if (flight.routeType === "Direct") { + return { dep: flight.leg.departure, arr: flight.leg.arrival }; + } + const firstLeg = flight.legs[0]!; + const lastLeg = flight.legs[flight.legs.length - 1]!; + return { dep: firstLeg.departure, arr: lastLeg.arrival }; +} + +function getDepTime(dep: IFlightLeg["departure"]): string { + return dep.times.actualBlockOff?.local ?? dep.times.scheduledDeparture.local; +} + +function getArrTime(arr: IFlightLeg["arrival"]): string { + return arr.times.actualBlockOn?.local ?? arr.times.scheduledArrival.local; +} + +export const FlightsMiniListItem = forwardRef( + ({ flight, isSelected, lang }, ref) => { + const { dep, arr } = getEndpoints(flight); + const href = `/${lang}/${buildOnlineBoardUrl({ + type: "details", + carrier: flight.flightId.carrier, + flightNumber: flight.flightId.flightNumber, + ...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}), + date: flight.flightId.date, + })}`; + + const className = `mini-list__item${isSelected ? " mini-list__item--selected" : ""}`; + + return ( + +
+ {flight.flightId.carrier} {flight.flightId.flightNumber} +
+
+ {getDepTime(dep)} + + ✈ + + {getArrTime(arr)} + {dep.scheduled.airportCode} + {arr.scheduled.airportCode} +
+ + ); + }, +); + +FlightsMiniListItem.displayName = "FlightsMiniListItem"; +``` + +- [ ] **Step 5: Run test to verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx` + +Expected: PASS — all 8 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.tsx src/features/online-board/components/FlightsMiniList/FlightsMiniListItem.test.tsx src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss +git commit -m "Add FlightsMiniListItem component with Link navigation" +``` + +--- + +### Task 3: FlightsMiniList Container + +**Files:** +- Create: `src/features/online-board/components/FlightsMiniList/FlightsMiniList.tsx` +- Create: `src/features/online-board/components/FlightsMiniList/FlightsMiniList.test.tsx` +- Create: `src/features/online-board/components/FlightsMiniList/index.ts` + +- [ ] **Step 1: Write failing tests** + +Create `src/features/online-board/components/FlightsMiniList/FlightsMiniList.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "@modern-js/runtime/router"; +import { FlightsMiniList } from "./FlightsMiniList.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +// Mock scrollIntoView (not implemented in jsdom) +beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +function makeFlight(id: string, carrier = "SU", flightNumber = "0022", date = "20260416"): ISimpleFlight { + return { + id, + routeType: "Direct" as const, + flyingTime: "1h", + status: "Scheduled" as const, + flightId: { carrier, flightNumber, suffix: "", date }, + operatingBy: {}, + leg: { + 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: "", tzOffset: 0, utc: "" } }, + }, + dayChange: 0, + 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: "", tzOffset: 0, utc: "" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "1h", + index: 0, + operatingBy: {}, + status: "Scheduled" as const, + updated: "", + }, + }; +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe("FlightsMiniList", () => { + it("returns null when flights array is empty", () => { + const current = makeFlight("SU0022-20260416"); + const { container } = renderWithRouter( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("returns null when flights array has only one flight", () => { + const current = makeFlight("SU0022-20260416"); + const { container } = renderWithRouter( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders one item per flight when multiple flights", () => { + const flights = [ + makeFlight("SU0022-20260416", "SU", "0022", "20260416"), + makeFlight("SU0022-20260417", "SU", "0022", "20260417"), + makeFlight("SU0022-20260418", "SU", "0022", "20260418"), + ]; + renderWithRouter( + , + ); + expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy(); + expect(screen.getByTestId("mini-list-item-SU0022-20260417")).toBeTruthy(); + expect(screen.getByTestId("mini-list-item-SU0022-20260418")).toBeTruthy(); + }); + + it("highlights the item matching currentFlight.id", () => { + const flights = [ + makeFlight("SU0022-20260416", "SU", "0022", "20260416"), + makeFlight("SU0022-20260417", "SU", "0022", "20260417"), + ]; + renderWithRouter( + , + ); + const selected = screen.getByTestId("mini-list-item-SU0022-20260417"); + const notSelected = screen.getByTestId("mini-list-item-SU0022-20260416"); + expect(selected.className).toMatch(/--selected/); + expect(notSelected.className).not.toMatch(/--selected/); + }); + + it("calls scrollIntoView on the selected item after mount", async () => { + const flights = [ + makeFlight("SU0022-20260416", "SU", "0022", "20260416"), + makeFlight("SU0022-20260417", "SU", "0022", "20260417"), + ]; + renderWithRouter( + , + ); + // Wait a microtask for useEffect + await Promise.resolve(); + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ block: "center", behavior: "smooth" }), + ); + }); + + it("has data-testid on the container", () => { + const flights = [ + makeFlight("SU0022-20260416", "SU", "0022", "20260416"), + makeFlight("SU0022-20260417", "SU", "0022", "20260417"), + ]; + renderWithRouter( + , + ); + expect(screen.getByTestId("flights-mini-list")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FlightsMiniList/FlightsMiniList.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create FlightsMiniList.tsx** + +Create `src/features/online-board/components/FlightsMiniList/FlightsMiniList.tsx`: + +```tsx +import { type FC, useEffect, useRef } from "react"; +import type { ISimpleFlight } from "../../types.js"; +import { FlightsMiniListItem } from "./FlightsMiniListItem.js"; +import "./FlightsMiniList.scss"; + +export interface FlightsMiniListProps { + flights: ISimpleFlight[]; + currentFlight: ISimpleFlight; + lang: string; +} + +export const FlightsMiniList: FC = ({ + flights, + currentFlight, + lang, +}) => { + const itemRefs = useRef>(new Map()); + + useEffect(() => { + const selected = itemRefs.current.get(currentFlight.id); + if (selected) { + selected.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }, [currentFlight.id]); + + if (flights.length <= 1) { + return null; + } + + return ( +
+ {flights.map((flight) => ( + { + if (el) { + itemRefs.current.set(flight.id, el); + } else { + itemRefs.current.delete(flight.id); + } + }} + flight={flight} + isSelected={flight.id === currentFlight.id} + lang={lang} + /> + ))} +
+ ); +}; +``` + +- [ ] **Step 4: Create the index barrel** + +Create `src/features/online-board/components/FlightsMiniList/index.ts`: + +```typescript +export { FlightsMiniList } from "./FlightsMiniList.js"; +export type { FlightsMiniListProps } from "./FlightsMiniList.js"; +export { FlightsMiniListItem } from "./FlightsMiniListItem.js"; +export type { FlightsMiniListItemProps } from "./FlightsMiniListItem.js"; +``` + +- [ ] **Step 5: Run test to verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FlightsMiniList/FlightsMiniList.test.tsx` + +Expected: PASS — all 6 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/FlightsMiniList/ +git commit -m "Add FlightsMiniList container with scroll-into-view behavior" +``` + +--- + +### Task 4: Integrate Mini-List into OnlineBoardDetailsPage + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +- [ ] **Step 1: Add failing integration tests** + +Append these tests to the existing `OnlineBoardDetailsPage.test.tsx` (add a new `describe` block inside the outer describe): + +```tsx +describe("mini-list integration", () => { + it("renders mini-list when multiple flights are returned", () => { + // The existing test file uses a mockState variable. Mutate it to provide multiple flights. + // Assumes `makeFlight(id, date)` helper exists or can be adapted from existing mocks. + const first = mockFlight; + const second = { ...mockFlight, id: "SU0022-20260417" }; + mockState = { flight: first, allFlights: [first, second], loading: false, error: null }; + render( + + + , + ); + expect(screen.getByTestId("flights-mini-list")).toBeTruthy(); + }); + + it("does not render mini-list when only one flight is returned", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], loading: false, error: null }; + render( + + + , + ); + expect(screen.queryByTestId("flights-mini-list")).toBeNull(); + }); + + it("renders inside PageLayout (has page-layout class)", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], loading: false, error: null }; + const { container } = render( + + + , + ); + expect(container.querySelector(".page-layout")).toBeTruthy(); + }); +}); +``` + +**Adaptation note:** Read the existing `OnlineBoardDetailsPage.test.tsx` to see how it mocks `useFlightDetails`. The existing test uses a `mockState` variable. Add `allFlights` to every existing `mockState` assignment so the hook returns the new shape. Also add `MemoryRouter` to the existing `render` calls if they aren't already wrapped (the mini-list uses `` which needs router context). + +- [ ] **Step 2: Run tests to verify failures** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: The 3 new tests fail; existing tests might also fail if `allFlights` isn't set in their mock states (fix those as part of this step). + +- [ ] **Step 3: Update all existing mockState assignments** + +Find every `mockState = { ... }` or similar in `OnlineBoardDetailsPage.test.tsx`. For each, add `allFlights` to match `flight` semantics: + +- When `flight: mockFlight` → add `allFlights: [mockFlight]` +- When `flight: null` → add `allFlights: []` +- When mocking multiple flights → pass them all in `allFlights` + +- [ ] **Step 4: Refactor the component to use PageLayout** + +Edit `src/features/online-board/components/OnlineBoardDetailsPage.tsx`. Near the top add imports: + +```tsx +import { PageLayout } from "@/ui/layout/PageLayout.js"; +import { PageTabs } from "@/ui/layout/PageTabs.js"; +import { FlightsMiniList } from "./FlightsMiniList/index.js"; +``` + +Then replace the final `return (...)` block (currently starting around line 188 with `
`) with: + +```tsx + const legs = getLegs(displayFlight); + const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`; + + const seoProps = buildFlightDetailsSeo(t, displayFlight, locale, canonicalOrigin); + const jsonLd = buildFlightJsonLd(displayFlight); + + return ( + <> + + + } + title={

{flightNumber}

} + breadcrumbs={[ + { label: t("BREADCRUMBS.ONLINEBOARD"), href: `/${locale}/onlineboard` }, + { label: flightNumber }, + ]} + contentLeft={ + + } + > +
+ {/* Connection status */} +
+ {connectionStatus === "live" && ( + Live + )} + {connectionStatus === "reconnecting" && ( + Reconnecting... + )} + {connectionStatus === "offline" && ( + Offline + )} +
+ + {/* Flight header (status only; h1 moved to PageLayout title) */} +
+ {displayFlight.status} +
+ + {/* Summary card */} + + + {/* Operating carrier */} + {displayFlight.operatingBy.carrier && ( +
+ Operated by: {displayFlight.operatingBy.carrier} + {displayFlight.operatingBy.flightNumber + ? ` ${displayFlight.operatingBy.flightNumber}` + : ""} +
+ )} + + {/* Detailed leg information */} + + + {/* Flying time */} +
+ Total flying time: {displayFlight.flyingTime} +
+
+
+ + ); +``` + +Also update the hook destructuring near the top: + +```tsx +const { flight, allFlights, loading, error } = useFlightDetails(detailsParams); +``` + +And make sure `allFlights` is in scope where `PageLayout` is rendered. You may need to ensure that loading/error/not-found branches **also** render inside `PageLayout` with the sidebar. For those early-return branches, replace the return with: + +```tsx +if (loading) { + return ( + } + breadcrumbs={[{ label: t("BREADCRUMBS.ONLINEBOARD"), href: `/${locale}/onlineboard` }]} + > + + + ); +} + +if (error) { + return ( + } + breadcrumbs={[{ label: t("BREADCRUMBS.ONLINEBOARD"), href: `/${locale}/onlineboard` }]} + > +
+ Failed to load flight details. Please try again. +
+
+ ); +} + +if (!flight) { + return ( + } + breadcrumbs={[{ label: t("BREADCRUMBS.ONLINEBOARD"), href: `/${locale}/onlineboard` }]} + > +
Flight not found.
+
+ ); +} +``` + +- [ ] **Step 5: Check if `BREADCRUMBS.ONLINEBOARD` key exists** + +Run: `grep -n "BREADCRUMBS" src/i18n/locales/en/common.json src/i18n/locales/ru/common.json` + +If the key doesn't exist, add it to both files. In `src/i18n/locales/en/common.json` find a suitable location (e.g., top level near other similar blocks) and add: + +```json + "BREADCRUMBS": { + "ONLINEBOARD": "Online Board" + }, +``` + +And in `src/i18n/locales/ru/common.json`: + +```json + "BREADCRUMBS": { + "ONLINEBOARD": "Онлайн-табло" + }, +``` + +- [ ] **Step 6: Run tests to verify pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: All tests pass, including the 3 new mini-list integration tests. + +- [ ] **Step 7: Run full suite** + +Run: `pnpm test` + +Expected: No new failures beyond any pre-existing ones (the previously-skipped feedback button and flight-tab-default tests remain skipped). + +- [ ] **Step 8: Run typecheck** + +Run: `pnpm typecheck` + +Expected: No new errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx src/i18n/locales/en/common.json src/i18n/locales/ru/common.json +git commit -m "Wire FlightsMiniList into OnlineBoardDetailsPage via PageLayout" +``` + +--- + +### Task 5: Manual Browser Verification + +**Files:** None — observational. + +- [ ] **Step 1: Start both dev servers** + +Run in separate terminals (or background): + +```bash +cd ClientApp && NODE_OPTIONS=--openssl-legacy-provider npx ng serve --port 4200 +pnpm dev:full +``` + +Wait for both to be ready (Angular on :4200, React on :8080 with proxy). + +- [ ] **Step 2: Navigate to a flight details page in the browser** + +Open: `http://localhost:8080/ru/onlineboard/SU0022-16042026` + +Verify: +- Page loads inside a two-column layout (sidebar + main content) +- Breadcrumbs at top: "Home > Online Board > SU 0022" +- If the real API returns only one flight for this number on this date, the sidebar should be empty (no mini-list) +- If the API returns multiple flights (same number on adjacent dates), the sidebar shows them +- Clicking a different flight item navigates to that flight's URL and the page re-renders +- The selected item has a blue border +- The selected item is visible (scrolled into view) + +- [ ] **Step 3: Force multiple flights via Playwright mock** + +Since the real dev API may only return one flight per number+date, run this verification script: + +```bash +cat << 'SCRIPT' | npx tsx --input-type=module - +import { chromium } from "@playwright/test"; +import { mockAngularAPIs } from "./tests/e2e-angular/support/angular-api-mock.js"; + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); +await mockAngularAPIs(page); + +// Inject a mock with multiple flights for SU0022 +await page.route("**/onlineboard/details*", (route) => { + const makeFlight = (dateStr) => ({ + id: `SU0022-${dateStr}`, + routeType: "Direct", + flyingTime: "1h 30m", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: dateStr, dateLT: dateStr }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + index: 0, flyingTime: "1h 30m", status: "Scheduled", updated: "", dayChange: 0, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + departure: { + scheduled: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", 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: "07:00" } }, + }, + arrival: { + scheduled: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" }, + latest: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "09:30" } }, + }, + equipment: {}, + }, + }); + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + partners: [], + daysOfFlight: [], + routes: [ + makeFlight("20260415"), + makeFlight("20260416"), + makeFlight("20260417"), + makeFlight("20260418"), + ], + }, + }), + }); +}); + +await page.goto("http://localhost:8080/ru/onlineboard/SU0022-16042026", { waitUntil: "networkidle" }); +await page.waitForTimeout(3000); + +const miniList = await page.locator('[data-testid="flights-mini-list"]').count(); +console.log("Mini-list container:", miniList); + +const items = await page.locator('[data-testid^="mini-list-item-"]').evaluateAll(els => + els.map(el => el.getAttribute("data-testid")), +); +console.log("Items:", items); + +// Check selected item +const selected = await page.locator('.mini-list__item--selected').evaluate(el => + el.getAttribute("data-testid"), +).catch(() => null); +console.log("Selected item:", selected); + +await page.screenshot({ path: "/tmp/mini-list-verify.png", fullPage: true }); +console.log("Screenshot: /tmp/mini-list-verify.png"); + +await browser.close(); +SCRIPT +``` + +Expected output: +- Mini-list container: 1 +- Items: array of 4 testids (one per date) +- Selected item: `mini-list-item-SU0022-20260416` (the one matching the URL date) +- Screenshot shows sidebar with 4 items, one highlighted with blue border + +- [ ] **Step 4: No commit — verification is observational**