diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index 082618d9..13b8c8db 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -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({ )} - + {(() => { const next = legs[i + 1]; @@ -660,7 +662,7 @@ export const OnlineBoardDetailsPage: FC = ({ {/* Detailed leg information */} - + {/* Angular keeps the total flying time inside the FlightSchedule collapsible block; we used to render a separate line above it diff --git a/src/features/online-board/components/details-panels/AircraftPanel.test.tsx b/src/features/online-board/components/details-panels/AircraftPanel.test.tsx index 0123edaf..138cb6a4 100644 --- a/src/features/online-board/components/details-panels/AircraftPanel.test.tsx +++ b/src/features/online-board/components/details-panels/AircraftPanel.test.tsx @@ -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(); 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 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + const link = screen.getByRole("link", { name: "SU 1234A" }); + expect(link).toBeTruthy(); + expect(link.getAttribute("href")).toContain("1234A"); + }); + }); }); diff --git a/src/features/online-board/components/details-panels/AircraftPanel.tsx b/src/features/online-board/components/details-panels/AircraftPanel.tsx index b7f9a822..0d9c25f5 100644 --- a/src/features/online-board/components/details-panels/AircraftPanel.tsx +++ b/src/features/online-board/components/details-panels/AircraftPanel.tsx @@ -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[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 = ({ equipment }) => { +export const AircraftPanel: FC = ({ + equipment, + locale, + departureDateLocal, +}) => { const { t } = useTranslation(); const aircraftInfo = equipment.aircraft; const aircraft = aircraftInfo?.actual ?? aircraftInfo?.scheduled; @@ -39,7 +96,21 @@ export const AircraftPanel: FC = ({ 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 = ({ equipment }) => { if (previousLabel) { props.push({ label: t("BOARD.PREVIOUS-FLIGHT"), - value: previousLabel, - className: "aircraft-panel__prop--link", + value: previousHref ? ( + + {previousLabel} + + ) : ( + previousLabel + ), }); } diff --git a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx index 0f4ce0ec..6229aa4a 100644 --- a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx +++ b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx @@ -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 = ({ leg, viewType }) => { +export const FlightDetailsAccordion: FC = ({ leg, viewType, locale }) => { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState(false); @@ -184,7 +186,13 @@ export const FlightDetailsAccordion: FC = ({ leg, v icon: ICON_AIRCRAFT, title: t("SHARED.PLANE"), subtitle: title, - body: , + body: ( + + ), }); } const meals = leg.equipment.meal;