diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
index f8b20634..a95f1052 100644
--- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
+++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
@@ -127,6 +127,11 @@ vi.mock("@modern-js/runtime/router", () => ({
useSearchParams: () => [mockSearchParamsInstance],
}));
+vi.mock("@/shared/hooks/useDictionaries.js", () => ({
+ useCityName: (code: string) => code,
+ useStationDisplayName: (code: string) => code,
+}));
+
const mockUseOnlineBoard = vi.fn((_params: SearchFlightsParams) => ({
flights: [],
loading: false,
@@ -392,41 +397,104 @@ describe("OnlineBoardDetailsPage", () => {
});
});
- describe("Breadcrumb trail (Angular parity, never appends a leaf)", () => {
- function assertTwoCrumbsNoLeaf(container: HTMLElement) {
+ describe("Breadcrumb trail (Angular parity)", () => {
+ function getCrumbLink(container: HTMLElement, key: string): HTMLAnchorElement | null {
const nav = container.querySelector("[data-testid='breadcrumbs']");
- expect(nav).toBeTruthy();
- const items = nav!.querySelectorAll(".breadcrumbs__item");
- // Home + BOARD.TITLE only — Angular's trail stops here regardless
- // of how the user reached the details page.
- expect(items.length).toBe(2);
- expect(nav!.textContent).not.toContain("BREADCRUMBS.FLIGHT-NUMBER");
- expect(nav!.textContent).not.toContain("BREADCRUMBS.ROUTE");
- expect(nav!.textContent).not.toContain("BREADCRUMBS.DEPARTURE");
- expect(nav!.textContent).not.toContain("BREADCRUMBS.ARRIVAL");
+ if (!nav) return null;
+ const anchors = Array.from(nav.querySelectorAll("a"));
+ return (anchors.find((a) => a.textContent?.includes(key)) as HTMLAnchorElement) ?? null;
+ }
+ function countItems(container: HTMLElement) {
+ return container.querySelectorAll("[data-testid='breadcrumbs'] .breadcrumbs__item").length;
}
- const requestSamples = [
- "request=onlineboard-flight-SU1234-20260515",
- "request=onlineboard-route-MOW-LED-20260515",
- "request=onlineboard-route-MOW-LED-20260515-09001800",
- "request=onlineboard-departure-SVO-20260515",
- "request=onlineboard-arrival-SVO-20260515",
- "",
- ];
- for (const qs of requestSamples) {
- it(`renders only [Home, Онлайн-Табло] when search params are: ${qs || "(empty)"}`, () => {
- mockSearchParamsInstance = new URLSearchParams(qs);
- const { container } = render(
- ,
- );
- assertTwoCrumbsNoLeaf(container);
- });
- }
+ it("share-link (no ?request=) → only [Home, Онлайн-Табло], no leaf", () => {
+ mockSearchParamsInstance = new URLSearchParams();
+ const { container } = render(
+ ,
+ );
+ expect(countItems(container)).toBe(2);
+ const nav = container.querySelector("[data-testid='breadcrumbs']")!;
+ expect(nav.textContent).not.toContain("BREADCRUMBS.FLIGHT-NUMBER");
+ expect(nav.textContent).not.toContain("BREADCRUMBS.ROUTE");
+ expect(nav.textContent).not.toContain("BREADCRUMBS.DEPARTURE");
+ expect(nav.textContent).not.toContain("BREADCRUMBS.ARRIVAL");
+ });
+
+ it("flight context → leaf 'Рейс: …' linking back to /flight/", () => {
+ mockSearchParamsInstance = new URLSearchParams("request=onlineboard-flight-SU1234-20260515");
+ const { container } = render(
+ ,
+ );
+ expect(countItems(container)).toBe(3);
+ const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER");
+ expect(link).toBeTruthy();
+ expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU1234-20260515");
+ });
+
+ it("route context → leaf 'Маршрут: …' linking back to /route/", () => {
+ mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515");
+ const { container } = render(
+ ,
+ );
+ expect(countItems(container)).toBe(3);
+ const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
+ expect(link).toBeTruthy();
+ expect(link?.getAttribute("href")).toContain("/ru/onlineboard/route/MOW-LED-20260515");
+ });
+
+ it("route context with time range → back URL keeps time range suffix", () => {
+ mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515-09001800");
+ const { container } = render(
+ ,
+ );
+ const link = getCrumbLink(container, "BREADCRUMBS.ROUTE");
+ expect(link?.getAttribute("href")).toContain("09001800");
+ });
+
+ it("departure context → leaf 'Вылет: …' linking back to /departure/", () => {
+ mockSearchParamsInstance = new URLSearchParams("request=onlineboard-departure-SVO-20260515");
+ const { container } = render(
+ ,
+ );
+ const link = getCrumbLink(container, "BREADCRUMBS.DEPARTURE");
+ expect(link).toBeTruthy();
+ expect(link?.getAttribute("href")).toContain("/ru/onlineboard/departure/SVO-20260515");
+ });
+
+ it("arrival context → leaf 'Прилет: …' linking back to /arrival/", () => {
+ mockSearchParamsInstance = new URLSearchParams("request=onlineboard-arrival-SVO-20260515");
+ const { container } = render(
+ ,
+ );
+ const link = getCrumbLink(container, "BREADCRUMBS.ARRIVAL");
+ expect(link).toBeTruthy();
+ expect(link?.getAttribute("href")).toContain("/ru/onlineboard/arrival/SVO-20260515");
+ });
});
describe("TZ §4.1.15.8 timeline status center-class per status", () => {
diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
index a77ca2ff..7ee6c50f 100644
--- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx
+++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
@@ -22,6 +22,7 @@ import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
import { buildFlightJsonLd } from "../json-ld.js";
import { buildOnlineBoardUrl } from "../url.js";
+import { useCityName, useStationDisplayName } from "@/shared/hooks/useDictionaries.js";
import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js";
import { FlightsMiniList } from "./FlightsMiniList/index.js";
import { DayTabs } from "./DayTabs/index.js";
@@ -417,13 +418,87 @@ export const OnlineBoardDetailsPage: FC = ({
// The leaf is clickable and navigates back to the source search page
// with filter state (including time range) preserved.
//
- // Angular stops the trail at 'Онлайн-Табло' (links to /onlineboard) —
- // never adds a leaf crumb for the flight or station. The page heading
- // already shows the flight number / city.
- const detailsCrumbs = useMemo(
- () => [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }],
- [locale, t],
- );
+ // Resolve the IATA codes carried in `?request=` to display names.
+ // Angular's onlineboard departure/arrival leaf shows the airport name
+ // when the URL station is an airport IATA (SVO → "Шереметьево") and
+ // falls back to city otherwise. Route leaf shows the city name.
+ // Hooks must be called unconditionally; safe defaults when no request.
+ const requestStation =
+ parentRequest && parentRequest.area === "onlineboard" &&
+ (parentRequest.kind === "departure" || parentRequest.kind === "arrival")
+ ? parentRequest.station
+ : "";
+ const requestRouteDep =
+ parentRequest && parentRequest.area === "onlineboard" && parentRequest.kind === "route"
+ ? parentRequest.departure
+ : "";
+ const requestRouteArr =
+ parentRequest && parentRequest.area === "onlineboard" && parentRequest.kind === "route"
+ ? parentRequest.arrival
+ : "";
+ const stationDisplay = useStationDisplayName(requestStation);
+ const routeDepCity = useCityName(requestRouteDep);
+ const routeArrCity = useCityName(requestRouteArr);
+
+ const detailsCrumbs = useMemo(() => {
+ const baseCrumbs = [{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }];
+ if (!parentRequest || parentRequest.area !== "onlineboard") return baseCrumbs;
+
+ const backUrl = (() => {
+ switch (parentRequest.kind) {
+ case "flight": {
+ const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
+ if (!m || !m[1] || !m[2]) return `/${locale}/onlineboard`;
+ return `/${locale}/${buildOnlineBoardUrl({
+ type: "flight",
+ carrier: m[1],
+ flightNumber: m[2],
+ date: parentRequest.date,
+ })}`;
+ }
+ case "departure":
+ return `/${locale}/${buildOnlineBoardUrl(
+ parentRequest.timeFrom && parentRequest.timeTo
+ ? { type: "departure", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
+ : { type: "departure", station: parentRequest.station, date: parentRequest.date },
+ )}`;
+ case "arrival":
+ return `/${locale}/${buildOnlineBoardUrl(
+ parentRequest.timeFrom && parentRequest.timeTo
+ ? { type: "arrival", station: parentRequest.station, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
+ : { type: "arrival", station: parentRequest.station, date: parentRequest.date },
+ )}`;
+ case "route":
+ return `/${locale}/${buildOnlineBoardUrl(
+ parentRequest.timeFrom && parentRequest.timeTo
+ ? { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date, timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
+ : { type: "route", departure: parentRequest.departure, arrival: parentRequest.arrival, date: parentRequest.date },
+ )}`;
+ }
+ })();
+
+ const leafLabel = (() => {
+ switch (parentRequest.kind) {
+ case "flight": {
+ // Angular renders "Рейс: SU 6188" — carrier and number space-separated
+ const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
+ const formatted = m?.[1] && m?.[2] ? `${m[1]} ${m[2]}` : parentRequest.flightNumber;
+ return t("BREADCRUMBS.FLIGHT-NUMBER", { flightNumber: formatted });
+ }
+ case "departure":
+ return t("BREADCRUMBS.DEPARTURE", { city: stationDisplay });
+ case "arrival":
+ return t("BREADCRUMBS.ARRIVAL", { city: stationDisplay });
+ case "route":
+ return t("BREADCRUMBS.ROUTE", {
+ departureCity: routeDepCity,
+ arrivalCity: routeArrCity,
+ });
+ }
+ })();
+
+ return [...baseCrumbs, { label: leafLabel, url: backUrl }];
+ }, [parentRequest, locale, t, stationDisplay, routeDepCity, routeArrCity]);
const parentParams = useMemo(() => {
if (!parentRequest || parentRequest.area !== "onlineboard") return null;
diff --git a/src/features/schedule/components/ScheduleDetailsPage.test.tsx b/src/features/schedule/components/ScheduleDetailsPage.test.tsx
index 139f0347..7645f1a8 100644
--- a/src/features/schedule/components/ScheduleDetailsPage.test.tsx
+++ b/src/features/schedule/components/ScheduleDetailsPage.test.tsx
@@ -22,6 +22,7 @@ let mockSearchParamsGet: (key: string) => string | null = () => null;
vi.mock("@modern-js/runtime/router", () => ({
useSearchParams: () => [{ get: (k: string) => mockSearchParamsGet(k) }],
useNavigate: () => vi.fn(),
+ useParams: () => ({ lang: "ru-ru" }),
Link: ({
children,
to,
@@ -33,6 +34,11 @@ vi.mock("@modern-js/runtime/router", () => ({
}) => {children},
}));
+vi.mock("@/shared/hooks/useDictionaries.js", () => ({
+ useCityName: (code: string) => code,
+ useStationDisplayName: (code: string) => code,
+}));
+
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record) => {
@@ -196,7 +202,7 @@ describe("ScheduleDetailsPage breadcrumbs", () => {
expect(screen.queryByTestId("crumb-1")).toBeNull();
});
- it("never appends a leaf crumb even when ?request= carries schedule area (Angular parity)", () => {
+ it("appends a route leaf crumb when ?request= carries schedule area (Angular parity)", () => {
mockSearchParamsGet = (k) =>
k === "request"
? "schedule-route-NBC-KHV-20220307-20220313"
@@ -211,10 +217,15 @@ describe("ScheduleDetailsPage breadcrumbs", () => {
);
const crumb0 = screen.getByTestId("crumb-0");
+ const crumb1 = screen.getByTestId("crumb-1");
expect(crumb0.textContent).toContain("SCHEDULE.TITLE");
- // Angular's trail stops at 'Расписание рейсов' — no third crumb,
- // even when a request context is available.
- expect(screen.queryByTestId("crumb-1")).toBeNull();
+ // Leaf label key is BREADCRUMBS.SCHEDULE-ROUTE — Angular shows
+ // "{depCity} - {arrCity}" (no "Маршрут:" prefix on schedule details).
+ expect(crumb1.textContent).toContain("BREADCRUMBS.SCHEDULE-ROUTE");
+ const backUrl = crumb1.getAttribute("data-url") ?? "";
+ expect(backUrl).toContain("/schedule/");
+ expect(backUrl).toContain("NBC");
+ expect(backUrl).toContain("KHV");
});
it("ignores ?request= that carries onlineboard area", () => {
diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx
index 293e25d5..9dbb5e8a 100644
--- a/src/features/schedule/components/ScheduleDetailsPage.tsx
+++ b/src/features/schedule/components/ScheduleDetailsPage.tsx
@@ -26,6 +26,7 @@ import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
import { extractSimpleFlights } from "../extractSimpleFlights.js";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
+import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { buildScheduleDetailsSeo } from "../seo.js";
import { buildScheduleFlightJsonLd } from "../json-ld.js";
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
@@ -164,13 +165,78 @@ export const ScheduleDetailsPage: FC = ({
flights,
]);
- // Angular stops the trail at 'Расписание рейсов' (links to /schedule)
- // — never adds the route name as a third crumb. The page heading
- // already shows "{depCity} - {arrCity}".
- const breadcrumbs = useMemo(
- () => [{ label: t("SCHEDULE.TITLE"), url: scheduleHref }],
- [scheduleHref, t],
- );
+ // Angular's schedule details page adds a third crumb only when the
+ // user reached the page from a search context (?request=schedule-…).
+ // The leaf reads "{depCity} - {arrCity}" (no "Маршрут:" prefix —
+ // unlike the onlineboard variant) and links back to the route search
+ // URL with the original date range / filter state preserved.
+ const requestDep =
+ parentRequest && parentRequest.area === "schedule" ? parentRequest.departure : "";
+ const requestArr =
+ parentRequest && parentRequest.area === "schedule" ? parentRequest.arrival : "";
+ const requestDepCity = useCityName(requestDep);
+ const requestArrCity = useCityName(requestArr);
+
+ const breadcrumbs = useMemo(() => {
+ const baseCrumbs = [{ label: t("SCHEDULE.TITLE"), url: scheduleHref }];
+ if (!parentRequest || parentRequest.area !== "schedule") return baseCrumbs;
+
+ const backUrl = `/${locale}/${buildScheduleUrl(
+ parentRequest.returnTrip
+ ? {
+ type: "roundtrip",
+ outbound: {
+ departure: parentRequest.departure,
+ arrival: parentRequest.arrival,
+ dateFrom: parentRequest.dateFrom,
+ dateTo: parentRequest.dateTo,
+ ...(parentRequest.timeFrom && parentRequest.timeTo
+ ? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
+ : {}),
+ ...(parentRequest.connections !== undefined
+ ? { connections: parentRequest.connections }
+ : {}),
+ },
+ inbound: {
+ departure: parentRequest.returnTrip.departure,
+ arrival: parentRequest.returnTrip.arrival,
+ dateFrom: parentRequest.returnTrip.dateFrom,
+ dateTo: parentRequest.returnTrip.dateTo,
+ ...(parentRequest.returnTrip.timeFrom && parentRequest.returnTrip.timeTo
+ ? {
+ timeFrom: parentRequest.returnTrip.timeFrom,
+ timeTo: parentRequest.returnTrip.timeTo,
+ }
+ : {}),
+ ...(parentRequest.returnTrip.connections !== undefined
+ ? { connections: parentRequest.returnTrip.connections }
+ : {}),
+ },
+ }
+ : {
+ type: "route",
+ outbound: {
+ departure: parentRequest.departure,
+ arrival: parentRequest.arrival,
+ dateFrom: parentRequest.dateFrom,
+ dateTo: parentRequest.dateTo,
+ ...(parentRequest.timeFrom && parentRequest.timeTo
+ ? { timeFrom: parentRequest.timeFrom, timeTo: parentRequest.timeTo }
+ : {}),
+ ...(parentRequest.connections !== undefined
+ ? { connections: parentRequest.connections }
+ : {}),
+ },
+ },
+ )}`;
+
+ const leafLabel = t("BREADCRUMBS.SCHEDULE-ROUTE", {
+ departureCity: requestDepCity,
+ arrivalCity: requestArrCity,
+ });
+
+ return [...baseCrumbs, { label: leafLabel, url: backUrl }];
+ }, [parentRequest, locale, scheduleHref, t, requestDepCity, requestArrCity]);
/**
* TZ §4.1.16.3 R22-R28: navigate to a new day for the same flight.
diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json
index 0b14254f..a5396ec5 100644
--- a/src/i18n/locales/de/common.json
+++ b/src/i18n/locales/de/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "Flug: {flightNumber}",
"DEPARTURE": "Abflug: {city}",
"ARRIVAL": "Ankunft: {city}",
- "ROUTE": "Strecke: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Strecke: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 7e4768b0..08fc79c4 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -61,8 +61,8 @@
"FLIGHT-NUMBER": "Flight: {flightNumber}",
"DEPARTURE": "Departure: {city}",
"ARRIVAL": "Arrival: {city}",
- "ROUTE": "Route: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Route: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
},
"DETAILS": {
"REGISTRATION": "Check-in",
diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json
index 3cb1e111..751c31a9 100644
--- a/src/i18n/locales/es/common.json
+++ b/src/i18n/locales/es/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "Vuelo: {flightNumber}",
"DEPARTURE": "Salida: {city}",
"ARRIVAL": "Llegada: {city}",
- "ROUTE": "Ruta: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Ruta: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json
index 9b056f6c..f0d3d831 100644
--- a/src/i18n/locales/fr/common.json
+++ b/src/i18n/locales/fr/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "Vol: {flightNumber}",
"DEPARTURE": "Départ: {city}",
"ARRIVAL": "Arrivée: {city}",
- "ROUTE": "Itinéraire: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Itinéraire: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json
index c23e0bac..f61cce22 100644
--- a/src/i18n/locales/it/common.json
+++ b/src/i18n/locales/it/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "Volo: {flightNumber}",
"DEPARTURE": "Partenza: {city}",
"ARRIVAL": "Arrivo: {city}",
- "ROUTE": "Rotta: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Rotta: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json
index 9e61c3e7..5b6d90fe 100644
--- a/src/i18n/locales/ja/common.json
+++ b/src/i18n/locales/ja/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "便: {flightNumber}",
"DEPARTURE": "出発: {city}",
"ARRIVAL": "到着: {city}",
- "ROUTE": "ルート: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "ルート: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json
index d152d551..3cacab8b 100644
--- a/src/i18n/locales/ko/common.json
+++ b/src/i18n/locales/ko/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "편: {flightNumber}",
"DEPARTURE": "출발: {city}",
"ARRIVAL": "도착: {city}",
- "ROUTE": "노선: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "노선: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
index 1bc0be63..1841f56b 100644
--- a/src/i18n/locales/ru/common.json
+++ b/src/i18n/locales/ru/common.json
@@ -58,11 +58,11 @@
},
"BREADCRUMBS": {
"ONLINEBOARD": "Онлайн-табло",
- "FLIGHT-NUMBER": "Номер рейса: {flightNumber}",
+ "FLIGHT-NUMBER": "Рейс: {flightNumber}",
"DEPARTURE": "Вылет: {city}",
"ARRIVAL": "Прилет: {city}",
- "ROUTE": "Маршрут: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "Маршрут: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
},
"DETAILS": {
"REGISTRATION": "Регистрация",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index 2523ceed..98b39154 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -431,7 +431,7 @@
"FLIGHT-NUMBER": "航班: {flightNumber}",
"DEPARTURE": "出发: {city}",
"ARRIVAL": "到达: {city}",
- "ROUTE": "航线: {departureCity}-{arrivalCity}",
- "SCHEDULE-ROUTE": "{departureCity}-{arrivalCity}"
+ "ROUTE": "航线: {departureCity} - {arrivalCity}",
+ "SCHEDULE-ROUTE": "{departureCity} - {arrivalCity}"
}
}
diff --git a/src/shared/hooks/useDictionaries.ts b/src/shared/hooks/useDictionaries.ts
index 6901f4f6..f80e39e7 100644
--- a/src/shared/hooks/useDictionaries.ts
+++ b/src/shared/hooks/useDictionaries.ts
@@ -38,3 +38,24 @@ export function useCityName(code: string): string {
}
return code;
}
+
+/**
+ * Resolve a station IATA to its display name following Angular's
+ * `getCityOrAirport` rule: city dictionary first (so true city codes
+ * like LED → "Санкт-Петербург", MOW → "Москва" win), and only when
+ * the code isn't a city does it fall through to the airport dictionary
+ * (SVO → "Шереметьево"). Crucially this does NOT escalate from an
+ * airport up to its parent city — used by the Onlineboard breadcrumb
+ * leaf for departure/arrival URLs.
+ */
+export function useStationDisplayName(code: string): string {
+ const { language } = useLocale();
+ const { dictionaries } = useDictionariesState(language);
+ if (!code || !dictionaries) return code;
+ const upper = code.toUpperCase();
+ const city = dictionaries.cityByCode.get(upper);
+ if (city) return city.name;
+ const airport = dictionaries.airportByCode.get(upper);
+ if (airport) return airport.name;
+ return code;
+}
diff --git a/tests/e2e/breadcrumbs-parity.spec.ts b/tests/e2e/breadcrumbs-parity.spec.ts
index cd7fcf5b..9c06c0f6 100644
--- a/tests/e2e/breadcrumbs-parity.spec.ts
+++ b/tests/e2e/breadcrumbs-parity.spec.ts
@@ -1,14 +1,23 @@
import { test, expect, type Page } from "@playwright/test";
// Angular's breadcrumb trail (audited live on flights.test.aeroflot.ru):
-// /schedule → [Главная]
-// /schedule/route/... → [Главная, Расписание рейсов]
-// /schedule///?request=…
-// → [Главная, Расписание рейсов] (no leaf even with ?request=)
-// /onlineboard → [Главная]
-// /onlineboard/route/... → [Главная, Онлайн-Табло]
-// /onlineboard/-?request=…
-// → [Главная, Онлайн-Табло] (no leaf even with ?request=)
+// /schedule → [Главная]
+// /schedule/route/... → [Главная, Расписание рейсов]
+// /schedule/// → [Главная, Расписание рейсов]
+// /schedule///?request=schedule-route-…
+// → [Главная, Расписание рейсов, "{depCity} - {arrCity}"]
+// (no "Маршрут:" prefix; leaf links back to /schedule/route/...)
+// /onlineboard → [Главная]
+// /onlineboard/route/... → [Главная, Онлайн-Табло]
+// /onlineboard/- → [Главная, Онлайн-Табло]
+// /onlineboard/-?request=onlineboard-flight-…
+// → […, "Рейс: SU 6188"] (carrier and number space-separated)
+// /onlineboard/-?request=onlineboard-route-…
+// → […, "Маршрут: {depCity} - {arrCity}"]
+// /onlineboard/-?request=onlineboard-departure--…
+// → […, "Вылет: {airportName}"] (airport name, e.g. SVO → "Шереметьево")
+// /onlineboard/-?request=onlineboard-arrival--…
+// → […, "Прилет: {cityName}"]
async function readCrumbs(page: Page) {
return page.evaluate(() =>
@@ -40,11 +49,23 @@ const cases: { name: string; url: string; expected: { text: string; href: string
],
},
{
- name: "Schedule details page (with ?request= context)",
+ name: "Schedule details page (share-link, no ?request=)",
+ url: "/ru-ru/schedule/SVO/SU6951-20260427/LED",
+ expected: [
+ { text: "Главная", href: "https://www.aeroflot.ru" },
+ { text: "Расписание рейсов", href: "/ru-ru/schedule" },
+ ],
+ },
+ {
+ name: "Schedule details page (with ?request=schedule-route)",
url: "/ru-ru/schedule/SVO/SU6951-20260427/LED?request=schedule-route-MOW-LED-20260427-20260503",
expected: [
{ text: "Главная", href: "https://www.aeroflot.ru" },
{ text: "Расписание рейсов", href: "/ru-ru/schedule" },
+ {
+ text: "Москва - Санкт-Петербург",
+ href: "/ru-ru/schedule/route/MOW-LED-20260427-20260503",
+ },
],
},
{
@@ -63,13 +84,61 @@ const cases: { name: string; url: string; expected: { text: string; href: string
],
},
{
- name: "Onlineboard details page (with ?request= context)",
- url: "/ru-ru/onlineboard/SU0006-20260423?request=onlineboard-flight-SU0006-20260423",
+ name: "Onlineboard details page (share-link, no ?request=)",
+ url: "/ru-ru/onlineboard/SU0006-20260423",
expected: [
{ text: "Главная", href: "https://www.aeroflot.ru" },
{ text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
],
},
+ {
+ name: "Onlineboard details page (with ?request=onlineboard-flight)",
+ url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-flight-SU6188-20260423",
+ expected: [
+ { text: "Главная", href: "https://www.aeroflot.ru" },
+ { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
+ {
+ text: "Рейс: SU 6188",
+ href: "/ru-ru/onlineboard/flight/SU6188-20260423",
+ },
+ ],
+ },
+ {
+ name: "Onlineboard details page (with ?request=onlineboard-route)",
+ url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-route-MOW-LED-20260423",
+ expected: [
+ { text: "Главная", href: "https://www.aeroflot.ru" },
+ { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
+ {
+ text: "Маршрут: Москва - Санкт-Петербург",
+ href: "/ru-ru/onlineboard/route/MOW-LED-20260423",
+ },
+ ],
+ },
+ {
+ name: "Onlineboard details page (with ?request=onlineboard-departure, airport IATA)",
+ url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-departure-SVO-20260423",
+ expected: [
+ { text: "Главная", href: "https://www.aeroflot.ru" },
+ { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
+ {
+ text: "Вылет: Шереметьево",
+ href: "/ru-ru/onlineboard/departure/SVO-20260423",
+ },
+ ],
+ },
+ {
+ name: "Onlineboard details page (with ?request=onlineboard-arrival, city IATA)",
+ url: "/ru-ru/onlineboard/SU6188-20260423?request=onlineboard-arrival-LED-20260423",
+ expected: [
+ { text: "Главная", href: "https://www.aeroflot.ru" },
+ { text: "Онлайн-Табло", href: "/ru-ru/onlineboard" },
+ {
+ text: "Прилет: Санкт-Петербург",
+ href: "/ru-ru/onlineboard/arrival/LED-20260423",
+ },
+ ],
+ },
];
test.describe("Breadcrumb parity with Angular", () => {
@@ -77,8 +146,11 @@ test.describe("Breadcrumb parity with Angular", () => {
test(c.name, async ({ page }) => {
await page.goto(c.url);
await expect(page.getByTestId("breadcrumbs")).toBeVisible({ timeout: 15000 });
- const items = await readCrumbs(page);
- expect(items).toEqual(c.expected);
+ // Poll on the full items array — the leaf depends on dictionaries
+ // fetched asynchronously, so labels arrive after the initial paint.
+ await expect
+ .poll(async () => readCrumbs(page), { timeout: 15000 })
+ .toEqual(c.expected);
});
}
});