Add Timeline component with 2-leg carousel for multi-leg flights
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user