Add Timeline component with 2-leg carousel for multi-leg flights

This commit is contained in:
2026-04-17 02:32:42 +03:00
parent dd43ea6905
commit b8197b2db5
2 changed files with 251 additions and 0 deletions
@@ -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(<Timeline legs={legs} canChange={false} />);
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(<Timeline legs={legs} canChange={false} />);
expect(screen.queryByTestId("timeline-prev")).toBeNull();
});
it("does not render next arrow on last pair (2 legs)", () => {
const legs = [makeLeg(0), makeLeg(1)];
render(<Timeline legs={legs} canChange={false} />);
expect(screen.queryByTestId("timeline-next")).toBeNull();
});
it("shows next arrow with 3 legs, clicking advances", () => {
const legs = [makeLeg(0), makeLeg(1), makeLeg(2)];
render(<Timeline legs={legs} canChange={false} />);
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(<Timeline legs={legs} canChange={true} />);
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(<Timeline legs={legs} canChange={true} />);
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();
});
});
@@ -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<SectionProps> = ({ legNumber, duration, specifying }) => (
<div className="timeline-section">
<div className="timeline-section__separator" />
<span className="timeline-section__number">{legNumber}</span>
<span
className={`timeline-section__duration${
specifying ? " timeline-section__duration--specifying" : ""
}`}
>
{duration}
</span>
<div className="timeline-section__separator" />
</div>
);
export const Timeline: FC<TimelineProps> = ({ 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 (
<div className="timeline">
{index > 0 && (
<button
type="button"
className="timeline__arrow"
data-testid="timeline-prev"
onClick={() => setIndex((i) => Math.max(0, i - 1))}
aria-label="Previous legs"
>
{"\u2039"}
</button>
)}
<div className="timeline__content">
<div className="timeline__row timeline__row--times">
<span>{depTime(currentLeg, canChange)}</span>
<Section
legNumber={index + 1}
duration={currentLeg.flyingTime}
specifying={isSpecifying(currentLeg, canChange)}
/>
<span>{arrTime(currentLeg, canChange)}</span>
<Section
legNumber={index + 2}
duration={nextLeg.flyingTime}
specifying={isSpecifying(nextLeg, canChange)}
/>
<span>{depTime(nextLeg, canChange)}</span>
<span>{arrTime(nextLeg, canChange)}</span>
</div>
<div className="timeline__row timeline__row--stations">
<Station station={currentLeg.departure} align="left" size="medium" />
<StationChange from={currentLeg.arrival} to={nextLeg.departure} />
<Station station={nextLeg.arrival} align="right" size="medium" />
</div>
</div>
{index < lastPairStart && (
<button
type="button"
className="timeline__arrow"
data-testid="timeline-next"
onClick={() => setIndex((i) => Math.min(lastPairStart, i + 1))}
aria-label="Next legs"
>
{"\u203a"}
</button>
)}
</div>
);
};