diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index 0a8c037f..1f2e99ad 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -29,7 +29,7 @@ import { buildScheduleFlightJsonLd } from "../json-ld.js"; import { ScheduleFlightBody } from "./ScheduleFlightBody.js"; import { FlightSchedule } from "@/features/online-board/components/FlightSchedule/index.js"; import { FullRouteTimeline } from "@/features/online-board/components/FullRouteTimeline/index.js"; -import { FlightsMiniList } from "@/features/online-board/components/FlightsMiniList/index.js"; +import { ScheduleFlightsMiniList } from "./ScheduleFlightsMiniList.js"; import { DayTabs } from "@/features/online-board/components/DayTabs/index.js"; import type { IScheduleFlightId, IFlightLeg, ISimpleFlight } from "../types.js"; import "./ScheduleDetailsPage.scss"; @@ -314,8 +314,9 @@ export const ScheduleDetailsPage: FC = ({ breadcrumbs={breadcrumbs} contentLeft={ miniListCurrentFlight ? ( - // TZ §4.1.16.2 R10-R21: schedule mini-list (desktop/tablet only) - ({ + Link: forwardRef< + HTMLAnchorElement, + React.AnchorHTMLAttributes & { to: string } + >(({ children, to, ...props }, ref) => ( + + {children} + + )), +})); + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +function flight(id: string, ymd: string): ISimpleFlight { + const iso = `${ymd}T10:00:00`; + const arrIso = `${ymd}T13:00:00`; + return { + routeType: "Direct", + id, + flightId: { carrier: "SU", flightNumber: "1", date: ymd.replace(/-/g, ""), suffix: "" }, + operatingBy: { scheduled: "SU" }, + flyingTime: "03:00:00", + status: "Scheduled", + leg: { + index: 0, + dayChange: 0, + flyingTime: "03:00:00", + updated: "", + equipment: {} as never, + operatingBy: { scheduled: "SU" }, + status: "Scheduled", + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + departure: { + scheduled: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" }, + checkingStatus: "", + times: { + scheduledDeparture: { local: iso, localTime: "10:00", tzOffset: 0, utc: iso, dayChange: { value: 0 } as never }, + }, + } as never, + arrival: { + scheduled: { airport: "LED", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" }, + times: { + scheduledArrival: { local: arrIso, localTime: "13:00", tzOffset: 0, utc: arrIso, dayChange: { value: 0 } as never }, + }, + } as never, + } as never, + } as unknown as ISimpleFlight; +} + +const wrap = (ui: React.ReactElement) => ui; + +describe("ScheduleFlightsMiniList §4.1.16.2", () => { + it("renders three day accordions: [X-1], [X], [X+1]", () => { + const current = flight("a", "2026-04-22"); + render( + wrap( + , + ), + ); + expect(screen.getByTestId("mini-list-day-2026-04-21")).toBeTruthy(); + expect(screen.getByTestId("mini-list-day-2026-04-22")).toBeTruthy(); + expect(screen.getByTestId("mini-list-day-2026-04-23")).toBeTruthy(); + }); + + it("expands [X] by default and keeps empty [X-1]/[X+1] locked", () => { + const current = flight("a", "2026-04-22"); + render( + wrap( + , + ), + ); + expect(screen.getByTestId("mini-list-day-2026-04-22").className).toContain("--open"); + expect(screen.getByTestId("mini-list-day-2026-04-21").className).toContain("--locked"); + expect(screen.getByTestId("mini-list-day-2026-04-23").className).toContain("--locked"); + }); + + it("does not toggle a locked (empty) day on click", () => { + const current = flight("a", "2026-04-22"); + render( + wrap( + , + ), + ); + const header = screen + .getByTestId("mini-list-day-2026-04-21") + .querySelector("button"); + if (header) fireEvent.click(header); + expect(screen.getByTestId("mini-list-day-2026-04-21").className).not.toContain("--open"); + }); + + it("opens [X-1] when it has flights", () => { + const current = flight("a", "2026-04-22"); + const prevDay = flight("b", "2026-04-21"); + render( + wrap( + , + ), + ); + // Non-empty [X-1] is unlocked by default but closed. Clicking opens it. + const prev = screen.getByTestId("mini-list-day-2026-04-21"); + expect(prev.className).not.toContain("--locked"); + expect(prev.className).not.toContain("--open"); + fireEvent.click(prev.querySelector("button")!); + expect(screen.getByTestId("mini-list-day-2026-04-21").className).toContain("--open"); + }); +}); diff --git a/src/features/schedule/components/ScheduleFlightsMiniList.tsx b/src/features/schedule/components/ScheduleFlightsMiniList.tsx new file mode 100644 index 00000000..46d86025 --- /dev/null +++ b/src/features/schedule/components/ScheduleFlightsMiniList.tsx @@ -0,0 +1,186 @@ +/** + * TZ §4.1.16.2 — Schedule flight-card mini-list grouped by day. + * + * Renders three accordion sections for the days `[X-1]`, `[X]`, `[X+1]`, + * where X is the scheduled departure date of the currently-opened + * flight. The `[X]` accordion is open by default; `[X-1]` and `[X+1]` + * start collapsed. Days whose group has no flights render as locked + * and dimmed — users cannot expand them. + * + * Reuses `FlightsMiniListItem` from the OB mini-list to render the + * individual rows (same layout requirement per §4.1.16.2 R13–R21). + */ + +import { type FC, useEffect, useMemo, useRef, useState } from "react"; +import type { ISimpleFlight } from "@/features/online-board/types.js"; +import { FlightsMiniListItem } from "@/features/online-board/components/FlightsMiniList/FlightsMiniListItem.js"; +import { useTranslation } from "@/i18n/provider.js"; +import "@/features/online-board/components/FlightsMiniList/FlightsMiniList.scss"; +import "./ScheduleFlightsMiniList.scss"; + +export interface ScheduleFlightsMiniListProps { + flights: ISimpleFlight[]; + currentFlight: ISimpleFlight; + lang: string; +} + +/** Scheduled local-departure YYYY-MM-DD of the flight's primary leg. */ +function primaryDepartureYmd(f: ISimpleFlight): string | null { + const leg = f.routeType === "Direct" ? f.leg : f.legs[0]; + const iso = leg?.departure.times.scheduledDeparture.local; + return iso ? iso.slice(0, 10) : null; +} + +function addDaysYmd(ymd: string, days: number): string | null { + const d = new Date(`${ymd}T00:00:00`); + if (Number.isNaN(d.getTime())) return null; + d.setDate(d.getDate() + days); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function weekdayAndDate(ymd: string, lang: string, t: (k: string) => string): string { + const d = new Date(`${ymd}T00:00:00`); + const weekday = d.toLocaleDateString(lang, { weekday: "long" }); + const dayNum = d.getDate(); + const monthShort = t(`MONTH-SHORT.${d.getMonth() + 1}`); + const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1); + return `${weekdayCap}, ${dayNum} ${monthShort}`; +} + +export const ScheduleFlightsMiniList: FC = ({ + flights, + currentFlight, + lang, +}) => { + const { t } = useTranslation(); + const itemRefs = useRef>(new Map()); + + const currentYmd = primaryDepartureYmd(currentFlight); + const prevYmd = currentYmd ? addDaysYmd(currentYmd, -1) : null; + const nextYmd = currentYmd ? addDaysYmd(currentYmd, 1) : null; + + const groups = useMemo(() => { + const g: Record = {}; + for (const f of flights) { + const d = primaryDepartureYmd(f); + if (!d) continue; + (g[d] ??= []).push(f); + } + return g; + }, [flights]); + + // [X] is always expanded by default. User toggles override defaults. + const [openDays, setOpenDays] = useState>( + () => new Set(currentYmd ? [currentYmd] : []), + ); + + useEffect(() => { + if (!currentYmd) return; + setOpenDays((prev) => { + if (prev.has(currentYmd)) return prev; + const next = new Set(prev); + next.add(currentYmd); + return next; + }); + }, [currentYmd]); + + useEffect(() => { + const selected = itemRefs.current.get(currentFlight.id); + if (selected) selected.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [currentFlight.id]); + + if (!currentYmd || !prevYmd || !nextYmd) { + // Fall back to a flat list when we can't pin the current day (e.g. + // test fixtures with missing times). Preserves previous behavior. + return ( +
+ {flights.map((f) => ( + { + if (el) itemRefs.current.set(f.id, el); + else itemRefs.current.delete(f.id); + }} + flight={f} + isSelected={f.id === currentFlight.id} + lang={lang} + /> + ))} +
+ ); + } + + const days: Array<{ ymd: string; flightsOnDay: ISimpleFlight[] }> = [ + { ymd: prevYmd, flightsOnDay: groups[prevYmd] ?? [] }, + { ymd: currentYmd, flightsOnDay: groups[currentYmd] ?? [currentFlight] }, + { ymd: nextYmd, flightsOnDay: groups[nextYmd] ?? [] }, + ]; + + const toggle = (ymd: string, locked: boolean) => { + if (locked) return; + setOpenDays((prev) => { + const next = new Set(prev); + if (next.has(ymd)) next.delete(ymd); + else next.add(ymd); + return next; + }); + }; + + return ( +
+ {days.map(({ ymd, flightsOnDay }) => { + const locked = flightsOnDay.length === 0; + const isOpen = openDays.has(ymd) && !locked; + return ( +
+ + {isOpen && ( +
+ {flightsOnDay.map((f) => ( + { + if (el) itemRefs.current.set(f.id, el); + else itemRefs.current.delete(f.id); + }} + flight={f} + isSelected={f.id === currentFlight.id} + lang={lang} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +};