diff --git a/src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx b/src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx new file mode 100644 index 00000000..8df3cf77 --- /dev/null +++ b/src/features/online-board/components/FullRouteTimeline/Timeline.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { IFlightLeg, IDuration } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +import { Timeline } from "./Timeline.js"; + +function makeLeg( + i: number, + overrides: { + actualDepLocal?: string; + actualArrLocal?: string; + estimatedDuration?: IDuration; + } = {}, +): IFlightLeg { + return { + arrival: { + scheduled: { + airport: "", + airportCode: "AAA", + city: `City${i}`, + cityCode: `C${i}`, + countryCode: "RU", + }, + terminal: "", + times: { + scheduledArrival: { + dayChange: { value: 0, title: "" }, + local: `a${i}`, + localTime: `a${i}`, + tzOffset: 0, + utc: `2026-04-17T0${i}:00:00Z`, + }, + actualBlockOn: overrides.actualArrLocal + ? { + dayChange: { value: 0, title: "" }, + local: overrides.actualArrLocal, + localTime: overrides.actualArrLocal, + tzOffset: 0, + utc: `2026-04-17T0${i}:00:00Z`, + } + : undefined, + }, + }, + departure: { + scheduled: { + airport: "", + airportCode: "BBB", + city: `Dep${i}`, + cityCode: `D${i}`, + countryCode: "RU", + }, + terminal: "", + checkingStatus: "", + times: { + scheduledDeparture: { + dayChange: { value: 0, title: "" }, + local: `d${i}`, + localTime: `d${i}`, + tzOffset: 0, + utc: `2026-04-17T0${i}:00:00Z`, + }, + actualBlockOff: overrides.actualDepLocal + ? { + dayChange: { value: 0, title: "" }, + local: overrides.actualDepLocal, + localTime: overrides.actualDepLocal, + tzOffset: 0, + utc: `2026-04-17T0${i}:00:00Z`, + } + : undefined, + }, + }, + dayChange: 0, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + flyingTime: `${i}h`, + index: i, + operatingBy: {}, + status: "Scheduled", + updated: "", + estimatedDuration: overrides.estimatedDuration, + } as IFlightLeg; +} + +describe("Timeline", () => { + it("renders times for 2 legs at index 0", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + expect(screen.getByText("d0")).toBeTruthy(); + expect(screen.getByText("a0")).toBeTruthy(); + expect(screen.getByText("d1")).toBeTruthy(); + expect(screen.getByText("a1")).toBeTruthy(); + }); + + it("does not render prev arrow on first page", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + expect(screen.queryByTestId("timeline-prev")).toBeNull(); + }); + + it("does not render next arrow on last pair (2 legs)", () => { + const legs = [makeLeg(0), makeLeg(1)]; + render(); + expect(screen.queryByTestId("timeline-next")).toBeNull(); + }); + + it("shows next arrow with 3 legs, clicking advances", () => { + const legs = [makeLeg(0), makeLeg(1), makeLeg(2)]; + render(); + const nextBtn = screen.getByTestId("timeline-next"); + expect(nextBtn).toBeTruthy(); + fireEvent.click(nextBtn); + expect(screen.getByText("d2")).toBeTruthy(); + expect(screen.queryByText("d0")).toBeNull(); + }); + + it("applies specifying class when canChange=true and estimatedDuration.isNegative", () => { + const legs = [ + makeLeg(0, { estimatedDuration: { days: 0, hours: 1, minutes: 0, isNegative: true } }), + makeLeg(1), + ]; + const { container } = render(); + expect(container.querySelector(".timeline-section__duration--specifying")).not.toBeNull(); + }); + + it("prefers latest times when canChange=true (falls back to scheduled when no actual)", () => { + const legs = [ + makeLeg(0, { actualDepLocal: "dx0", actualArrLocal: "ax0" }), + makeLeg(1), // no actual, falls back + ]; + render(); + expect(screen.getByText("dx0")).toBeTruthy(); + expect(screen.getByText("ax0")).toBeTruthy(); + // leg 1 has no actuals — falls back to scheduled + expect(screen.getByText("d1")).toBeTruthy(); + expect(screen.getByText("a1")).toBeTruthy(); + }); +}); diff --git a/src/features/online-board/components/FullRouteTimeline/Timeline.tsx b/src/features/online-board/components/FullRouteTimeline/Timeline.tsx new file mode 100644 index 00000000..d8f79583 --- /dev/null +++ b/src/features/online-board/components/FullRouteTimeline/Timeline.tsx @@ -0,0 +1,108 @@ +import { type FC, useState } from "react"; +import type { IFlightLeg } from "../../types.js"; +import { Station } from "./Station.js"; +import { StationChange } from "./StationChange.js"; + +export interface TimelineProps { + legs: IFlightLeg[]; + canChange: boolean; +} + +function depTime(leg: IFlightLeg, canChange: boolean): string { + const t = leg.departure.times; + if (canChange) return t.actualBlockOff?.local ?? t.scheduledDeparture.local; + return t.scheduledDeparture.local; +} + +function arrTime(leg: IFlightLeg, canChange: boolean): string { + const t = leg.arrival.times; + if (canChange) return t.actualBlockOn?.local ?? t.scheduledArrival.local; + return t.scheduledArrival.local; +} + +function isSpecifying(leg: IFlightLeg, canChange: boolean): boolean { + return canChange && leg.estimatedDuration?.isNegative === true; +} + +interface SectionProps { + legNumber: number; + duration: string; + specifying: boolean; +} + +const Section: FC = ({ legNumber, duration, specifying }) => ( + + + {legNumber} + + {duration} + + + +); + +export const Timeline: FC = ({ legs, canChange }) => { + const [index, setIndex] = useState(0); + const lastPairStart = Math.max(0, legs.length - 2); + const currentLeg = legs[index]; + const nextLeg = legs[index + 1]; + + if (!currentLeg || !nextLeg) return null; + + return ( + + {index > 0 && ( + setIndex((i) => Math.max(0, i - 1))} + aria-label="Previous legs" + > + {"\u2039"} + + )} + + + + {depTime(currentLeg, canChange)} + + {arrTime(currentLeg, canChange)} + + {depTime(nextLeg, canChange)} + {arrTime(nextLeg, canChange)} + + + + + + + + + + {index < lastPairStart && ( + setIndex((i) => Math.min(lastPairStart, i + 1))} + aria-label="Next legs" + > + {"\u203a"} + + )} + + ); +};