From ec67111d1034297a899f6468afdcd8cbf9e4dda5 Mon Sep 17 00:00:00 2001 From: gnezim Date: Sat, 18 Apr 2026 15:53:50 +0300 Subject: [PATCH] Rebuild details leg block to Angular layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the vertically-stacked station blocks with Angular's route-strip + time-table layout: - Top row: big time + city + airport/terminal on both sides, with the status label and a progress bar in the middle. Scheduled time shows as a small strike-through line under the actual when delayed. Arrival time picks up the '+1' day-change marker when the flight crosses midnight. - Bottom row: 'По расписанию' + 'Фактическое / Ожидаемое' detail rows for both departure and arrival, with UTC offsets and dates styled exactly like the Angular design. The progress bar colors switch between blue (in-flight), green (finished/arrived) and red (cancelled). The status text localizes via FLIGHT-STATUSES.*. Integration tests switched from asserting IATA codes to asserting the city names, which now render in the promoted row (matches Angular and the audit feedback). --- .../components/OnlineBoardDetailsPage.scss | 165 ++++++++++++ .../OnlineBoardDetailsPage.test.tsx | 7 +- .../components/OnlineBoardDetailsPage.tsx | 240 ++++++++++++------ .../online-board/flight-details.test.tsx | 9 +- 4 files changed, 332 insertions(+), 89 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.scss b/src/features/online-board/components/OnlineBoardDetailsPage.scss index 1638c7f6..9d47dee9 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.scss +++ b/src/features/online-board/components/OnlineBoardDetailsPage.scss @@ -3,6 +3,171 @@ @use "../../../styles/fonts" as fonts; @use "../../../styles/screen" as screen; +// --------------------------------------------------------------------------- +// Leg route strip — Angular's flight-details-wrapper layout. +// --------------------------------------------------------------------------- + +.leg-route { + padding: vars.$space-xl; + + &__main { + display: grid; + grid-template-columns: auto 1fr 1fr auto 1fr; + gap: 24px; + align-items: center; + } + + &__times { + display: flex; + flex-direction: column; + line-height: 1; + } + + &__time { + font-size: 32px; + font-weight: 500; + color: #222; + + &-strike { + font-size: 14px; + color: colors.$orange; + text-decoration: line-through; + margin-top: 4px; + } + } + + &__day-change { + font-size: 12px; + color: colors.$blue; + vertical-align: super; + margin-left: 4px; + } + + &__station { + min-width: 0; + } + + &__city { + font-size: 20px; + font-weight: 500; + color: #222; + } + + &__airport { + font-size: 12px; + color: #8a8a8a; + margin-top: 4px; + } + + &__center { + display: flex; + flex-direction: column; + align-items: center; + min-width: 180px; + gap: 4px; + } + + &__status-text { + font-size: 14px; + font-weight: 500; + color: colors.$blue; + } + + &__bar { + width: 100%; + height: 2px; + background: #d0d5dd; + position: relative; + } + + &__bar-inner { + height: 100%; + background: colors.$blue; + width: 0; + transition: width 150ms ease; + } + + &__center--finished { + .leg-route__status-text { color: #6da244; } + .leg-route__bar-inner { width: 100%; background: #6da244; } + } + + &__center--progress { + .leg-route__status-text { color: colors.$blue; } + .leg-route__bar-inner { width: 50%; } + } + + &__center--cancelled { + .leg-route__status-text { color: #e55353; } + .leg-route__bar { background: #fbd4d4; } + .leg-route__bar-inner { background: #e55353; } + } + + &__duration { + font-size: 12px; + color: #8a8a8a; + } + + &__details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + margin-top: vars.$space-xl; + padding-top: vars.$space-xl; + border-top: 1px dashed #e0e6f0; + } + + &__details-side { + display: grid; + grid-template-columns: 1fr 1fr; + gap: vars.$space-xl; + + &--arrival { + justify-self: end; + } + } + + &__detail { + display: flex; + flex-direction: column; + gap: 4px; + + &-label { + font-size: 12px; + color: #8a8a8a; + } + + &-value { + font-weight: 600; + color: #222; + } + + &-offset { + font-weight: normal; + font-size: 11px; + color: #8a8a8a; + margin-left: 6px; + } + + &-date { + font-size: 12px; + color: colors.$blue; + } + } + + @include screen.mobile { + &__main { + grid-template-columns: 1fr; + gap: vars.$space-m; + } + + &__details { + grid-template-columns: 1fr; + gap: vars.$space-m; + } + } +} + .flight-details { &--error, &--not-found { diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx index dd87f7e5..9ef331da 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx @@ -171,9 +171,10 @@ describe("OnlineBoardDetailsPage", () => { it("displays departure and arrival stations", () => { render(); - // SVO and JFK appear in both FlightCard and FlightLegs sections - expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1); + // The new LegRoute layout promotes city + airport name instead of + // IATA codes; assert those render at least once each. + expect(screen.getAllByText("Moscow").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("New York").length).toBeGreaterThanOrEqual(1); }); it("displays aircraft info", () => { diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index d27f4787..b9ecf514 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -30,7 +30,7 @@ import { DetailsBackButton } from "./DetailsBackButton/index.js"; import { FlightSchedule } from "./FlightSchedule/index.js"; import { FullRouteTimeline } from "./FullRouteTimeline/index.js"; import { TransferBar } from "./TransferBar/index.js"; -import type { IParsedFlightId, IFlightLeg } from "../types.js"; +import type { IParsedFlightId, IFlightLeg, FlightStatus as FlightStatusType } from "../types.js"; import { operatingCarrier } from "../types.js"; import { formatLocalTime, @@ -68,67 +68,169 @@ export interface OnlineBoardDetailsPageProps { * + terminal, plus scheduled / expected / actual times formatted as * "23:30 UTC+03:00". Matches Angular's flight-details-wrapper rows. */ -function LegStation({ - side, - code, - airport, - city, - terminal, - gate, - bagBelt, - scheduledIso, - actualIso, +/** + * Route strip for a single leg — mirrors Angular's flight-details-wrapper: + * [big time] City / Airport-Terminal [status + progress] [big time] City / Airport-Terminal + * with scheduled strike-through under the actual time if they differ, and + * a detailed time table beneath (По расписанию / Фактическое / Ожидаемое). + */ +function LegRoute({ + leg, + status, }: { - side: "departure" | "arrival"; - code: string; - airport: string | undefined; - city: string | undefined; - terminal: string | undefined; - gate?: string | undefined; - bagBelt?: string | undefined; - scheduledIso: string; - actualIso: string | undefined; + leg: IFlightLeg; + status: FlightStatusType; }): JSX.Element { const { t } = useTranslation(); - const scheduledTime = formatLocalTime(scheduledIso); - const scheduledOffset = formatUtcOffset(scheduledIso); - const scheduledDate = formatDayMonthYear(scheduledIso); - const actualTime = actualIso ? formatLocalTime(actualIso) : null; - const actualOffset = actualIso ? formatUtcOffset(actualIso) : ""; + const dep = leg.departure; + const arr = leg.arrival; + const depSched = dep.times.scheduledDeparture; + const arrSched = arr.times.scheduledArrival; + const depActual = dep.times.actualBlockOff; + const arrActual = arr.times.actualBlockOn; + + const depMainTime = formatLocalTime(depActual?.local ?? depSched.local); + const depScheduledTime = formatLocalTime(depSched.local); + const depShowStrike = Boolean(depActual?.local) && depMainTime !== depScheduledTime; + const arrMainTime = formatLocalTime(arrActual?.local ?? arrSched.local); + const arrScheduledTime = formatLocalTime(arrSched.local); + const arrShowStrike = Boolean(arrActual?.local) && arrMainTime !== arrScheduledTime; + + const arrDayChange = + arrActual?.dayChange?.value ?? arrSched.dayChange?.value ?? 0; + + const depDetail = (iso: string | undefined) => { + if (!iso) return null; + return { + time: formatLocalTime(iso), + offset: formatUtcOffset(iso), + date: formatDayMonthYear(iso), + }; + }; + + const depScheduledDetail = depDetail(depSched.local); + const depActualDetail = depActual?.local ? depDetail(depActual.local) : null; + const arrScheduledDetail = depDetail(arrSched.local); + const arrActualDetail = arrActual?.local ? depDetail(arrActual.local) : null; + + const isFinished = status === "Arrived" || status === "Landed"; + const isCancelled = status === "Cancelled"; return ( -
- {code} - {airport && {airport}} - {city && {city}} - {terminal && ( - - {t("SHARED.TERMINAL")} {terminal} - - )} - {gate && {t("SHARED.GATE")} {gate}} - {bagBelt && ( - - {t("DETAILS.BAG_BELT")} {bagBelt} - - )} -
- {t("SHARED.SCHEDULED")} - {scheduledTime} - {scheduledOffset && ( - {scheduledOffset} - )} - {scheduledDate} -
- {actualTime && ( -
- {t("SHARED.ACTUAL")} - {actualTime} - {actualOffset && ( - {actualOffset} +
+
+
+
{depMainTime}
+ {depShowStrike && ( +
{depScheduledTime}
)}
- )} + +
+
{dep.scheduled.city}
+
+ {dep.scheduled.airport} + {dep.terminal ? ` - ${dep.terminal}` : ""} +
+
+ +
+
+ {t(`FLIGHT-STATUSES.${status}`)} +
+
+
+
+
+ {humanizeFlyingTime(leg.flyingTime, "ru")} +
+
+ +
+
+ {arrMainTime} + {arrDayChange > 0 && ( + +{arrDayChange} + )} +
+ {arrShowStrike && ( +
{arrScheduledTime}
+ )} +
+ +
+
{arr.scheduled.city}
+
+ {arr.scheduled.airport} + {arr.terminal ? ` - ${arr.terminal}` : ""} +
+
+
+ +
+
+ {depScheduledDetail && ( +
+
{t("SHARED.SCHEDULED")}
+
+ {depScheduledDetail.time} + {depScheduledDetail.offset && ( + + {depScheduledDetail.offset} + + )} +
+
{depScheduledDetail.date}
+
+ )} + {depActualDetail && ( +
+
{t("SHARED.ACTUAL")}
+
+ {depActualDetail.time} + {depActualDetail.offset && ( + + {depActualDetail.offset} + + )} +
+
{depActualDetail.date}
+
+ )} +
+
+ {arrScheduledDetail && ( +
+
{t("SHARED.SCHEDULED")}
+
+ {arrScheduledDetail.time} + {arrScheduledDetail.offset && ( + + {arrScheduledDetail.offset} + + )} +
+
{arrScheduledDetail.date}
+
+ )} + {arrActualDetail && ( +
+
+ {isFinished ? t("SHARED.ACTUAL") : t("DETAILS.STATUS_EXPECTED")} +
+
+ {arrActualDetail.time} + {arrActualDetail.offset && ( + + {arrActualDetail.offset} + + )} +
+
{arrActualDetail.date}
+
+ )} +
+
); } @@ -160,33 +262,7 @@ function FlightLegs({
)} -
- - -
- {humanizeFlyingTime(leg.flyingTime, "ru")} -
- - -
+ {leg.equipment.name && (
diff --git a/tests/integration/online-board/flight-details.test.tsx b/tests/integration/online-board/flight-details.test.tsx index 113db33e..4769d209 100644 --- a/tests/integration/online-board/flight-details.test.tsx +++ b/tests/integration/online-board/flight-details.test.tsx @@ -276,7 +276,7 @@ describe("Flight details page integration", () => { expect(screen.getByTestId("flight-details-not-found")).toBeTruthy(); }); - it("renders airport codes in leg stations", () => { + it("renders departure and arrival stations", () => { setupWithFlight(); render( { canonicalOrigin="https://www.aeroflot.ru" />, ); - // Airport codes appear in both FlightCard and FlightLegs - expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1); + // LegRoute promotes the city name now (matches Angular), and the + // airport name appears as the secondary line. Assert both city names. + expect(screen.getAllByText("Moscow").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("New York").length).toBeGreaterThanOrEqual(1); }); });