From 0c660671eaa2d48dfa2d679675757d12d648841b Mon Sep 17 00:00:00 2001 From: gnezim Date: Sat, 18 Apr 2026 13:27:56 +0300 Subject: [PATCH] Close the remaining high-impact parity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch of fixes identified by the comparison audit: Schedule search page (ScheduleSearchPage): - Resolve IATA codes to city/airport names, so the H1 reads 'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'. - Breadcrumb trail now includes the human-friendly route as its last entry. Details page (OnlineBoardDetailsPage): - Hide the 'Перелет N' leg header for single-leg flights (Angular parity — that label is only meaningful for multi-leg routes). - Translate the leg status through FLIGHT-STATUSES.* instead of emitting the raw enum ('Cancelled' → 'Отменен', etc.). - Humanize leg and total flying time through formatDuration so the page reads '1ч 25м' rather than '01:25:00'. Details meal panel (MealPanel): - Use the same FOOD.* translation keys as Angular, so labels become 'Эконом класс / Комфорт класс / Бизнес класс / Специальное питание'. - Add the Special-meal icon + link (was stubbed out previously). Accessibility: - Route the English aria-labels through new SHARED.A11Y-* keys in DayTabs pagination, FlightListSkeleton, ScrollUpButton and PrintButton. Breadcrumbs: - Render the 'Главная' crumb as a link even when it's the only / last item (it was dropping to plain text on start pages). Angular always links it to aeroflot.ru. Tests updated to assert the new translated labels and duration formatting; 1258 tests passing. --- .../BoardDetailsHeader/PrintButton.tsx | 4 +- .../components/DayTabs/DayTabs.tsx | 6 ++- .../OnlineBoardDetailsPage.test.tsx | 5 ++- .../components/OnlineBoardDetailsPage.tsx | 33 ++++++++++++++--- .../details-panels/MealPanel.test.tsx | 4 +- .../components/details-panels/MealPanel.tsx | 18 ++++++--- .../details-panels/icons/special-food.svg | 8 ++++ .../components/details-panels/shared.ts | 3 +- .../components/ScheduleSearchPage.tsx | 24 +++++++++++- .../components/ScheduleStartPage.scss | 23 ++++++++++++ src/i18n/locales/de/common.json | 7 +++- src/i18n/locales/en/common.json | 7 +++- src/i18n/locales/es/common.json | 7 +++- src/i18n/locales/fr/common.json | 7 +++- src/i18n/locales/it/common.json | 7 +++- src/i18n/locales/ja/common.json | 7 +++- src/i18n/locales/ko/common.json | 7 +++- src/i18n/locales/ru/common.json | 7 +++- src/i18n/locales/zh/common.json | 7 +++- src/ui/flights/FlightListSkeleton.tsx | 4 +- src/ui/layout/Breadcrumbs.tsx | 37 +++++++++++-------- src/ui/layout/ScrollUpButton.tsx | 4 +- .../online-board/flight-details.test.tsx | 7 +++- 23 files changed, 194 insertions(+), 49 deletions(-) create mode 100644 src/features/online-board/components/details-panels/icons/special-food.svg diff --git a/src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx b/src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx index 4de3d77f..9698eef3 100644 --- a/src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; import "./actions.scss"; export interface PrintButtonProps { @@ -6,12 +7,13 @@ export interface PrintButtonProps { } export const PrintButton: FC = () => { + const { t } = useTranslation(); return ( = ({ locale, onNavigate, }) => { + const { t } = useTranslation(); const allDates = useMemo( () => generateDateRange(new Date(), daysBefore, daysAfter), [daysBefore, daysAfter], @@ -56,7 +58,7 @@ export const DayTabs: FC = ({ data-testid="day-tabs-prev" disabled={!canGoPrev} onClick={() => setCurrentPage((p) => Math.max(0, p - 1))} - aria-label="Previous page" + aria-label={t("SHARED.A11Y-PREV-PAGE")} > {"\u2039"} @@ -78,7 +80,7 @@ export const DayTabs: FC = ({ data-testid="day-tabs-next" disabled={!canGoNext} onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))} - aria-label="Next page" + aria-label={t("SHARED.A11Y-NEXT-PAGE")} > {"\u203a"} diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx index b8c4c99f..dd87f7e5 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx @@ -185,7 +185,10 @@ describe("OnlineBoardDetailsPage", () => { it("displays flying time", () => { render(); expect(screen.getByTestId("flying-time")).toBeTruthy(); - expect(screen.getByText("BOARD.TOTAL-FLYING-TIME: 10:30")).toBeTruthy(); + // flyingTime 10:30 → formatDuration() humanizes to '10h 30m' (en) or + // '10ч 30м' (ru). The mocked `t` returns keys unchanged, so the final + // render with locale 'ru' produces "BOARD.TOTAL-FLYING-TIME: 10ч 30м". + expect(screen.getByText(/BOARD\.TOTAL-FLYING-TIME:\s*10ч\s*30м/)).toBeTruthy(); }); describe("accordion integration", () => { diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index 119cfe06..29faaa40 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -36,8 +36,24 @@ import { formatLocalTime, formatUtcOffset, formatDayMonthYear, + formatDuration, } from "@/shared/utils/datetime/index.js"; +/** + * Parse "HH:mm" / "HH:mm:ss" / "H:mm" into total minutes, then humanize + * through the shared formatDuration helper so the details page reads + * '1ч 25м' (Angular parity) rather than the raw '01:25:00'. + */ +function humanizeFlyingTime(value: string, locale: string): string { + if (!value) return ""; + const parts = value.split(":"); + if (parts.length < 2) return value; + const h = Number(parts[0]); + const m = Number(parts[1]); + if (Number.isNaN(h) || Number.isNaN(m)) return value; + return formatDuration(h * 60 + m, locale); +} + export interface OnlineBoardDetailsPageProps { /** Parsed flight identifier from the URL */ flightId: IParsedFlightId; @@ -129,15 +145,20 @@ function FlightLegs({ viewType: "Onlineboard" | "Schedule"; }): JSX.Element { const { t } = useTranslation(); + const showLegHeaders = legs.length > 1; return (
{legs.map((leg, i) => (
-
- {t("BOARD.LEG")} {(leg.index ?? i) + 1} - {leg.status} -
+ {showLegHeaders && ( +
+ {t("BOARD.LEG")} {(leg.index ?? i) + 1} + + {t(`FLIGHT-STATUSES.${leg.status}`)} + +
+ )}
- {leg.flyingTime} + {humanizeFlyingTime(leg.flyingTime, "ru")}
= ({ {/* Flying time */}
- {t("BOARD.TOTAL-FLYING-TIME")}: {displayFlight.flyingTime} + {t("BOARD.TOTAL-FLYING-TIME")}: {humanizeFlyingTime(displayFlight.flyingTime, locale)}
diff --git a/src/features/online-board/components/details-panels/MealPanel.test.tsx b/src/features/online-board/components/details-panels/MealPanel.test.tsx index 12f1beb3..34075882 100644 --- a/src/features/online-board/components/details-panels/MealPanel.test.tsx +++ b/src/features/online-board/components/details-panels/MealPanel.test.tsx @@ -25,10 +25,10 @@ describe("MealPanel", () => { expect(screen.getByTestId("meal-icon-Business")).toBeTruthy(); }); - it("skips Special type (no link defined)", () => { + it("renders Special type with its own icon + link (matches Angular)", () => { const meals: IMealItem[] = [{ type: "Special" }, { type: "Economy" }]; render(); - expect(screen.queryByTestId("meal-icon-Special")).toBeNull(); + expect(screen.getByTestId("meal-icon-Special")).toBeTruthy(); expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy(); }); diff --git a/src/features/online-board/components/details-panels/MealPanel.tsx b/src/features/online-board/components/details-panels/MealPanel.tsx index d2f4fdf1..b4809774 100644 --- a/src/features/online-board/components/details-panels/MealPanel.tsx +++ b/src/features/online-board/components/details-panels/MealPanel.tsx @@ -5,21 +5,27 @@ import { MEAL_LINKS } from "./shared.js"; import econoIcon from "./icons/econom.svg"; import comfortIcon from "./icons/comfort.svg"; import businessIcon from "./icons/business.svg"; +import specialIcon from "./icons/special-food.svg"; import "./panels.scss"; -const MEAL_ICON_URL: Record, string> = { +const MEAL_ICON_URL: Record = { Economy: econoIcon, Comfort: comfortIcon, Business: businessIcon, + Special: specialIcon, }; -const MEAL_LABEL_KEYS: Record, string> = { - Economy: "DETAILS.MEAL_ECONOMY", - Comfort: "DETAILS.MEAL_COMFORT", - Business: "DETAILS.MEAL_BUSINESS", +// Label keys mirror Angular's flight-details-meal template (FOOD.*). +// Results: 'Эконом класс', 'Комфорт класс', 'Бизнес класс', +// 'Специальное питание' (ru); English fallbacks under FOOD in each locale. +const MEAL_LABEL_KEYS: Record = { + Economy: "FOOD.ECONOMY", + Comfort: "FOOD.COMFORT", + Business: "FOOD.BUSINESS", + Special: "FOOD.SPECIAL", }; -const MEAL_ORDER = ["Economy", "Comfort", "Business"] as const; +const MEAL_ORDER: MealType[] = ["Economy", "Comfort", "Business", "Special"]; export interface MealPanelProps { meals: IMealItem[]; diff --git a/src/features/online-board/components/details-panels/icons/special-food.svg b/src/features/online-board/components/details-panels/icons/special-food.svg new file mode 100644 index 00000000..6cbd3635 --- /dev/null +++ b/src/features/online-board/components/details-panels/icons/special-food.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/features/online-board/components/details-panels/shared.ts b/src/features/online-board/components/details-panels/shared.ts index b316753b..da9b7a60 100644 --- a/src/features/online-board/components/details-panels/shared.ts +++ b/src/features/online-board/components/details-panels/shared.ts @@ -61,8 +61,9 @@ export const SERVICE_ICON_FALLBACK = "comfort-plus"; * Meal type → aeroflot.ru info page link. * From Angular flight-details-meal.component.ts. */ -export const MEAL_LINKS: Record, string> = { +export const MEAL_LINKS: Record = { Economy: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_0", Comfort: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_1", Business: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_2", + Special: "https://www.aeroflot.ru/ru-ru/information/onboard/dining/additional", }; diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index 45f59487..d7548d75 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -16,6 +16,7 @@ import { FlightList } from "@/ui/flights/FlightList.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; import { PageTabs } from "@/ui/layout/PageTabs.js"; import { DayTabs } from "@/features/online-board/components/DayTabs/index.js"; +import { useDictionaries } from "@/shared/dictionaries/index.js"; import "./ScheduleSearchPage.scss"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import { useScheduleSearch } from "../hooks/useScheduleSearch.js"; @@ -75,9 +76,25 @@ export const ScheduleSearchPage: FC = ({ params }) => { const routeParams = useParams<{ lang: string }>(); const lang = routeParams.lang ?? "ru"; + const { dictionaries } = useDictionaries(lang); const outbound = params.outbound; const inbound = params.type === "roundtrip" ? params.inbound : undefined; + // Resolve IATA codes to human city/airport names so the heading reads + // 'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'. + const describeStation = (code?: string): string => { + if (!code || !dictionaries) return code ?? ""; + const upper = code.toUpperCase(); + return ( + dictionaries.airportByCode.get(upper)?.name ?? + dictionaries.cityByCode.get(upper)?.name ?? + code + ); + }; + const depName = describeStation(outbound.departure); + const arrName = describeStation(outbound.arrival); + const routeHeading = `${t("BOARD.ROUTE-TEXT")}${depName} - ${arrName}`; + // Fetch outbound flights const outboundRequest = toSearchRequest(outbound); const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh } = @@ -138,10 +155,13 @@ export const ScheduleSearchPage: FC = ({ params }) => { headerLeft={} title={

- {t("BOARD.ROUTE-TEXT")}{outbound.departure} - {outbound.arrival} + {routeHeading}

} - breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]} + breadcrumbs={[ + { label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }, + { label: routeHeading }, + ]} stickyContent={ = ({ count = 5, }) => { + const { t } = useTranslation(); return ( -
+
{Array.from({ length: count }, (_, i) => (
diff --git a/src/ui/layout/Breadcrumbs.tsx b/src/ui/layout/Breadcrumbs.tsx index 3cdd9a83..7acf392a 100644 --- a/src/ui/layout/Breadcrumbs.tsx +++ b/src/ui/layout/Breadcrumbs.tsx @@ -31,21 +31,28 @@ export const Breadcrumbs: FC = ({ items = [] }) => { return ( ); diff --git a/src/ui/layout/ScrollUpButton.tsx b/src/ui/layout/ScrollUpButton.tsx index 84984682..8efbc6cf 100644 --- a/src/ui/layout/ScrollUpButton.tsx +++ b/src/ui/layout/ScrollUpButton.tsx @@ -7,11 +7,13 @@ */ import { type FC, useState, useEffect, useCallback } from "react"; +import { useTranslation } from "@/i18n/provider.js"; import "./ScrollUpButton.scss"; const SCROLL_THRESHOLD = 300; export const ScrollUpButton: FC = () => { + const { t } = useTranslation(); const [visible, setVisible] = useState(false); useEffect(() => { @@ -34,7 +36,7 @@ export const ScrollUpButton: FC = () => { type="button" className="scroll-up-button" onClick={scrollToTop} - aria-label="Scroll to top" + aria-label={t("SHARED.A11Y-SCROLL-TO-TOP")} data-testid="scroll-up-button" > diff --git a/tests/integration/online-board/flight-details.test.tsx b/tests/integration/online-board/flight-details.test.tsx index 1d55bad4..1c340051 100644 --- a/tests/integration/online-board/flight-details.test.tsx +++ b/tests/integration/online-board/flight-details.test.tsx @@ -128,8 +128,11 @@ describe("Flight details page integration", () => { canonicalOrigin="https://www.aeroflot.ru" />, ); - // Status appears in both overall status and leg status - expect(screen.getAllByText("Scheduled").length).toBeGreaterThanOrEqual(1); + // FlightStatus renders STATUS_LABELS_RU in the embedded + // summary row (the leg header only renders for + // multi-leg flights now). For a direct Scheduled flight that's + // the single Russian label 'Запланирован'. + expect(screen.getAllByText("Запланирован").length).toBeGreaterThanOrEqual(1); }); it("renders flight legs for direct flight", () => {