Rebuild details leg block to Angular layout

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).
This commit is contained in:
2026-04-18 15:53:50 +03:00
parent ceeae1a7b1
commit ec67111d10
4 changed files with 332 additions and 89 deletions
@@ -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 {
@@ -171,9 +171,10 @@ describe("OnlineBoardDetailsPage", () => {
it("displays departure and arrival stations", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
// 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", () => {
@@ -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 (
<div className={`flight-details__leg-${side}`}>
<span className="flight-details__station-code">{code}</span>
{airport && <span className="flight-details__station-name">{airport}</span>}
{city && <span className="flight-details__station-city">{city}</span>}
{terminal && (
<span className="flight-details__terminal">
{t("SHARED.TERMINAL")} {terminal}
</span>
)}
{gate && <span className="flight-details__gate">{t("SHARED.GATE")} {gate}</span>}
{bagBelt && (
<span className="flight-details__bag-belt">
{t("DETAILS.BAG_BELT")} {bagBelt}
</span>
)}
<div className="flight-details__time-row">
<span className="flight-details__time-label">{t("SHARED.SCHEDULED")}</span>
<span className="flight-details__time-value">{scheduledTime}</span>
{scheduledOffset && (
<span className="flight-details__time-offset">{scheduledOffset}</span>
)}
<span className="flight-details__time-date">{scheduledDate}</span>
</div>
{actualTime && (
<div className="flight-details__time-row flight-details__time-row--actual">
<span className="flight-details__time-label">{t("SHARED.ACTUAL")}</span>
<span className="flight-details__time-value">{actualTime}</span>
{actualOffset && (
<span className="flight-details__time-offset">{actualOffset}</span>
<div className="leg-route">
<div className="leg-route__main">
<div className="leg-route__times leg-route__times--departure">
<div className="leg-route__time">{depMainTime}</div>
{depShowStrike && (
<div className="leg-route__time-strike">{depScheduledTime}</div>
)}
</div>
)}
<div className="leg-route__station leg-route__station--departure">
<div className="leg-route__city">{dep.scheduled.city}</div>
<div className="leg-route__airport">
{dep.scheduled.airport}
{dep.terminal ? ` - ${dep.terminal}` : ""}
</div>
</div>
<div className={`leg-route__center leg-route__center--${isFinished ? "finished" : isCancelled ? "cancelled" : "progress"}`}>
<div className="leg-route__status-text">
{t(`FLIGHT-STATUSES.${status}`)}
</div>
<div className="leg-route__bar">
<div className="leg-route__bar-inner" />
</div>
<div className="leg-route__duration">
{humanizeFlyingTime(leg.flyingTime, "ru")}
</div>
</div>
<div className="leg-route__times leg-route__times--arrival">
<div className="leg-route__time">
{arrMainTime}
{arrDayChange > 0 && (
<span className="leg-route__day-change">+{arrDayChange}</span>
)}
</div>
{arrShowStrike && (
<div className="leg-route__time-strike">{arrScheduledTime}</div>
)}
</div>
<div className="leg-route__station leg-route__station--arrival">
<div className="leg-route__city">{arr.scheduled.city}</div>
<div className="leg-route__airport">
{arr.scheduled.airport}
{arr.terminal ? ` - ${arr.terminal}` : ""}
</div>
</div>
</div>
<div className="leg-route__details">
<div className="leg-route__details-side">
{depScheduledDetail && (
<div className="leg-route__detail">
<div className="leg-route__detail-label">{t("SHARED.SCHEDULED")}</div>
<div className="leg-route__detail-value">
{depScheduledDetail.time}
{depScheduledDetail.offset && (
<span className="leg-route__detail-offset">
{depScheduledDetail.offset}
</span>
)}
</div>
<div className="leg-route__detail-date">{depScheduledDetail.date}</div>
</div>
)}
{depActualDetail && (
<div className="leg-route__detail">
<div className="leg-route__detail-label">{t("SHARED.ACTUAL")}</div>
<div className="leg-route__detail-value">
{depActualDetail.time}
{depActualDetail.offset && (
<span className="leg-route__detail-offset">
{depActualDetail.offset}
</span>
)}
</div>
<div className="leg-route__detail-date">{depActualDetail.date}</div>
</div>
)}
</div>
<div className="leg-route__details-side leg-route__details-side--arrival">
{arrScheduledDetail && (
<div className="leg-route__detail">
<div className="leg-route__detail-label">{t("SHARED.SCHEDULED")}</div>
<div className="leg-route__detail-value">
{arrScheduledDetail.time}
{arrScheduledDetail.offset && (
<span className="leg-route__detail-offset">
{arrScheduledDetail.offset}
</span>
)}
</div>
<div className="leg-route__detail-date">{arrScheduledDetail.date}</div>
</div>
)}
{arrActualDetail && (
<div className="leg-route__detail">
<div className="leg-route__detail-label">
{isFinished ? t("SHARED.ACTUAL") : t("DETAILS.STATUS_EXPECTED")}
</div>
<div className="leg-route__detail-value">
{arrActualDetail.time}
{arrActualDetail.offset && (
<span className="leg-route__detail-offset">
{arrActualDetail.offset}
</span>
)}
</div>
<div className="leg-route__detail-date">{arrActualDetail.date}</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -160,33 +262,7 @@ function FlightLegs({
</div>
)}
<div className="flight-details__leg-stations">
<LegStation
side="departure"
code={leg.departure.scheduled.airportCode}
airport={leg.departure.scheduled.airport}
city={leg.departure.scheduled.city}
terminal={leg.departure.terminal}
gate={leg.departure.gate}
scheduledIso={leg.departure.times.scheduledDeparture.local}
actualIso={leg.departure.times.actualBlockOff?.local}
/>
<div className="flight-details__leg-duration">
<span>{humanizeFlyingTime(leg.flyingTime, "ru")}</span>
</div>
<LegStation
side="arrival"
code={leg.arrival.scheduled.airportCode}
airport={leg.arrival.scheduled.airport}
city={leg.arrival.scheduled.city}
terminal={leg.arrival.terminal}
bagBelt={leg.arrival.bagBelt}
scheduledIso={leg.arrival.times.scheduledArrival.local}
actualIso={leg.arrival.times.actualBlockOn?.local}
/>
</div>
<LegRoute leg={leg} status={leg.status} />
{leg.equipment.name && (
<div className="flight-details__aircraft">
@@ -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(
<OnlineBoardDetailsPage
@@ -285,8 +285,9 @@ describe("Flight details page integration", () => {
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);
});
});