From de22fc3722685d8cf633c1bda638c9ff9e5c8fc4 Mon Sep 17 00:00:00 2001 From: gnezim Date: Sat, 18 Apr 2026 00:35:37 +0300 Subject: [PATCH] Rebuild schedule results page for Angular parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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
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. --- src/features/schedule/api.ts | 41 ++++++++- .../components/ScheduleSearchPage.tsx | 92 +++++++++++-------- src/i18n/locales/de/common.json | 3 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 3 +- src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/it/common.json | 3 +- src/i18n/locales/ja/common.json | 3 +- src/i18n/locales/ko/common.json | 3 +- src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/zh/common.json | 3 +- .../[lang]/onlineboard/[params]/page.tsx | 4 +- .../onlineboard/arrival/[params]/page.tsx | 2 +- .../onlineboard/departure/[params]/page.tsx | 2 +- .../onlineboard/flight/[params]/page.tsx | 2 +- .../onlineboard/route/[params]/page.tsx | 2 +- src/routes/[lang]/schedule/$.tsx | 4 +- .../route/[params]/[returnParams]/page.tsx | 2 +- .../[lang]/schedule/route/[params]/page.tsx | 2 +- 19 files changed, 123 insertions(+), 57 deletions(-) diff --git a/src/features/schedule/api.ts b/src/features/schedule/api.ts index 2ba4ac49..bb52ac5c 100644 --- a/src/features/schedule/api.ts +++ b/src/features/schedule/api.ts @@ -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(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; +} diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index 5ea8dbe9..45f59487 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -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 = ({ 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 (
{jsonLd && } + } + title={ +

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

+ } + breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]} + stickyContent={ + { + const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; + handleDateChange(iso); + }} + /> + } + > + {outboundError && ( +
+
+

{t("BOARD.LOAD-FAILED")}

+ +
+
+ )} - {/* Calendar strip */} - {calendarDays.length > 0 && ( -
- {calendarDays.map((day) => ( - - ))} -
- )} +
+
+

+ {t("SCHEDULE.OUTBOUND")}: {outbound.departure} → {outbound.arrival} +

+ +
- {/* Error state */} - {outboundError && ( -
-

{t("BOARD.LOAD-FAILED")}

- -
- )} - - {/* Outbound flights */} -
-

{t("SCHEDULE.OUTBOUND")}: {outbound.departure} → {outbound.arrival}

- -
- - {/* Inbound flights (round-trip) */} - {inbound && ( -
-

{t("SCHEDULE.RETURN")}: {inbound.departure} → {inbound.arrival}

- -
- )} + {inbound && ( +
+

+ {t("SCHEDULE.RETURN")}: {inbound.departure} → {inbound.arrival} +

+ +
+ )} +
+
); }; diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 9eff7665..211af6b3 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -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:", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 36d4efd2..b6d87eb8 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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:", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 55014685..67a3d6da 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -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:", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 8ab34af4..856b5f11 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -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:", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index cc23a192..0d822ef0 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -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:", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 6e40db01..655d4fd6 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -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": "ご注意:", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 89b1407a..28f87077 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -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": "참고:", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index e4e35d7f..fcab927d 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -404,7 +404,8 @@ "RETRY": "Повторить", "CONNECTION-LIVE": "Онлайн", "CONNECTION-RECONNECTING": "Соединение…", - "CONNECTION-OFFLINE": "Нет связи" + "CONNECTION-OFFLINE": "Нет связи", + "INVALID-PARAMS": "Неверные параметры URL." }, "SMOKE": { "HEADING": "Страница проверки" diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index e64c70af..b6080820 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -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": "请注意:", diff --git a/src/routes/[lang]/onlineboard/[params]/page.tsx b/src/routes/[lang]/onlineboard/[params]/page.tsx index 7b727350..417b9282 100644 --- a/src/routes/[lang]/onlineboard/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/[params]/page.tsx @@ -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 (
-

Invalid flight parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx index 360f3414..5b13f9f0 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx @@ -30,7 +30,7 @@ export default function ArrivalSearchPage(): JSX.Element { if (!parsed) { return (
-

Invalid arrival parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx index 5c8adbd1..ec6becfd 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx @@ -30,7 +30,7 @@ export default function DepartureSearchPage(): JSX.Element { if (!parsed) { return (
-

Invalid departure parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx index c3fef4e3..60c744b4 100644 --- a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx @@ -30,7 +30,7 @@ export default function FlightSearchPage(): JSX.Element { if (!parsed) { return (
-

Invalid flight parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx index 1155e585..f1ba3c03 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx @@ -30,7 +30,7 @@ export default function RouteSearchPage(): JSX.Element { if (!parsed) { return (
-

Invalid route parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/schedule/$.tsx b/src/routes/[lang]/schedule/$.tsx index f6612899..e0506e8e 100644 --- a/src/routes/[lang]/schedule/$.tsx +++ b/src/routes/[lang]/schedule/$.tsx @@ -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 (
-

Invalid schedule flight parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx index b300936f..66b64378 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -33,7 +33,7 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element { if (!outbound || !inbound) { return (
-

Invalid round-trip schedule parameters.

+

{t("SHARED.INVALID-PARAMS")}

); } diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx index 41257e0c..0ba74421 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -30,7 +30,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element { if (!parsed) { return (
-

Invalid schedule route parameters.

+

{t("SHARED.INVALID-PARAMS")}

); }