Render previous-flight chip as link per TZ §4.1.15.9

Angular rule: show the previous-flight identifier as a clickable link
opening the prior flight's details in a new browser tab, gated on the
flight's scheduled departure being > today − 2 days old. Outside that
window it falls back to plain text to avoid stale cross-links.

Threads locale + departureDateLocal from OnlineBoardDetailsPage through
FlightLegs → FlightDetailsAccordion → AircraftPanel. URL is built with
the existing buildFlightUrlParams helper using previousFlight.localDate,
matching Angular's dateToSearchBy = new Date(prevFlight.localDate).
This commit is contained in:
2026-04-22 00:17:43 +03:00
parent e33c8c4b24
commit 1740af682c
4 changed files with 238 additions and 10 deletions
@@ -301,9 +301,11 @@ function LegRoute({
function FlightLegs({
legs,
viewType,
locale,
}: {
legs: IFlightLeg[];
viewType: "Onlineboard" | "Schedule";
locale?: string;
}): JSX.Element {
const { t } = useTranslation();
const showLegHeaders = legs.length > 1;
@@ -334,7 +336,7 @@ function FlightLegs({
</div>
)}
<FlightDetailsAccordion leg={leg} viewType="Onlineboard" />
<FlightDetailsAccordion leg={leg} viewType="Onlineboard" {...(locale !== undefined ? { locale } : {})} />
</div>
{(() => {
const next = legs[i + 1];
@@ -660,7 +662,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
{/* Detailed leg information */}
<FlightLegs legs={legs} viewType="Onlineboard" />
<FlightLegs legs={legs} viewType="Onlineboard" locale={locale} />
{/* Angular keeps the total flying time inside the FlightSchedule
collapsible block; we used to render a separate line above it
@@ -8,6 +8,13 @@ vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// Build an ISO date string that is N days from today (negative = past)
function isoDateDaysFromNow(n: number): string {
const d = new Date();
d.setDate(d.getDate() + n);
return d.toISOString();
}
describe("AircraftPanel", () => {
// The aircraft title (e.g. 'Airbus A321') is rendered by the wrapping
// FlightDetailsAccordion row caption, not by the panel itself — Angular
@@ -59,4 +66,134 @@ describe("AircraftPanel", () => {
render(<AircraftPanel equipment={eq} />);
expect(screen.getByTestId("aircraft-panel")).toBeTruthy();
});
// §4.1.15.9: previous-flight chip must be a link (opens new tab) when the
// flight's scheduled departure date is more recent than today 2 days.
describe("previous-flight link (§4.1.15.9)", () => {
const previousFlight = {
localDate: "2026-04-20",
carrier: "SU",
flightNumber: "6805",
date: "2026-04-20",
};
it("renders an <a target=_blank> link when departure is within the last 2 days", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight,
},
};
// departureDateLocal set to today (within the 2-day window)
render(
<AircraftPanel
equipment={eq}
locale="ru-ru"
departureDateLocal={isoDateDaysFromNow(0)}
/>,
);
const link = screen.getByRole("link", { name: "SU 6805" });
expect(link).toBeTruthy();
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("href")).toContain("ru-ru/onlineboard");
expect(link.getAttribute("href")).toContain("SU6805");
});
it("includes the correct date (localDate of previousFlight) in the href", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight: {
localDate: "2026-04-20",
carrier: "SU",
flightNumber: "0100",
date: "2026-04-20",
},
},
};
render(
<AircraftPanel
equipment={eq}
locale="ru-ru"
departureDateLocal={isoDateDaysFromNow(0)}
/>,
);
const link = screen.getByRole("link");
// Date in href must be the compact localDate of the previous flight
expect(link.getAttribute("href")).toContain("20260420");
});
it("renders plain text (no link) when departure is older than 2 days", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight,
},
};
// departureDateLocal 10 days ago — outside the 2-day window
render(
<AircraftPanel
equipment={eq}
locale="ru-ru"
departureDateLocal={isoDateDaysFromNow(-10)}
/>,
);
expect(screen.getByText("SU 6805")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("renders plain text when locale is not provided", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight,
},
};
render(
<AircraftPanel
equipment={eq}
departureDateLocal={isoDateDaysFromNow(0)}
/>,
);
expect(screen.getByText("SU 6805")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("renders plain text when departureDateLocal is not provided", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight,
},
};
render(<AircraftPanel equipment={eq} locale="ru-ru" />);
expect(screen.getByText("SU 6805")).toBeTruthy();
expect(screen.queryByRole("link")).toBeNull();
});
it("includes suffix in the link label and href when present", () => {
const eq: IEquipmentFull = {
aircraft: {
actual: { title: "A320" },
previousFlight: {
localDate: "2026-04-20",
carrier: "SU",
flightNumber: "1234",
suffix: "A",
date: "2026-04-20",
},
},
};
render(
<AircraftPanel
equipment={eq}
locale="ru-ru"
departureDateLocal={isoDateDaysFromNow(0)}
/>,
);
const link = screen.getByRole("link", { name: "SU 1234A" });
expect(link).toBeTruthy();
expect(link.getAttribute("href")).toContain("1234A");
});
});
});
@@ -1,10 +1,19 @@
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { IEquipmentFull, ISeat, SeatType } from "../../types.js";
import type { IEquipmentFull, ISeat, SeatType, IPreviousFlight } from "../../types.js";
import { buildFlightUrlParams } from "../../url.js";
import "./panels.scss";
export interface AircraftPanelProps {
equipment: IEquipmentFull;
/** Current locale, e.g. "ru-ru". Used to build the previous-flight link. */
locale?: string;
/**
* Local ISO date-time string for the flight's scheduled departure.
* Used to gate whether the previous-flight chip is rendered as a link
* (§4.1.15.9: show as link when scheduled departure date > today 2 days).
*/
departureDateLocal?: string;
}
function seatsByType(seats: ISeat[] | undefined, type: SeatType): number {
@@ -19,13 +28,61 @@ function totalSeats(seats: ISeat[] | undefined): number {
return seats.reduce((acc, s) => acc + s.count, 0);
}
/**
* Returns true when the previous-flight chip should be an interactive link.
*
* Angular rule (§4.1.15.9 / `shoudShowPreviosFlight`): render the chip as a
* link when the flight's scheduled-departure *date* is strictly after
* (today 2 days). For older flights the chip shows as plain text so the
* stale cross-link doesn't navigate to a potentially retired board page.
*/
function shouldShowPreviousFlightLink(departureDateLocal: string | undefined): boolean {
if (!departureDateLocal) return false;
// Extract local calendar date from the ISO string ("2026-04-21T10:30:00+03:00")
const localDateStr = departureDateLocal.slice(0, 10); // "YYYY-MM-DD"
const scheduledDate = new Date(localDateStr);
if (isNaN(scheduledDate.getTime())) return false;
const now = new Date();
const threshold = new Date(now.getFullYear(), now.getMonth(), now.getDate());
threshold.setDate(threshold.getDate() - 2);
return scheduledDate > threshold;
}
/**
* Build the absolute URL path for the previous flight's details card.
* Output: `/{locale}/onlineboard/{carrier}{flightNumber}-{yyyyMMdd}`
* The date used is `previousFlight.localDate` (local departure date of the
* previous flight), matching Angular's `dateToSearchBy = new Date(prevFlight.localDate)`.
*/
function buildPreviousFlightHref(
prev: IPreviousFlight,
locale: string,
): string {
// localDate may be "YYYY-MM-DD" or ISO; compact it to "yyyyMMdd".
const compactDate = prev.localDate.slice(0, 10).replace(/-/g, "");
const id: Parameters<typeof buildFlightUrlParams>[0] = {
carrier: prev.carrier,
flightNumber: prev.flightNumber,
date: compactDate,
};
if (prev.suffix !== undefined) id.suffix = prev.suffix;
const urlSegment = buildFlightUrlParams(id);
return `/${locale}/onlineboard/${urlSegment}`;
}
/**
* Aircraft panel — matches Angular's flight-details-airplane layout:
* properties arranged horizontally (Название | Количество мест | Эконом |
* Комфорт | Бизнес | Предыдущий рейс). The aircraft title/model link is
* rendered by the wrapping accordion row caption.
*/
export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
export const AircraftPanel: FC<AircraftPanelProps> = ({
equipment,
locale,
departureDateLocal,
}) => {
const { t } = useTranslation();
const aircraftInfo = equipment.aircraft;
const aircraft = aircraftInfo?.actual ?? aircraftInfo?.scheduled;
@@ -39,7 +96,21 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
? `${previous.carrier} ${previous.flightNumber}${previous.suffix ?? ""}`
: null;
const props: Array<{ label: string; value: string | number; className?: string }> = [];
const showLink =
!!previous &&
!!locale &&
shouldShowPreviousFlightLink(departureDateLocal);
const previousHref =
showLink && previous && locale
? buildPreviousFlightHref(previous, locale)
: null;
const props: Array<{
label: string;
value: ReactNode;
className?: string;
}> = [];
if (aircraft?.name) props.push({ label: t("AIRPLANE.NAME"), value: aircraft.name });
if (total > 0) props.push({ label: t("AIRPLANE.SEATS-TOTAL"), value: total });
if (economy > 0) props.push({ label: t("AIRPLANE.SEATS-ECONOMY"), value: economy });
@@ -48,8 +119,18 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
if (previousLabel) {
props.push({
label: t("BOARD.PREVIOUS-FLIGHT"),
value: previousLabel,
className: "aircraft-panel__prop--link",
value: previousHref ? (
<a
href={previousHref}
target="_blank"
rel="noreferrer"
className="aircraft-panel__previous-link"
>
{previousLabel}
</a>
) : (
previousLabel
),
});
}
@@ -21,6 +21,8 @@ import "./FlightDetailsAccordion.scss";
export interface FlightDetailsAccordionProps {
leg: IFlightLeg;
viewType: DetailsViewType;
/** Current locale (e.g. "ru-ru"). Forwarded to AircraftPanel for previous-flight link. */
locale?: string;
}
interface RowDef {
@@ -134,7 +136,7 @@ function TransitionTimes({
);
}
export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, viewType }) => {
export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, viewType, locale }) => {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
@@ -184,7 +186,13 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
icon: ICON_AIRCRAFT,
title: t("SHARED.PLANE"),
subtitle: title,
body: <AircraftPanel equipment={leg.equipment} />,
body: (
<AircraftPanel
equipment={leg.equipment}
{...(locale !== undefined ? { locale } : {})}
departureDateLocal={leg.departure.times.scheduledDeparture.local}
/>
),
});
}
const meals = leg.equipment.meal;