From 17476e4a89a5984b913f488a29d393b8e2ccd113 Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 14 May 2026 21:38:48 +0300 Subject: [PATCH] Clamp schedule API dates at window edges --- .../components/ScheduleSearchPage.tsx | 37 +++++++++++++++++-- .../schedule/hooks/useScheduleSearch.ts | 15 +++++++- tests/e2e/schedule-current-week-route.spec.ts | 22 +++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index 5cabdb69..1dc95c6e 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -30,6 +30,7 @@ import "./ScheduleSearchPage.scss"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import { useScheduleSearch } from "../hooks/useScheduleSearch.js"; import { buildScheduleUrl } from "../url.js"; +import { scheduleWindowBounds } from "@/shared/dateWindow.js"; import { buildFlightUrlParams } from "../../online-board/url.js"; import { buildDetailsRequestParam } from "@/shared/detailsRequestParam.js"; import { @@ -52,11 +53,12 @@ function toSearchRequest( direction: IScheduleRouteDirectionParams, attribute?: 1 | 2, ): IScheduleSearchRequest { + const [dateFrom, dateTo] = clampDirectionDatesToScheduleWindow(direction); const request: IScheduleSearchRequest = { departure: direction.departure, arrival: direction.arrival, - dateFrom: formatApiDate(direction.dateFrom), - dateTo: formatApiDate(direction.dateTo), + dateFrom: formatApiDate(dateFrom), + dateTo: formatApiDate(dateTo), }; if (direction.timeFrom) request.timeFrom = direction.timeFrom; @@ -75,6 +77,35 @@ function formatApiDate(yyyymmdd: string): string { return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; } +function yyyymmddToDate(value: string): Date | null { + if (!/^\d{8}$/.test(value)) return null; + const y = Number(value.slice(0, 4)); + const m = Number(value.slice(4, 6)); + const d = Number(value.slice(6, 8)); + const date = new Date(y, m - 1, d); + date.setHours(0, 0, 0, 0); + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return null; + return date; +} + +function dateToYyyymmdd(value: Date): string { + return `${value.getFullYear()}${String(value.getMonth() + 1).padStart(2, "0")}${String(value.getDate()).padStart(2, "0")}`; +} + +function clampDirectionDatesToScheduleWindow( + direction: IScheduleRouteDirectionParams, +): [string, string] { + const from = yyyymmddToDate(direction.dateFrom); + const to = yyyymmddToDate(direction.dateTo); + if (!from || !to) return [direction.dateFrom, direction.dateTo]; + + const [windowMin, windowMax] = scheduleWindowBounds(); + const clampedFrom = from.getTime() < windowMin.getTime() ? windowMin : from; + const clampedTo = to.getTime() > windowMax.getTime() ? windowMax : to; + + return [dateToYyyymmdd(clampedFrom), dateToYyyymmdd(clampedTo)]; +} + import { extractSimpleFlights } from "../extractSimpleFlights.js"; export const ScheduleSearchPage: FC = ({ params }) => { @@ -259,7 +290,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { flights: inboundFlights, loading: inboundLoading, cancel: cancelInbound, - } = useScheduleSearch(inboundRequest); + } = useScheduleSearch(inboundRequest, Boolean(inbound)); const isLoading = outboundLoading || (inbound ? inboundLoading : false); diff --git a/src/features/schedule/hooks/useScheduleSearch.ts b/src/features/schedule/hooks/useScheduleSearch.ts index 718c7c22..c23a3b1b 100644 --- a/src/features/schedule/hooks/useScheduleSearch.ts +++ b/src/features/schedule/hooks/useScheduleSearch.ts @@ -29,10 +29,11 @@ export interface UseScheduleSearchResult { */ export function useScheduleSearch( params: IScheduleSearchRequest, + enabled = true, ): UseScheduleSearchResult { const client = useApiClient(); const [flights, setFlights] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); @@ -55,6 +56,17 @@ export function useScheduleSearch( }, []); useEffect(() => { + if (!enabled) { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + setFlights([]); + setLoading(false); + setError(null); + return; + } + // Abort any previous in-flight request (§4.1.12 — new search aborts in-flight) if (abortRef.current) { abortRef.current.abort(); @@ -92,6 +104,7 @@ export function useScheduleSearch( params.timeFrom, params.timeTo, params.connections, + enabled, refreshKey, ]); diff --git a/tests/e2e/schedule-current-week-route.spec.ts b/tests/e2e/schedule-current-week-route.spec.ts index 03a5e87f..35f2a0b2 100644 --- a/tests/e2e/schedule-current-week-route.spec.ts +++ b/tests/e2e/schedule-current-week-route.spec.ts @@ -21,6 +21,13 @@ function yyyymmdd(date: Date): string { return `${y}${m}${d}`; } +function apiDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + function currentScheduleWeekRange(): [string, string] { const scheduleMinDate = addDays(new Date(), -1); const monday = startOfWeekMonday(scheduleMinDate); @@ -46,12 +53,25 @@ test.describe("Schedule VVO-MJZ week route parity", () => { consoleMessages, }) => { const [dateFrom, dateTo] = currentScheduleWeekRange(); + const minApiDate = apiDate(addDays(new Date(), -1)); + const scheduleSearch = page.waitForResponse( + (response) => + response.url().includes("/api/flights/1/ru/schedule") && + response.url().includes("departure=VVO") && + response.url().includes("arrival=MJZ"), + ); + await page.goto(`/ru-ru/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`); await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, { timeout: 30000, }); + await expect(page.getByTestId("loader-bar")).toBeHidden({ timeout: 30000 }); + await expect(page.getByText("Неверные параметры поиска")).toBeHidden(); await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`)); + + const requestUrl = new URL((await scheduleSearch).url()); + expect(requestUrl.searchParams.get("dateFrom")).toBe(minApiDate); }); test("next schedule week route renders without the search error page", async ({ @@ -64,6 +84,8 @@ test.describe("Schedule VVO-MJZ week route parity", () => { await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, { timeout: 30000, }); + await expect(page.getByTestId("loader-bar")).toBeHidden({ timeout: 30000 }); + await expect(page.getByText("Неверные параметры поиска")).toBeHidden(); await expect(page.getByText("Что-то пошло не так")).toBeHidden(); await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`)); });