Rebuild schedule results page for Angular parity

Previously the /schedule/route results page rendered everything on the
dark-blue background and dumped the raw 382-char bitmask from the
/days endpoint straight into the DOM. Changes:

- Wrap the page in PageLayout with PageTabs, breadcrumb and an H1
  matching Angular ('Маршрут: SVO - LED').
- Swap the inline calendar loop for the shared <DayTabs> component
  (weekday + day + month labels, paging arrows).
- Replace the broken comma-split parser in getScheduleCalendarDays
  with the same bitmask-to-dates conversion the board endpoint uses,
  so the calendar now yields real yyyy-MM-dd strings.
- Frame the results in <section class='frame'> so they sit on a white
  card (matches the board pages).
- Translate the 'Invalid …' parameter errors on every route page to
  SHARED.INVALID-PARAMS ('Неверные параметры URL.') and wire t() into
  the two route files that still lacked useTranslation.
This commit is contained in:
2026-04-18 00:35:37 +03:00
parent 692fb5e292
commit de22fc3722
19 changed files with 123 additions and 57 deletions
+37 -4
View File
@@ -71,8 +71,9 @@ export async function getScheduleDetails(
* Get available calendar days for a given route.
* Maps to: `GET days/{date}/382/{param}/schedule/v1`
*
* The API returns `{ days: "2025-01-01,2025-01-02,..." }` -- a single
* comma-separated string. This function splits it into `string[]`.
* The API returns `{ days: "1111110001..." }` a 382-char bitmask
* where each character represents a day starting from (baseDate - 1).
* '1' = available, '0' = no flights. Equivalent to the board endpoint.
*/
export async function getScheduleCalendarDays(
client: ApiClient,
@@ -85,14 +86,46 @@ export async function getScheduleCalendarDays(
const path = `flights/v1/${client.locale}/days/${params.date}/382/${routeSegment}/schedule/`;
const response = await client.get<IScheduleDaysResponse>(path);
return parseCalendarDays(response.days);
return parseCalendarDays(response.days, params.date);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function parseCalendarDays(days: string): string[] {
/**
* Parse the /days response into an array of yyyy-MM-dd strings.
*
* Handles two shapes:
* - legacy comma-separated ("2025-01-01,2025-01-02,…")
* - bitmask ("1111000…") where each position maps to a day starting from
* `baseDate - 1`. This is what the upstream actually returns today.
*/
function parseCalendarDays(days: string, baseDate: string): string[] {
if (!days) return [];
if (/^[01]+$/.test(days)) {
return bitmaskToDates(days, baseDate);
}
return days.split(",").map((d) => d.trim()).filter(Boolean);
}
function bitmaskToDates(bitmask: string, baseDate: string): string[] {
// baseDate is yyyy-MM-dd (possibly with Txx:xx:xx suffix).
const iso = baseDate.includes("T") ? baseDate.split("T")[0]! : baseDate;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return [];
const cursor = new Date(Number(y), Number(m) - 1, Number(d));
cursor.setDate(cursor.getDate() - 1);
const result: string[] = [];
for (let i = 0; i < bitmask.length; i++) {
if (bitmask[i] === "1") {
const yy = cursor.getFullYear().toString();
const mm = (cursor.getMonth() + 1).toString().padStart(2, "0");
const dd = cursor.getDate().toString().padStart(2, "0");
result.push(`${yy}-${mm}-${dd}`);
}
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
@@ -13,6 +13,9 @@ import { useCallback } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
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 "./ScheduleSearchPage.scss";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
@@ -122,47 +125,64 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
? buildScheduleFlightListJsonLd(outboundSimple, searchDescription)
: undefined;
// DayTabs uses yyyymmdd; the calendar API returns yyyy-MM-dd. Normalize
// once so onNavigate hands DayTabs-compatible strings back to us.
const toYyyymmdd = (date: string): string =>
date.includes("-") ? date.replace(/-/g, "") : date;
const availableDates = calendarDays.map(toYyyymmdd);
return (
<div className="schedule-search" data-testid="schedule-search">
{jsonLd && <JsonLdRenderer data={jsonLd} />}
<PageLayout
headerLeft={<PageTabs viewType="schedule" />}
title={
<h1 className="text--white page-title">
{t("BOARD.ROUTE-TEXT")}{outbound.departure} - {outbound.arrival}
</h1>
}
breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]}
stickyContent={
<DayTabs
selectedDate={outbound.dateFrom}
availableDates={availableDates}
daysBefore={2}
daysAfter={30}
locale={lang}
onNavigate={(yyyymmdd) => {
const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
handleDateChange(iso);
}}
/>
}
>
{outboundError && (
<section className="frame" data-testid="search-error">
<div className="schedule-search__error">
<p>{t("BOARD.LOAD-FAILED")}</p>
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
</div>
</section>
)}
{/* Calendar strip */}
{calendarDays.length > 0 && (
<div className="schedule-search__calendar" data-testid="calendar-strip">
{calendarDays.map((day) => (
<button
key={day}
type="button"
className="calendar-day"
onClick={() => handleDateChange(day)}
>
{day}
</button>
))}
</div>
)}
<section className="frame">
<div className="schedule-search__outbound" data-testid="outbound-results">
<h2>
{t("SCHEDULE.OUTBOUND")}: {outbound.departure} &rarr; {outbound.arrival}
</h2>
<FlightList flights={outboundSimple} loading={outboundLoading} />
</div>
{/* Error state */}
{outboundError && (
<div className="schedule-search__error" data-testid="search-error">
<p>{t("BOARD.LOAD-FAILED")}</p>
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
</div>
)}
{/* Outbound flights */}
<div className="schedule-search__outbound" data-testid="outbound-results">
<h2>{t("SCHEDULE.OUTBOUND")}: {outbound.departure} &rarr; {outbound.arrival}</h2>
<FlightList flights={outboundSimple} loading={outboundLoading} />
</div>
{/* Inbound flights (round-trip) */}
{inbound && (
<div className="schedule-search__inbound" data-testid="inbound-results">
<h2>{t("SCHEDULE.RETURN")}: {inbound.departure} &rarr; {inbound.arrival}</h2>
<FlightList flights={inboundSimple} loading={inboundLoading} />
</div>
)}
{inbound && (
<div className="schedule-search__inbound" data-testid="inbound-results">
<h2>
{t("SCHEDULE.RETURN")}: {inbound.departure} &rarr; {inbound.arrival}
</h2>
<FlightList flights={inboundSimple} loading={inboundLoading} />
</div>
)}
</section>
</PageLayout>
</div>
);
};
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "Bitte beachten Sie:",
+2 -1
View File
@@ -404,7 +404,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "Please note:",
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "Nota:",
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "Remarque:",
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "Attenzione:",
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "ご注意:",
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "참고:",
+2 -1
View File
@@ -404,7 +404,8 @@
"RETRY": "Повторить",
"CONNECTION-LIVE": "Онлайн",
"CONNECTION-RECONNECTING": "Соединение…",
"CONNECTION-OFFLINE": "Нет связи"
"CONNECTION-OFFLINE": "Нет связи",
"INVALID-PARAMS": "Неверные параметры URL."
},
"SMOKE": {
"HEADING": "Страница проверки"
+2 -1
View File
@@ -377,7 +377,8 @@
"RETRY": "Retry",
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline"
"CONNECTION-OFFLINE": "Offline",
"INVALID-PARAMS": "Invalid URL parameters."
},
"WARNING": {
"IFLY_HIGHLIGHT": "请注意:",
@@ -9,6 +9,7 @@
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { getEnv } from "@/env/index.js";
@@ -20,6 +21,7 @@ const OnlineBoardDetailsPage = lazy(() =>
);
export default function FlightDetailsPage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ params: string; lang: string }>();
const raw = routeParams.params ?? "";
const locale = routeParams.lang ?? "ru";
@@ -28,7 +30,7 @@ export default function FlightDetailsPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid flight parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -30,7 +30,7 @@ export default function ArrivalSearchPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid arrival parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -30,7 +30,7 @@ export default function DepartureSearchPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid departure parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -30,7 +30,7 @@ export default function FlightSearchPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid flight parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -30,7 +30,7 @@ export default function RouteSearchPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid route parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
+3 -1
View File
@@ -10,6 +10,7 @@
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { getEnv } from "@/env/index.js";
@@ -49,6 +50,7 @@ function parseFlightSegments(segments: string[]): IScheduleFlightId[] {
}
export default function ScheduleDetailsRoute(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ "*": string; lang: string }>();
const locale = routeParams.lang ?? "ru";
const canonicalOrigin = getEnv().PROD_ORIGIN;
@@ -61,7 +63,7 @@ export default function ScheduleDetailsRoute(): JSX.Element {
if (flights.length === 0) {
return (
<div data-testid="invalid-params">
<p>Invalid schedule flight parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -33,7 +33,7 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element {
if (!outbound || !inbound) {
return (
<div data-testid="invalid-params">
<p>Invalid round-trip schedule parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
@@ -30,7 +30,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element {
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid schedule route parameters.</p>
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}