diff --git a/docs/superpowers/plans/2026-04-17-back-button-schedule-timeline.md b/docs/superpowers/plans/2026-04-17-back-button-schedule-timeline.md new file mode 100644 index 00000000..31271309 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-back-button-schedule-timeline.md @@ -0,0 +1,953 @@ +# Back Button + Flight Schedule Timeline (B.6) 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 Angular-parity navigation (back button in header) and a schedule timeline (accordion with scheduled times + duration + days-of-week strip + week date-range note) to the React Online Board details page. + +**Architecture:** Two independent feature directories. `DetailsBackButton` is a simple ``. `FlightSchedule` is a PrimeReact `Accordion` wrapping a Schedule tab, plus a persistent days-of-week strip below. A small `weekDateRange` helper uses `date-fns` ISO week functions. `IFlightLeg` gains an optional `daysOfWeek` field. + +**Tech Stack:** React 18, PrimeReact `Accordion` + `AccordionTab`, `date-fns` (`startOfISOWeek`, `endOfISOWeek`, `parseISO`, `format`), React Router ``, Vitest + React Testing Library. + +--- + +## File Structure + +### New files + +- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.tsx` +- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.scss` +- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx` +- `src/features/online-board/components/DetailsBackButton/index.ts` +- `src/features/online-board/components/FlightSchedule/weekDateRange.ts` +- `src/features/online-board/components/FlightSchedule/weekDateRange.test.ts` +- `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx` +- `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx` +- `src/features/online-board/components/FlightSchedule/FlightSchedule.tsx` +- `src/features/online-board/components/FlightSchedule/FlightSchedule.scss` +- `src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx` +- `src/features/online-board/components/FlightSchedule/index.ts` + +### Modified files + +- `src/features/online-board/types.ts` — add `IDaysOfWeek`, extend `IFlightLeg` with optional `daysOfWeek` +- `src/features/online-board/types.test.ts` — add test case +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — swap PageTabs→DetailsBackButton, insert FlightSchedule +- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — update mocks/assertions + +--- + +### Task 1: Extend `IFlightLeg` with `daysOfWeek` + +**Files:** +- Modify: `src/features/online-board/types.ts` +- Modify: `src/features/online-board/types.test.ts` + +- [ ] **Step 1: Add failing test** + +Append to `src/features/online-board/types.test.ts` inside the existing `describe("online-board types extension")` block: + +```typescript + it("IDaysOfWeek has current and flight bit strings", () => { + const d: IDaysOfWeek = { current: "1000010", flight: "1111111" }; + expect(d.current).toBe("1000010"); + expect(d.flight).toBe("1111111"); + }); + + it("IFlightLeg accepts optional daysOfWeek", () => { + const leg: Partial = { + daysOfWeek: { current: "1000010", flight: "1111111" }, + }; + expect(leg.daysOfWeek?.flight).toBe("1111111"); + }); +``` + +Also update the imports at the top of `types.test.ts` to include `IDaysOfWeek`: + +Find the existing `import type {` block and add `IDaysOfWeek,` to the list. + +- [ ] **Step 2: Run test to verify fail** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: FAIL — `IDaysOfWeek` is not exported. + +- [ ] **Step 3: Extend `src/features/online-board/types.ts`** + +Add a new exported interface near the `IFlightLegFlags` (just before the `IFlightLeg` definition): + +```typescript +// --------------------------------------------------------------------------- +// Days of week +// --------------------------------------------------------------------------- + +/** + * Bit-string indicator of flight operation days. + * Position 0 = Monday, position 6 = Sunday. `"1"` means active. + */ +export interface IDaysOfWeek { + current: string; + flight: string; +} +``` + +Then add `daysOfWeek?: IDaysOfWeek;` to the `IFlightLeg` interface (add at the end, preserving other fields): + +```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; + daysOfWeek?: IDaysOfWeek; +} +``` + +- [ ] **Step 4: Run test to verify pass** + +Run: `pnpm vitest run src/features/online-board/types.test.ts` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/types.ts src/features/online-board/types.test.ts +git commit -m "Extend IFlightLeg with optional daysOfWeek field" +``` + +--- + +### Task 2: `weekDateRange` Helper + +**Files:** +- Create: `src/features/online-board/components/FlightSchedule/weekDateRange.ts` +- Create: `src/features/online-board/components/FlightSchedule/weekDateRange.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// weekDateRange.test.ts +import { describe, it, expect } from "vitest"; +import { getWeekDateRange } from "./weekDateRange.js"; + +describe("getWeekDateRange", () => { + it("returns Monday/Sunday for a mid-week date", () => { + // 2026-04-15 is a Wednesday + const result = getWeekDateRange("2026-04-15T10:00:00"); + expect(result.start).toBe("13.04.2026"); // Monday + expect(result.end).toBe("19.04.2026"); // Sunday + }); + + it("returns Monday/Sunday for a Sunday input (same ISO week)", () => { + // 2026-04-19 is a Sunday + const result = getWeekDateRange("2026-04-19T10:00:00"); + expect(result.start).toBe("13.04.2026"); + expect(result.end).toBe("19.04.2026"); + }); + + it("returns Monday/Sunday for a Monday input", () => { + const result = getWeekDateRange("2026-04-13T10:00:00"); + expect(result.start).toBe("13.04.2026"); + expect(result.end).toBe("19.04.2026"); + }); + + it("returns empty strings for invalid input", () => { + const result = getWeekDateRange("not-a-date"); + expect(result).toEqual({ start: "", end: "" }); + }); + + it("returns empty strings for undefined input", () => { + const result = getWeekDateRange(undefined); + expect(result).toEqual({ start: "", end: "" }); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/weekDateRange.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Create `weekDateRange.ts`** + +```typescript +import { parseISO, startOfISOWeek, endOfISOWeek, format, isValid } from "date-fns"; + +/** + * Given a scheduled local-time ISO string, return the ISO-week bounds + * (Monday/Sunday) as `dd.MM.yyyy` formatted strings. + * Returns `{ start: "", end: "" }` for invalid or missing input. + */ +export function getWeekDateRange(scheduledLocal: string | undefined): { + start: string; + end: string; +} { + if (!scheduledLocal) return { start: "", end: "" }; + const d = parseISO(scheduledLocal); + if (!isValid(d)) return { start: "", end: "" }; + return { + start: format(startOfISOWeek(d), "dd.MM.yyyy"), + end: format(endOfISOWeek(d), "dd.MM.yyyy"), + }; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/weekDateRange.test.ts` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FlightSchedule/weekDateRange.ts src/features/online-board/components/FlightSchedule/weekDateRange.test.ts +git commit -m "Add weekDateRange helper for flight schedule note" +``` + +--- + +### Task 3: `DaysOfWeekStrip` Component + +**Files:** +- Create: `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx` +- Create: `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DaysOfWeekStrip } from "./DaysOfWeekStrip.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +describe("DaysOfWeekStrip", () => { + it("renders 7 day boxes", () => { + render(); + const boxes = screen.getAllByTestId(/^day-of-week-\d$/); + expect(boxes).toHaveLength(7); + }); + + it("marks first 3 active, last 4 inactive for '1110000'", () => { + render(); + expect(screen.getByTestId("day-of-week-0").className).not.toMatch(/--inactive/); + expect(screen.getByTestId("day-of-week-1").className).not.toMatch(/--inactive/); + expect(screen.getByTestId("day-of-week-2").className).not.toMatch(/--inactive/); + expect(screen.getByTestId("day-of-week-3").className).toMatch(/--inactive/); + expect(screen.getByTestId("day-of-week-6").className).toMatch(/--inactive/); + }); + + it("marks all active for '1111111'", () => { + render(); + for (let i = 0; i < 7; i++) { + expect(screen.getByTestId(`day-of-week-${i}`).className).not.toMatch(/--inactive/); + } + }); + + it("marks all inactive for '0000000'", () => { + render(); + for (let i = 0; i < 7; i++) { + expect(screen.getByTestId(`day-of-week-${i}`).className).toMatch(/--inactive/); + } + }); + + it("renders DAYS.1 through DAYS.7 labels in order", () => { + render(); + expect(screen.getByTestId("day-of-week-0").textContent).toContain("DAYS.1"); + expect(screen.getByTestId("day-of-week-6").textContent).toContain("DAYS.7"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `DaysOfWeekStrip.tsx`** + +```tsx +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; + +export interface DaysOfWeekStripProps { + flightBitString: string; +} + +const DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; + +export const DaysOfWeekStrip: FC = ({ flightBitString }) => { + const { t } = useTranslation(); + + return ( +
+ {DAY_INDEXES.map((i) => { + const isActive = flightBitString[i] === "1"; + const className = isActive ? "day" : "day day--inactive"; + return ( + + {t(`DAYS.${i + 1}`)} + + ); + })} +
+ ); +}; +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx +git commit -m "Add DaysOfWeekStrip component for flight schedule" +``` + +--- + +### Task 4: `FlightSchedule` Component + SCSS + Barrel + +**Files:** +- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.tsx` +- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.scss` +- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx` +- Create: `src/features/online-board/components/FlightSchedule/index.ts` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FlightSchedule } from "./FlightSchedule.js"; +import type { ISimpleFlight } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ + t: (k: string) => { + if (k === "SHARED.NOTE-TIME-SCHEDULE") { + return "Valid from {START_DATE} to {END_DATE}"; + } + return k; + }, + }), +})); + +function makeFlight(overrides: { daysOfWeek?: { current: string; flight: string } }): ISimpleFlight { + return { + id: "X", routeType: "Direct", flyingTime: "2h 30m", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260415" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + 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: "2026-04-15T10:00:00", localTime: "10:00", tzOffset: 0, utc: "" } }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: "2h 30m", index: 0, operatingBy: {}, status: "Scheduled", updated: "", + ...overrides, + }, + } as ISimpleFlight; +} + +describe("FlightSchedule", () => { + it("returns null when firstLeg has no daysOfWeek", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("returns null when daysOfWeek.flight is empty string", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders with data-testid=flight-schedule when daysOfWeek present", () => { + render( + , + ); + expect(screen.getByTestId("flight-schedule")).toBeTruthy(); + }); + + it("renders 7 day boxes from daysOfWeek.flight", () => { + render( + , + ); + const boxes = screen.getAllByTestId(/^day-of-week-\d$/); + expect(boxes).toHaveLength(7); + }); + + it("substitutes {START_DATE} and {END_DATE} into the note", () => { + render( + , + ); + // 2026-04-15 is a Wednesday; ISO week Mon 13 - Sun 19 + expect(screen.getByTestId("flight-schedule-note").textContent).toContain("13.04.2026"); + expect(screen.getByTestId("flight-schedule-note").textContent).toContain("19.04.2026"); + }); + + it("renders scheduled departure/arrival times and duration", () => { + render( + , + ); + // Note: accordion open by default; times visible in content + // departure time: the local ISO string's time component + expect(screen.getByText("10:00")).toBeTruthy(); + expect(screen.getByText("12:30")).toBeTruthy(); + expect(screen.getByText("2h 30m")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `FlightSchedule.scss`** + +```scss +.flight-schedule { + background: #fff; + border-radius: 8px; + padding: 16px 24px; + margin-top: 16px; + + .p-accordion-content { + padding: 12px 0; + } + + &__row { + display: flex; + justify-content: space-between; + padding: 4px 0; + } + + &__label { color: #666; } + &__value { font-weight: 500; } + + &__days-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #eee; + } + + &__section-title { + font-size: 12px; + color: #666; + text-transform: uppercase; + margin-bottom: 8px; + } + + &__note { + margin-top: 12px; + font-size: 12px; + color: #666; + font-style: italic; + } +} + +.days-of-week-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + + .day { + padding: 8px 12px; + border-radius: 6px; + background: #e6f1fb; + font-size: 14px; + font-weight: 500; + color: #1a3a5c; + + &--inactive { + background: #f6f6f6; + color: rgba(102, 102, 102, 0.5); + } + } +} +``` + +- [ ] **Step 4: Create `FlightSchedule.tsx`** + +```tsx +import type { FC } from "react"; +import { Accordion, AccordionTab } from "primereact/accordion"; +import { useTranslation } from "@/i18n/provider.js"; +import type { ISimpleFlight } from "../../types.js"; +import { DaysOfWeekStrip } from "./DaysOfWeekStrip.js"; +import { getWeekDateRange } from "./weekDateRange.js"; +import "./FlightSchedule.scss"; + +export interface FlightScheduleProps { + flight: ISimpleFlight; +} + +function formatLocalTime(iso: string | undefined): string { + if (!iso) return ""; + // If it's already "HH:mm" (no date), return as-is + if (/^\d{2}:\d{2}$/.test(iso)) return iso; + // Otherwise try to extract time part from ISO: "2026-04-15T10:00:00" → "10:00" + const match = /T(\d{2}:\d{2})/.exec(iso); + return match ? match[1]! : iso; +} + +export const FlightSchedule: FC = ({ flight }) => { + const { t } = useTranslation(); + + const firstLeg = flight.routeType === "Direct" ? flight.leg : flight.legs[0]; + const lastLeg = flight.routeType === "Direct" ? flight.leg : flight.legs[flight.legs.length - 1]; + + if (!firstLeg?.daysOfWeek?.flight) return null; + if (!lastLeg) return null; + + const depLocal = firstLeg.departure.times.scheduledDeparture.local; + const arrLocal = lastLeg.arrival.times.scheduledArrival.local; + + const { start, end } = getWeekDateRange(depLocal); + const noteTemplate = t("SHARED.NOTE-TIME-SCHEDULE"); + const note = noteTemplate.replace("{START_DATE}", start).replace("{END_DATE}", end); + + return ( +
+ + +
+ {t("SHARED.DEPARTURE-SCHEDULED")} + {formatLocalTime(depLocal)} +
+
+ {t("SHARED.ARRIVAL-SCHEDULED")} + {formatLocalTime(arrLocal)} +
+
+ {t("SHARED.PATH-TIME")} + {flight.flyingTime} +
+
+
+ +
+
+ {t("SHARED.DAYS-EXECUTE-FLIGHT")} +
+ +
{note}
+
+
+ ); +}; +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { FlightSchedule } from "./FlightSchedule.js"; +export type { FlightScheduleProps } from "./FlightSchedule.js"; +``` + +- [ ] **Step 6: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx` + +Expected: 6 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/FlightSchedule/ +git commit -m "Add FlightSchedule accordion with days-of-week strip" +``` + +--- + +### Task 5: `DetailsBackButton` Component + +**Files:** +- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.tsx` +- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.scss` +- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx` +- Create: `src/features/online-board/components/DetailsBackButton/index.ts` + +- [ ] **Step 1: Write failing test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DetailsBackButton } from "./DetailsBackButton.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("@modern-js/runtime/router", () => ({ + Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => ( + {children} + ), +})); + +describe("DetailsBackButton", () => { + it("has data-testid=details-back-button", () => { + render(); + expect(screen.getByTestId("details-back-button")).toBeTruthy(); + }); + + it("links to /{locale}/onlineboard", () => { + render(); + const a = screen.getByTestId("details-back-button") as HTMLAnchorElement; + expect(a.getAttribute("href")).toBe("/ru/onlineboard"); + }); + + it("uses the provided locale in the link", () => { + render(); + const a = screen.getByTestId("details-back-button") as HTMLAnchorElement; + expect(a.getAttribute("href")).toBe("/en/onlineboard"); + }); + + it("renders the BACK-BOARD label", () => { + render(); + expect(screen.getByText("SHARED.BACK-BOARD")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `DetailsBackButton.scss`** + +```scss +.details-back-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #e3f0ff; + color: #1a3a5c; + border-radius: 6px; + text-decoration: none; + font-size: 14px; + font-family: inherit; + cursor: pointer; + + &:hover { + background: #c7dff5; + } + + &__arrow { + display: inline-block; + font-size: 16px; + } +} +``` + +- [ ] **Step 4: Create `DetailsBackButton.tsx`** + +```tsx +import type { FC } from "react"; +import { Link } from "@modern-js/runtime/router"; +import { useTranslation } from "@/i18n/provider.js"; +import "./DetailsBackButton.scss"; + +export interface DetailsBackButtonProps { + locale: string; +} + +export const DetailsBackButton: FC = ({ locale }) => { + const { t } = useTranslation(); + return ( + + + {t("SHARED.BACK-BOARD")} + + ); +}; +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { DetailsBackButton } from "./DetailsBackButton.js"; +export type { DetailsBackButtonProps } from "./DetailsBackButton.js"; +``` + +- [ ] **Step 6: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx` + +Expected: 4 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/DetailsBackButton/ +git commit -m "Add DetailsBackButton component for header navigation" +``` + +--- + +### Task 6: Wire Both into `OnlineBoardDetailsPage` + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +- [ ] **Step 1: Add integration tests** + +Append to the outer describe block in `OnlineBoardDetailsPage.test.tsx`: + +```tsx +describe("back button integration", () => { + it("renders DetailsBackButton in headerLeft", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.getByTestId("details-back-button")).toBeTruthy(); + }); +}); + +describe("flight schedule integration", () => { + it("renders FlightSchedule when firstLeg.daysOfWeek is present", () => { + const flightWithDaysOfWeek = { + ...mockFlight, + leg: { + ...mockFlight.leg, + daysOfWeek: { current: "1000010", flight: "1111111" }, + }, + }; + mockState = { flight: flightWithDaysOfWeek, allFlights: [flightWithDaysOfWeek], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.getByTestId("flight-schedule")).toBeTruthy(); + }); + + it("does not render FlightSchedule when daysOfWeek is absent", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.queryByTestId("flight-schedule")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests — expect failure** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: the 3 new tests fail (testids not present). + +- [ ] **Step 3: Update imports in `OnlineBoardDetailsPage.tsx`** + +Add near other component imports: + +```tsx +import { DetailsBackButton } from "./DetailsBackButton/index.js"; +import { FlightSchedule } from "./FlightSchedule/index.js"; +``` + +- [ ] **Step 4: Swap PageTabs for DetailsBackButton** + +In `OnlineBoardDetailsPage.tsx`, find `const commonLayoutProps` (around line 194): + +```tsx + const commonLayoutProps = { + headerLeft: , + breadcrumbs: [ + { label: t("BREADCRUMBS.ONLINEBOARD"), url: onlineboardHref }, + ], + }; +``` + +Change to: + +```tsx + const commonLayoutProps = { + headerLeft: , + breadcrumbs: [ + { label: t("BREADCRUMBS.ONLINEBOARD"), url: onlineboardHref }, + ], + }; +``` + +Also find the main happy-path return's `` opening: + +```tsx + } + title={

{flightNumber}

} +``` + +Change to: + +```tsx + } + title={

{flightNumber}

} +``` + +The `PageTabs` import becomes unused — remove it from the import list at the top of the file. + +- [ ] **Step 5: Insert `` after the flight legs** + +In the main return, find the closing tag after the flying-time div (after `` + the flying-time div, just before closing `` of `.flight-details`): + +```tsx + {/* Flying time */} +
+ Total flying time: {displayFlight.flyingTime} +
+ +``` + +Insert `` after the flying-time div (before the `` closing `.flight-details`): + +```tsx + {/* Flying time */} +
+ Total flying time: {displayFlight.flyingTime} +
+ + + +``` + +- [ ] **Step 6: Run tests — expect pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: all tests pass, including the 3 new integration tests. + +- [ ] **Step 7: Full suite + typecheck** + +Run: `pnpm test` +Run: `pnpm typecheck` + +Expected: no new failures, no new type errors. If any integration tests elsewhere (e.g., `tests/integration/online-board/*.test.tsx`) reference `PageTabs` via the details page, they may need the `DetailsBackButton` testid instead of the `onlineboard-tab` testid — fix those test assertions minimally. + +- [ ] **Step 8: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +git commit -m "Wire DetailsBackButton and FlightSchedule into OnlineBoardDetailsPage" +``` + +--- + +### Task 7: 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 the 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); + +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" }, + daysOfWeek: { current: "1000010", flight: "1111100" }, + 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: "2026-04-18T10:00: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: "2026-04-18T12:30:00", 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 backBtn = await page.locator('[data-testid="details-back-button"]').count(); +const schedule = await page.locator('[data-testid="flight-schedule"]').count(); +const dayBoxes = await page.locator('[data-testid^="day-of-week-"]').count(); +const noteText = await page.locator('[data-testid="flight-schedule-note"]').textContent().catch(() => ""); + +console.log("backBtn:", backBtn); +console.log("schedule:", schedule); +console.log("dayBoxes:", dayBoxes); +console.log("note:", noteText); + +await page.screenshot({ path: "/tmp/b6-verify.png", fullPage: true }); + +await browser.close(); +SCRIPT +``` + +Expected: `backBtn: 1, schedule: 1, dayBoxes: 7, note: "Valid from 13.04.2026 to 19.04.2026"` (or Russian equivalent depending on the real i18n key value). + +- [ ] **Step 3: No commit — observational**