Close the remaining high-impact parity gaps

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.
This commit is contained in:
2026-04-18 13:27:56 +03:00
parent 96235d5534
commit 0c660671ea
23 changed files with 194 additions and 49 deletions
@@ -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<PrintButtonProps> = () => {
const { t } = useTranslation();
return (
<a
className="flight-action-btn flight-action-btn--transparent"
data-testid="print-button"
href=""
aria-label="Print"
aria-label={t("SHARED.A11Y-PRINT-BTN")}
>
<svg
width="18"
@@ -1,4 +1,5 @@
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { DayTabButton } from "./DayTabButton.js";
import { DaySelect } from "./DaySelect.js";
import { generateDateRange, findPageIndex } from "./dateRange.js";
@@ -23,6 +24,7 @@ export const DayTabs: FC<DayTabsProps> = ({
locale,
onNavigate,
}) => {
const { t } = useTranslation();
const allDates = useMemo(
() => generateDateRange(new Date(), daysBefore, daysAfter),
[daysBefore, daysAfter],
@@ -56,7 +58,7 @@ export const DayTabs: FC<DayTabsProps> = ({
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"}
</button>
@@ -78,7 +80,7 @@ export const DayTabs: FC<DayTabsProps> = ({
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"}
</button>
@@ -185,7 +185,10 @@ describe("OnlineBoardDetailsPage", () => {
it("displays flying time", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
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", () => {
@@ -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 (
<div className="flight-details__legs" data-testid="flight-legs">
{legs.map((leg, i) => (
<Fragment key={`leg-${leg.index ?? i}`}>
<div className="flight-details__leg" data-testid={`flight-leg-${leg.index ?? i}`}>
<div className="flight-details__leg-header">
<span className="flight-details__leg-index">{t("BOARD.LEG")} {(leg.index ?? i) + 1}</span>
<span className="flight-details__leg-status">{leg.status}</span>
</div>
{showLegHeaders && (
<div className="flight-details__leg-header">
<span className="flight-details__leg-index">{t("BOARD.LEG")} {(leg.index ?? i) + 1}</span>
<span className="flight-details__leg-status">
{t(`FLIGHT-STATUSES.${leg.status}`)}
</span>
</div>
)}
<div className="flight-details__leg-stations">
<LegStation
@@ -152,7 +173,7 @@ function FlightLegs({
/>
<div className="flight-details__leg-duration">
<span>{leg.flyingTime}</span>
<span>{humanizeFlyingTime(leg.flyingTime, "ru")}</span>
</div>
<LegStation
@@ -379,7 +400,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
{/* Flying time */}
<div className="flight-details__flying-time" data-testid="flying-time">
{t("BOARD.TOTAL-FLYING-TIME")}: {displayFlight.flyingTime}
{t("BOARD.TOTAL-FLYING-TIME")}: {humanizeFlyingTime(displayFlight.flyingTime, locale)}
</div>
<FlightSchedule flight={displayFlight} />
@@ -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(<MealPanel meals={meals} />);
expect(screen.queryByTestId("meal-icon-Special")).toBeNull();
expect(screen.getByTestId("meal-icon-Special")).toBeTruthy();
expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy();
});
@@ -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<Exclude<MealType, "Special">, string> = {
const MEAL_ICON_URL: Record<MealType, string> = {
Economy: econoIcon,
Comfort: comfortIcon,
Business: businessIcon,
Special: specialIcon,
};
const MEAL_LABEL_KEYS: Record<Exclude<MealType, "Special">, 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<MealType, string> = {
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[];
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30.227" height="27.356" viewBox="0 0 30.227 27.356">
<g id="special-food" transform="translate(0.762 0.763)">
<path id="Path_1351" data-name="Path 1351" d="M33.083,76.562c0,6.344-6.427,11.485-14.357,11.485S4.369,82.911,4.369,76.562Z" transform="translate(-4.369 -62.205)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
<path id="Path_1352" data-name="Path 1352" d="M41.91,48.809a8.665,8.665,0,0,0-2.541-2.871" transform="translate(-31.712 -38.279)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
<path id="Path_1353" data-name="Path 1353" d="M18.951,17.285a3.829,3.829,0,0,0-5.36-4.108,3.83,3.83,0,0,0-6.973,3.169,3.828,3.828,0,0,0-1.9,5.069l.022.046" transform="translate(-4.372 -10.931)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
<path id="Path_1354" data-name="Path 1354" d="M92.594,38.572a4.786,4.786,0,1,0-9.38,0" transform="translate(-65.889 -28.043)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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<Exclude<MealType, "Special">, string> = {
export const MEAL_LINKS: Record<MealType, string> = {
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",
};
@@ -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<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ params }) => {
headerLeft={<PageTabs viewType="schedule" />}
title={
<h1 className="text--white page-title">
{t("BOARD.ROUTE-TEXT")}{outbound.departure} - {outbound.arrival}
{routeHeading}
</h1>
}
breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]}
breadcrumbs={[
{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` },
{ label: routeHeading },
]}
stickyContent={
<DayTabs
selectedDate={outbound.dateFrom}
@@ -202,6 +202,29 @@
}
}
// Time-range slider styled the same way as the online-board filter.
// Those rules are nested under `.online-board-filter` in the other
// SCSS file, so we duplicate them here — the schedule page isn't
// wrapped in that class.
.wrapper--time-selector {
margin-top: vars.$space-m;
.time-selector__label {
@include fonts.font-small(colors.$gray);
margin-bottom: vars.$space-s;
}
.time-selector {
padding: 0 vars.$space-s;
}
.time-selector__value {
@include fonts.font-small(colors.$gray);
margin-top: vars.$space-s;
text-align: right;
}
}
.schedule-start__time-row {
.schedule-start__time-inputs {
display: flex;
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Bitte beachten Sie:",
+6 -1
View File
@@ -406,7 +406,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Please note:",
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Nota:",
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Remarque:",
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Attenzione:",
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "ご注意:",
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "참고:",
+6 -1
View File
@@ -406,7 +406,12 @@
"CONNECTION-RECONNECTING": "Соединение…",
"CONNECTION-OFFLINE": "Нет связи",
"INVALID-PARAMS": "Неверные параметры URL.",
"LOADING": "Загрузка…"
"LOADING": "Загрузка…",
"A11Y-PREV-PAGE": "Предыдущая страница",
"A11Y-NEXT-PAGE": "Следующая страница",
"A11Y-LOADING-FLIGHTS": "Загрузка рейсов",
"A11Y-SCROLL-TO-TOP": "Наверх",
"A11Y-PRINT-BTN": "Печать"
},
"SMOKE": {
"HEADING": "Страница проверки"
+6 -1
View File
@@ -379,7 +379,12 @@
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…"
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
"A11Y-NEXT-PAGE": "Next page",
"A11Y-LOADING-FLIGHTS": "Loading flights",
"A11Y-SCROLL-TO-TOP": "Scroll to top",
"A11Y-PRINT-BTN": "Print"
},
"WARNING": {
"IFLY_HIGHLIGHT": "请注意:",
+3 -1
View File
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import "./FlightListSkeleton.scss";
export interface FlightListSkeletonProps {
@@ -13,8 +14,9 @@ export interface FlightListSkeletonProps {
export const FlightListSkeleton: FC<FlightListSkeletonProps> = ({
count = 5,
}) => {
const { t } = useTranslation();
return (
<div className="flight-list-skeleton" aria-busy="true" aria-label="Loading flights">
<div className="flight-list-skeleton" aria-busy="true" aria-label={t("SHARED.A11Y-LOADING-FLIGHTS")}>
{Array.from({ length: count }, (_, i) => (
<div key={i} className="flight-list-skeleton__row">
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--number" />
+22 -15
View File
@@ -31,21 +31,28 @@ export const Breadcrumbs: FC<BreadcrumbsProps> = ({ items = [] }) => {
return (
<nav className="breadcrumbs" aria-label="breadcrumb" data-testid="breadcrumbs">
<ol className="breadcrumbs__list">
{allItems.map((item, index) => (
<li
key={`${item.label}-${index}`}
className={`breadcrumbs__item${index === allItems.length - 1 ? " breadcrumbs__item--active" : ""}`}
>
{item.url && index < allItems.length - 1 ? (
<a href={item.url} className="breadcrumbs__link">{item.label}</a>
) : (
<span className="breadcrumbs__text">{item.label}</span>
)}
{index < allItems.length - 1 && (
<span className="breadcrumbs__separator">&gt;</span>
)}
</li>
))}
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
// The Home crumb (index 0) is always a link, even on start pages
// where it happens to be the only/last item — matches Angular,
// which always renders 'Главная' with the aeroflot.ru href.
const showAsLink = Boolean(item.url) && (index === 0 || !isLast);
return (
<li
key={`${item.label}-${index}`}
className={`breadcrumbs__item${isLast ? " breadcrumbs__item--active" : ""}`}
>
{showAsLink ? (
<a href={item.url} className="breadcrumbs__link">{item.label}</a>
) : (
<span className="breadcrumbs__text">{item.label}</span>
)}
{!isLast && (
<span className="breadcrumbs__separator">&gt;</span>
)}
</li>
);
})}
</ol>
</nav>
);
+3 -1
View File
@@ -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"
>
<svg viewBox="0 0 24 24" width="24" height="24">
@@ -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
// <FlightCard> 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", () => {