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