Clamp schedule API dates at window edges
This commit is contained in:
@@ -30,6 +30,7 @@ import "./ScheduleSearchPage.scss";
|
|||||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||||
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
||||||
import { buildScheduleUrl } from "../url.js";
|
import { buildScheduleUrl } from "../url.js";
|
||||||
|
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
||||||
import { buildFlightUrlParams } from "../../online-board/url.js";
|
import { buildFlightUrlParams } from "../../online-board/url.js";
|
||||||
import { buildDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
import { buildDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||||
import {
|
import {
|
||||||
@@ -52,11 +53,12 @@ function toSearchRequest(
|
|||||||
direction: IScheduleRouteDirectionParams,
|
direction: IScheduleRouteDirectionParams,
|
||||||
attribute?: 1 | 2,
|
attribute?: 1 | 2,
|
||||||
): IScheduleSearchRequest {
|
): IScheduleSearchRequest {
|
||||||
|
const [dateFrom, dateTo] = clampDirectionDatesToScheduleWindow(direction);
|
||||||
const request: IScheduleSearchRequest = {
|
const request: IScheduleSearchRequest = {
|
||||||
departure: direction.departure,
|
departure: direction.departure,
|
||||||
arrival: direction.arrival,
|
arrival: direction.arrival,
|
||||||
dateFrom: formatApiDate(direction.dateFrom),
|
dateFrom: formatApiDate(dateFrom),
|
||||||
dateTo: formatApiDate(direction.dateTo),
|
dateTo: formatApiDate(dateTo),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (direction.timeFrom) request.timeFrom = direction.timeFrom;
|
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)}`;
|
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";
|
import { extractSimpleFlights } from "../extractSimpleFlights.js";
|
||||||
|
|
||||||
export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||||
@@ -259,7 +290,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
|||||||
flights: inboundFlights,
|
flights: inboundFlights,
|
||||||
loading: inboundLoading,
|
loading: inboundLoading,
|
||||||
cancel: cancelInbound,
|
cancel: cancelInbound,
|
||||||
} = useScheduleSearch(inboundRequest);
|
} = useScheduleSearch(inboundRequest, Boolean(inbound));
|
||||||
|
|
||||||
const isLoading = outboundLoading || (inbound ? inboundLoading : false);
|
const isLoading = outboundLoading || (inbound ? inboundLoading : false);
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ export interface UseScheduleSearchResult {
|
|||||||
*/
|
*/
|
||||||
export function useScheduleSearch(
|
export function useScheduleSearch(
|
||||||
params: IScheduleSearchRequest,
|
params: IScheduleSearchRequest,
|
||||||
|
enabled = true,
|
||||||
): UseScheduleSearchResult {
|
): UseScheduleSearchResult {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
const [flights, setFlights] = useState<IFlight[]>([]);
|
const [flights, setFlights] = useState<IFlight[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(enabled);
|
||||||
const [error, setError] = useState<ApiError | null>(null);
|
const [error, setError] = useState<ApiError | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
@@ -55,6 +56,17 @@ export function useScheduleSearch(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
// Abort any previous in-flight request (§4.1.12 — new search aborts in-flight)
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
abortRef.current.abort();
|
abortRef.current.abort();
|
||||||
@@ -92,6 +104,7 @@ export function useScheduleSearch(
|
|||||||
params.timeFrom,
|
params.timeFrom,
|
||||||
params.timeTo,
|
params.timeTo,
|
||||||
params.connections,
|
params.connections,
|
||||||
|
enabled,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ function yyyymmdd(date: Date): string {
|
|||||||
return `${y}${m}${d}`;
|
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] {
|
function currentScheduleWeekRange(): [string, string] {
|
||||||
const scheduleMinDate = addDays(new Date(), -1);
|
const scheduleMinDate = addDays(new Date(), -1);
|
||||||
const monday = startOfWeekMonday(scheduleMinDate);
|
const monday = startOfWeekMonday(scheduleMinDate);
|
||||||
@@ -46,12 +53,25 @@ test.describe("Schedule VVO-MJZ week route parity", () => {
|
|||||||
consoleMessages,
|
consoleMessages,
|
||||||
}) => {
|
}) => {
|
||||||
const [dateFrom, dateTo] = currentScheduleWeekRange();
|
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 page.goto(`/ru-ru/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`);
|
||||||
|
|
||||||
await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, {
|
await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, {
|
||||||
timeout: 30000,
|
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}`));
|
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 ({
|
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)/, {
|
await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, {
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
await expect(page.getByTestId("loader-bar")).toBeHidden({ timeout: 30000 });
|
||||||
|
await expect(page.getByText("Неверные параметры поиска")).toBeHidden();
|
||||||
await expect(page.getByText("Что-то пошло не так")).toBeHidden();
|
await expect(page.getByText("Что-то пошло не так")).toBeHidden();
|
||||||
await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`));
|
await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user