Add TransferBar component for multi-leg transfer info

This commit is contained in:
2026-04-17 02:31:42 +03:00
parent 01b2981407
commit dd43ea6905
4 changed files with 239 additions and 0 deletions
@@ -0,0 +1,45 @@
.transfer-bar {
display: flex;
align-items: center;
gap: 16px;
background: #e3f0ff;
border: 1px solid #b8d4f0;
padding: 12px 16px;
min-height: 50px;
font-size: 13px;
&--separated {
margin: 12px 0;
border-radius: 3px;
}
&__icon svg {
fill: #ff9000;
width: 20px;
height: 20px;
}
&__content {
flex: 1;
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
&__type { font-weight: 500; }
&__times { color: #1a3a5c; }
@media (max-width: 768px) {
&__content {
flex-direction: column;
align-items: flex-start;
}
}
}
.transfer-time {
font-weight: 500;
color: #ff9000;
}
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { IFlightLeg } from "../../types.js";
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock("../FullRouteTimeline/StationChange.js", () => ({
StationChange: ({ from, to }: { from: { scheduled: { cityCode: string } }; to: { scheduled: { cityCode: string } } }) => (
<div data-testid="station-change">{from.scheduled.cityCode}-{to.scheduled.cityCode}</div>
),
}));
import { TransferBar } from "./TransferBar.js";
function makeLeg(overrides: {
arrCity?: string;
depCity?: string;
arrCityCode?: string;
depCityCode?: string;
arrAirportCode?: string;
depAirportCode?: string;
arrUtc?: string;
depUtc?: string;
arrLocal?: string;
depLocal?: string;
} = {}): IFlightLeg {
const arrCity = overrides.arrCity ?? "Moscow";
const depCity = overrides.depCity ?? "Moscow";
const arrCityCode = overrides.arrCityCode ?? "MOW";
const depCityCode = overrides.depCityCode ?? "MOW";
const arrAirportCode = overrides.arrAirportCode ?? "SVO";
const depAirportCode = overrides.depAirportCode ?? "SVO";
const arrUtc = overrides.arrUtc ?? "2026-04-17T10:00:00Z";
const depUtc = overrides.depUtc ?? "2026-04-17T11:30:00Z";
const arrLocal = overrides.arrLocal ?? "10:00";
const depLocal = overrides.depLocal ?? "11:30";
return {
arrival: {
scheduled: {
airport: "",
airportCode: arrAirportCode,
city: arrCity,
cityCode: arrCityCode,
countryCode: "RU",
},
terminal: "",
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: arrLocal,
localTime: arrLocal,
tzOffset: 0,
utc: arrUtc,
},
},
},
departure: {
scheduled: {
airport: "",
airportCode: depAirportCode,
city: depCity,
cityCode: depCityCode,
countryCode: "RU",
},
terminal: "",
checkingStatus: "",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: depLocal,
localTime: depLocal,
tzOffset: 0,
utc: depUtc,
},
},
},
dayChange: 0,
equipment: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
flyingTime: "1h 30m",
index: 0,
operatingBy: {},
status: "Scheduled",
updated: "",
} as IFlightLeg;
}
describe("TransferBar", () => {
it("renders with data-testid=transfer-bar", () => {
const leg = makeLeg({ arrUtc: "2026-04-17T10:00:00Z", arrLocal: "10:00" });
const nextLeg = makeLeg({ depUtc: "2026-04-17T11:30:00Z", depLocal: "11:30" });
render(<TransferBar leg={leg} nextLeg={nextLeg} viewType="Schedule" />);
expect(screen.getByTestId("transfer-bar")).toBeTruthy();
});
it("renders SHARED.INTERMEDIATE-LANDING label and both times", () => {
const leg = makeLeg({ arrLocal: "10:00", arrUtc: "2026-04-17T10:00:00Z" });
const nextLeg = makeLeg({ depLocal: "11:30", depUtc: "2026-04-17T11:30:00Z" });
render(<TransferBar leg={leg} nextLeg={nextLeg} viewType="Schedule" />);
expect(screen.getByText("SHARED.INTERMEDIATE-LANDING")).toBeTruthy();
expect(screen.getByText("10:00")).toBeTruthy();
expect(screen.getByText("11:30")).toBeTruthy();
});
it("renders TransferTime component", () => {
const leg = makeLeg({ arrUtc: "2026-04-17T10:00:00Z" });
const nextLeg = makeLeg({ depUtc: "2026-04-17T11:30:00Z" });
render(<TransferBar leg={leg} nextLeg={nextLeg} viewType="Schedule" />);
expect(screen.getByTestId("transfer-time")).toBeTruthy();
});
it("renders StationChange when arrival/departure cities differ", () => {
const leg = makeLeg({ arrCity: "Moscow", arrCityCode: "MOW" });
const nextLeg = makeLeg({ depCity: "Saint Petersburg", depCityCode: "LED", depAirportCode: "LED" });
render(<TransferBar leg={leg} nextLeg={nextLeg} viewType="Schedule" />);
expect(screen.getByTestId("station-change")).toBeTruthy();
});
it("omits --separated class when arrival/departure cities match", () => {
const leg = makeLeg({ arrCity: "Moscow", arrCityCode: "MOW", arrAirportCode: "SVO" });
const nextLeg = makeLeg({ depCity: "Moscow", depCityCode: "MOW", depAirportCode: "SVO" });
const { container } = render(<TransferBar leg={leg} nextLeg={nextLeg} viewType="Schedule" />);
const bar = container.querySelector(".transfer-bar");
expect(bar?.className).not.toContain("transfer-bar--separated");
});
});
@@ -0,0 +1,62 @@
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { IFlightLeg } from "../../types.js";
import { StationChange } from "../FullRouteTimeline/StationChange.js";
import { detectStationChange } from "../FullRouteTimeline/detectStationChange.js";
import { TransferTime } from "./TransferTime.js";
import "./TransferBar.scss";
export interface TransferBarProps {
leg: IFlightLeg;
nextLeg: IFlightLeg;
viewType: "Onlineboard" | "Schedule";
}
function arrivalLocal(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string {
const t = leg.arrival.times;
if (viewType === "Schedule") return t.scheduledArrival.local;
return t.actualBlockOn?.local ?? t.scheduledArrival.local;
}
function departureLocal(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string {
const t = leg.departure.times;
if (viewType === "Schedule") return t.scheduledDeparture.local;
return t.actualBlockOff?.local ?? t.scheduledDeparture.local;
}
export const TransferBar: FC<TransferBarProps> = ({ leg, nextLeg, viewType }) => {
const { t } = useTranslation();
const stationChange = detectStationChange(leg.arrival, nextLeg.departure);
const separated = stationChange !== "noChange";
const className = `transfer-bar${separated ? " transfer-bar--separated" : ""}`;
return (
<div className={className} data-testid="transfer-bar">
<div className="transfer-bar__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M3 12h15l-4-4m4 4-4 4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="transfer-bar__content">
<span className="transfer-bar__type">{t("SHARED.INTERMEDIATE-LANDING")}</span>
<div className="transfer-bar__duration">
<TransferTime
arrivalUtc={leg.arrival.times.scheduledArrival.utc}
departureUtc={nextLeg.departure.times.scheduledDeparture.utc}
/>
</div>
<div className="transfer-bar__times">
<span>{arrivalLocal(leg, viewType)}</span>
<span> </span>
<span>{departureLocal(nextLeg, viewType)}</span>
</div>
{separated && (
<div className="transfer-bar__station-change">
<StationChange from={leg.arrival} to={nextLeg.departure} />
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,2 @@
export { TransferBar } from "./TransferBar.js";
export type { TransferBarProps } from "./TransferBar.js";