diff --git a/src/features/online-board/components/TransferBar/TransferBar.test.tsx b/src/features/online-board/components/TransferBar/TransferBar.test.tsx index e0d4dc52..c4791789 100644 --- a/src/features/online-board/components/TransferBar/TransferBar.test.tsx +++ b/src/features/online-board/components/TransferBar/TransferBar.test.tsx @@ -106,6 +106,13 @@ describe("TransferBar", () => { expect(screen.getByText("11:30")).toBeTruthy(); }); + it("renders SHARED.FLIGHT-TRANSFER-PLURAL-ONE label when isIntermediateLanding=false", () => { + 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(); + expect(screen.getByText("SHARED.FLIGHT-TRANSFER-PLURAL-ONE")).toBeTruthy(); + }); + it("renders TransferTime component", () => { const leg = makeLeg({ arrUtc: "2026-04-17T10:00:00Z" }); const nextLeg = makeLeg({ depUtc: "2026-04-17T11:30:00Z" }); @@ -113,6 +120,13 @@ describe("TransferBar", () => { expect(screen.getByTestId("transfer-time")).toBeTruthy(); }); + it("always renders StationChange (station info shown even for same station)", () => { + const leg = makeLeg({ arrCity: "Moscow", arrCityCode: "MOW", arrAirportCode: "SVO" }); + const nextLeg = makeLeg({ depCity: "Moscow", depCityCode: "MOW", depAirportCode: "SVO" }); + render(); + expect(screen.getByTestId("station-change")).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" }); @@ -127,4 +141,12 @@ describe("TransferBar", () => { const bar = container.querySelector(".transfer-bar"); expect(bar?.className).not.toContain("transfer-bar--separated"); }); + + it("adds --separated class when arrival/departure airports differ", () => { + const leg = makeLeg({ arrCity: "Moscow", arrCityCode: "MOW", arrAirportCode: "SVO" }); + const nextLeg = makeLeg({ depCity: "Saint Petersburg", depCityCode: "LED", depAirportCode: "LED" }); + const { container } = render(); + const bar = container.querySelector(".transfer-bar"); + expect(bar?.className).toContain("transfer-bar--separated"); + }); }); diff --git a/src/features/online-board/components/TransferBar/TransferBar.tsx b/src/features/online-board/components/TransferBar/TransferBar.tsx index 6dbcf180..90e53001 100644 --- a/src/features/online-board/components/TransferBar/TransferBar.tsx +++ b/src/features/online-board/components/TransferBar/TransferBar.tsx @@ -10,25 +10,59 @@ export interface TransferBarProps { leg: IFlightLeg; nextLeg: IFlightLeg; viewType: "Onlineboard" | "Schedule"; + /** + * When true (default) the bar is labelled "Промежуточная посадка" (same + * flight number, multi-segment). When false it is labelled "Пересадка" + * (different flight numbers, connecting). TZ §4.1.15.6 / §4.1.16.7. + */ + isIntermediateLanding?: boolean; } 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; + if (viewType === "Schedule") return t.scheduledArrival.localTime ?? t.scheduledArrival.local; + return (t.actualBlockOn ?? t.estimatedBlockOn ?? t.scheduledArrival).localTime + ?? (t.actualBlockOn ?? t.estimatedBlockOn ?? 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; + if (viewType === "Schedule") return t.scheduledDeparture.localTime ?? t.scheduledDeparture.local; + return (t.actualBlockOff ?? t.estimatedBlockOff ?? t.scheduledDeparture).localTime + ?? (t.actualBlockOff ?? t.estimatedBlockOff ?? t.scheduledDeparture).local; } -export const TransferBar: FC = ({ leg, nextLeg, viewType }) => { +/** + * Pick the most-current UTC timestamp for transfer duration calculation. + * TZ §4.1.15.6: "одно из актуальных на момент отображения времен расписанию, + * ожидаемому, фактическому по каждой позиции вылет/прилет." + */ +function arrivalUtc(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string { + const t = leg.arrival.times; + if (viewType === "Schedule") return t.scheduledArrival.utc; + return (t.actualBlockOn ?? t.estimatedBlockOn ?? t.scheduledArrival).utc; +} + +function departureUtc(leg: IFlightLeg, viewType: "Onlineboard" | "Schedule"): string { + const t = leg.departure.times; + if (viewType === "Schedule") return t.scheduledDeparture.utc; + return (t.actualBlockOff ?? t.estimatedBlockOff ?? t.scheduledDeparture).utc; +} + +export const TransferBar: FC = ({ + leg, + nextLeg, + viewType, + isIntermediateLanding = true, +}) => { const { t } = useTranslation(); const stationChange = detectStationChange(leg.arrival, nextLeg.departure); const separated = stationChange !== "noChange"; + const labelKey = isIntermediateLanding + ? "SHARED.INTERMEDIATE-LANDING-PLURAL-ONE" + : "SHARED.FLIGHT-TRANSFER-PLURAL-ONE"; + const className = `transfer-bar${separated ? " transfer-bar--separated" : ""}`; return ( @@ -39,11 +73,11 @@ export const TransferBar: FC = ({ leg, nextLeg, viewType }) =>
- {t("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")} + {t(labelKey)}
@@ -51,11 +85,9 @@ export const TransferBar: FC = ({ leg, nextLeg, viewType }) => {departureLocal(nextLeg, viewType)}
- {separated && ( -
- -
- )} +
+ +
); diff --git a/src/features/schedule/components/ScheduleFlightBody.test.tsx b/src/features/schedule/components/ScheduleFlightBody.test.tsx index a1af0854..d7fb697a 100644 --- a/src/features/schedule/components/ScheduleFlightBody.test.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.test.tsx @@ -478,4 +478,44 @@ describe("ScheduleFlightBody – TZ §4.1.14.4", () => { expect(onStatus).toHaveBeenCalledOnce(); }); }); + + // ──────────────────────────────────────────────────────────────────────────── + // TZ §4.1.16.7 — Intermediate landing vs transfer duration (UTC fix) + // ──────────────────────────────────────────────────────────────────────────── + + describe("Transfer duration – TZ §4.1.16.7 (UTC-based, not local)", () => { + it("computes ground time from UTC (90 min = 1ч. 30мин.)", () => { + // leg1 arr UTC: 10:00Z, leg2 dep UTC: 11:30Z → 90 min + const depUtc = "2026-08-10T08:00:00Z"; + const midUtc = "2026-08-10T10:00:00Z"; // arr leg1 + const midNextUtc = "2026-08-10T11:30:00Z"; // dep leg2 + const arrUtc = "2026-08-10T14:30:00Z"; + const flight: ISimpleFlight = { + routeType: "MultiLeg", + id: "duration-test", + flyingTime: "06:30:00", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "9999", date: "2026-08-10" }, + operatingBy: {}, + legs: [ + makeLeg(depUtc, "11:00", midUtc, "13:00", "SVO", "KJA"), + makeLeg(midNextUtc, "14:30", arrUtc, "17:30", "KJA", "LED"), + ], + } as unknown as ISimpleFlight; + render(); + // 90 min = "1ч. 30мин." (ru locale) + const transfer = screen.getByTestId("flight-transfer"); + expect(transfer.textContent).toContain("1ч. 30мин."); + }); + + it("label is Промежуточная посадка for same flight-number multi-leg (§4.1.16.7)", () => { + render(); + expect(screen.getByText("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")).toBeTruthy(); + }); + + it("label is Пересадка for connecting (different flight numbers) (§4.1.16.7)", () => { + render(); + expect(screen.getByText("SHARED.FLIGHT-TRANSFER")).toBeTruthy(); + }); + }); }); diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx index 013ccff4..afb8c5c7 100644 --- a/src/features/schedule/components/ScheduleFlightBody.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -67,10 +67,14 @@ function formatDuration(value: string | undefined, language: string): string { return isRu ? `${h}ч. ${m}мин.` : `${h}h ${m}m`; } -/** Compute ground-transfer minutes between two consecutive legs. */ +/** + * Compute ground-transfer duration string between two consecutive legs. + * Uses UTC timestamps (not local, which lack timezone) for correct diff. + * TZ §4.1.16.7: duration = departure UTC − arrival UTC. + */ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string { - const prevArr = prev.arrival.times.scheduledArrival.local; - const nextDep = next.departure.times.scheduledDeparture.local; + const prevArr = prev.arrival.times.scheduledArrival.utc; + const nextDep = next.departure.times.scheduledDeparture.utc; if (!prevArr || !nextDep) return ""; const a = new Date(prevArr).getTime(); const b = new Date(nextDep).getTime();