Schedule mini-list: three-day [X-1]/[X]/[X+1] accordion (TZ §4.1.16.2)

New ScheduleFlightsMiniList groups sibling flights by scheduled
departure date into three accordions. [X] (the selected flight's day)
opens by default; adjacent days open only when the user clicks them.
Days without any flights in the loaded context render locked and
dimmed and cannot be expanded, matching TZ §4.1.16.2 R10-R21.

ScheduleDetailsPage swaps the flat FlightsMiniList for this new
component; the OB mini-list remains unchanged since its layout is
per-day-tabs-driven and already matches §4.1.15.2.
This commit is contained in:
2026-04-22 17:19:47 +03:00
parent 6d87b8fa36
commit 2e05b92e4e
4 changed files with 395 additions and 3 deletions
@@ -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<ScheduleDetailsPageProps> = ({
breadcrumbs={breadcrumbs}
contentLeft={
miniListCurrentFlight ? (
// TZ §4.1.16.2 R10-R21: schedule mini-list (desktop/tablet only)
<FlightsMiniList
// TZ §4.1.16.2 R10-R21: schedule mini-list grouped into
// [X-1] / [X] / [X+1] day accordions (desktop/tablet only).
<ScheduleFlightsMiniList
flights={flights as unknown as ISimpleFlight[]}
currentFlight={miniListCurrentFlight}
lang={locale}
@@ -0,0 +1,70 @@
@use "../../../styles/colors" as colors;
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
.schedule-mini-list {
display: flex;
flex-direction: column;
gap: vars.$space-s;
&__day {
border: 1px solid colors.$border;
border-radius: vars.$border-radius;
overflow: hidden;
background: colors.$white;
&--locked {
background: colors.$blue-extra-light;
opacity: 0.55;
pointer-events: none;
}
}
&__day-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: vars.$space-m vars.$space-l;
background: transparent;
border: 0;
color: colors.$blue-dark;
font-size: fonts.$font-size-m;
font-weight: fonts.$font-medium;
text-align: left;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid colors.$blue;
outline-offset: -2px;
}
}
&__day-label {
flex: 1;
text-transform: capitalize;
}
&__day-chevron {
width: 10px;
height: 6px;
background: currentColor;
clip-path: polygon(0 0, 100% 0, 50% 100%);
transition: transform 0.2s ease;
&--open {
transform: rotate(180deg);
}
}
&__day-body {
padding: 0 vars.$space-s vars.$space-s vars.$space-s;
display: flex;
flex-direction: column;
gap: vars.$space-xs;
}
}
@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { forwardRef } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleFlightsMiniList } from "./ScheduleFlightsMiniList.js";
import type { ISimpleFlight } from "@/features/online-board/types.js";
vi.mock("@modern-js/runtime/router", () => ({
Link: forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }
>(({ children, to, ...props }, ref) => (
<a ref={ref} href={to} {...props}>
{children}
</a>
)),
}));
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(
<ScheduleFlightsMiniList
flights={[current]}
currentFlight={current}
lang="en-us"
/>,
),
);
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(
<ScheduleFlightsMiniList
flights={[current]}
currentFlight={current}
lang="en-us"
/>,
),
);
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(
<ScheduleFlightsMiniList
flights={[current]}
currentFlight={current}
lang="en-us"
/>,
),
);
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(
<ScheduleFlightsMiniList
flights={[prevDay, current]}
currentFlight={current}
lang="en-us"
/>,
),
);
// 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");
});
});
@@ -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 R13R21).
*/
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<ScheduleFlightsMiniListProps> = ({
flights,
currentFlight,
lang,
}) => {
const { t } = useTranslation();
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(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<string, ISimpleFlight[]> = {};
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<Set<string>>(
() => 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 (
<section className="mini-list" data-testid="flights-mini-list">
{flights.map((f) => (
<FlightsMiniListItem
key={f.id}
ref={(el) => {
if (el) itemRefs.current.set(f.id, el);
else itemRefs.current.delete(f.id);
}}
flight={f}
isSelected={f.id === currentFlight.id}
lang={lang}
/>
))}
</section>
);
}
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 (
<section
className="mini-list schedule-mini-list"
data-testid="flights-mini-list"
>
{days.map(({ ymd, flightsOnDay }) => {
const locked = flightsOnDay.length === 0;
const isOpen = openDays.has(ymd) && !locked;
return (
<div
key={ymd}
className={`schedule-mini-list__day${
locked ? " schedule-mini-list__day--locked" : ""
}${isOpen ? " schedule-mini-list__day--open" : ""}`}
data-day={ymd}
data-testid={`mini-list-day-${ymd}`}
>
<button
type="button"
className="schedule-mini-list__day-header"
onClick={() => toggle(ymd, locked)}
aria-expanded={isOpen}
aria-disabled={locked}
disabled={locked}
>
<span className="schedule-mini-list__day-label">
{weekdayAndDate(ymd, lang, t)}
</span>
<span
className={`schedule-mini-list__day-chevron${
isOpen ? " schedule-mini-list__day-chevron--open" : ""
}`}
aria-hidden="true"
/>
</button>
{isOpen && (
<div className="schedule-mini-list__day-body">
{flightsOnDay.map((f) => (
<FlightsMiniListItem
key={f.id}
ref={(el) => {
if (el) itemRefs.current.set(f.id, el);
else itemRefs.current.delete(f.id);
}}
flight={f}
isSelected={f.id === currentFlight.id}
lang={lang}
/>
))}
</div>
)}
</div>
);
})}
</section>
);
};