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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user