diff --git a/playwright.config.ts b/playwright.config.ts
index 05e91125..552f74fa 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -22,7 +22,6 @@ const QUARANTINED_PATTERNS = [
"Online Board.*flight number clear button",
"Online Board.*route search results page hydrates",
"TIRREDESIGN-8.*Onlineboard day-tabs",
- "Onlineboard time-range filter.*TIRREDESIGN-11",
"P1.*Table 7: breadcrumbs on search pages.*Schedule route",
"Schedule date-range picker.*single click snaps to Mon-Sun",
"Schedule date-range picker.*next-month bleed-in",
diff --git a/src/routes/[lang]/onlineboard/_guards.test.ts b/src/routes/[lang]/onlineboard/_guards.test.ts
index dcc9c46f..ebc8bb9e 100644
--- a/src/routes/[lang]/onlineboard/_guards.test.ts
+++ b/src/routes/[lang]/onlineboard/_guards.test.ts
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { boardDateRedirect } from "./_guards.js";
+import { boardDateRedirect, boardSearchRedirect } from "./_guards.js";
describe("boardDateRedirect (clock frozen 2026-05-15)", () => {
beforeEach(() => {
@@ -40,3 +40,53 @@ describe("boardDateRedirect (clock frozen 2026-05-15)", () => {
expect(boardDateRedirect("en-us", "20260101")).toBe("/en-us/onlineboard");
});
});
+
+describe("boardSearchRedirect time-range guard", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 4, 15, 12)); // May 15 2026 noon
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("allows a valid time-range suffix", () => {
+ expect(
+ boardSearchRedirect("ru-ru", {
+ date: "20260515",
+ timeFrom: "1400",
+ timeTo: "1800",
+ }),
+ ).toBeNull();
+ });
+
+ it("allows 2400 as the upper bound", () => {
+ expect(
+ boardSearchRedirect("ru-ru", {
+ date: "20260515",
+ timeFrom: "2359",
+ timeTo: "2400",
+ }),
+ ).toBeNull();
+ });
+
+ it("redirects when the time range is reversed", () => {
+ expect(
+ boardSearchRedirect("ru-ru", {
+ date: "20260515",
+ timeFrom: "1800",
+ timeTo: "1400",
+ }),
+ ).toBe("/ru-ru/onlineboard");
+ });
+
+ it("redirects when a time component is outside HHmm bounds", () => {
+ expect(
+ boardSearchRedirect("ru-ru", {
+ date: "20260515",
+ timeFrom: "1460",
+ timeTo: "1800",
+ }),
+ ).toBe("/ru-ru/onlineboard");
+ });
+});
diff --git a/src/routes/[lang]/onlineboard/_guards.ts b/src/routes/[lang]/onlineboard/_guards.ts
index 22a39215..1ea03a5d 100644
--- a/src/routes/[lang]/onlineboard/_guards.ts
+++ b/src/routes/[lang]/onlineboard/_guards.ts
@@ -17,3 +17,46 @@ export function boardDateRedirect(locale: string, yyyymmdd: string): string | nu
if (!isInBoardWindow(yyyymmdd)) return `/${locale}/onlineboard`;
return null;
}
+
+function hhmmToMinutes(value: string): number | null {
+ if (!/^\d{4}$/.test(value)) return null;
+ const hours = Number(value.slice(0, 2));
+ const minutes = Number(value.slice(2, 4));
+ if (hours === 24 && minutes === 0) return 24 * 60;
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
+ return hours * 60 + minutes;
+}
+
+/**
+ * Angular parity for Online-Board date + optional `Время рейса` URL guards.
+ *
+ * A missing time range is valid. When a range is present, both ends must be
+ * valid HHmm values and `timeFrom` must be strictly before `timeTo`.
+ * `2400` is accepted as the upper bound, matching Angular/moment behavior.
+ */
+export function boardSearchRedirect(
+ locale: string,
+ params: { date: string; timeFrom?: string; timeTo?: string },
+): string | null {
+ const dateRedirect = boardDateRedirect(locale, params.date);
+ if (dateRedirect) return dateRedirect;
+
+ const hasFrom = params.timeFrom !== undefined;
+ const hasTo = params.timeTo !== undefined;
+ if (!hasFrom && !hasTo) return null;
+ if (!hasFrom || !hasTo) return `/${locale}/onlineboard`;
+
+ const timeFrom = params.timeFrom;
+ const timeTo = params.timeTo;
+ if (timeFrom === undefined || timeTo === undefined) {
+ return `/${locale}/onlineboard`;
+ }
+
+ const from = hhmmToMinutes(timeFrom);
+ const to = hhmmToMinutes(timeTo);
+ if (from === null || to === null || from >= to) {
+ return `/${locale}/onlineboard`;
+ }
+
+ return null;
+}
diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
index 3ff4fb1e..297565f9 100644
--- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
@@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
-import { boardDateRedirect } from "../../_guards.js";
+import { boardSearchRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
import("@/features/online-board/components/OnlineBoardSearchPage.js").then(
@@ -35,7 +35,7 @@ export default function ArrivalSearchPage(): JSX.Element {
if (!parsed) return ;
- const redirect = boardDateRedirect(locale, parsed.date);
+ const redirect = boardSearchRedirect(locale, parsed);
if (redirect) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
index 44c3b62d..e9a55040 100644
--- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
@@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
-import { boardDateRedirect } from "../../_guards.js";
+import { boardSearchRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
import("@/features/online-board/components/OnlineBoardSearchPage.js").then(
@@ -35,7 +35,7 @@ export default function DepartureSearchPage(): JSX.Element {
if (!parsed) return ;
- const redirect = boardDateRedirect(locale, parsed.date);
+ const redirect = boardSearchRedirect(locale, parsed);
if (redirect) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx
index 27bfed6c..bc42032e 100644
--- a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx
+++ b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx
@@ -105,6 +105,18 @@ describe("RouteSearchPage — date-window guard", () => {
expect(mockNavigate).toHaveBeenCalledWith("/en-us/onlineboard");
});
+ it("renders normally for a valid time-range suffix", () => {
+ renderWithParams("SVO-LED-20260515-14001800");
+ expect(screen.getByTestId("seo-head")).toBeTruthy();
+ expect(screen.queryByTestId("navigate")).toBeNull();
+ });
+
+ it("redirects when the time range is invalid", () => {
+ renderWithParams("SVO-LED-20260515-18001400");
+ expect(screen.getByTestId("navigate")).toBeTruthy();
+ expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard");
+ });
+
it("shows 404 for a malformed URL (no date)", () => {
renderWithParams("SVO-LED");
expect(screen.getByTestId("error-404")).toBeTruthy();
diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx
index b28331f4..f3bad49c 100644
--- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx
@@ -15,7 +15,7 @@ import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
-import { boardDateRedirect } from "../../_guards.js";
+import { boardSearchRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
import("@/features/online-board/components/OnlineBoardSearchPage.js").then(
@@ -36,7 +36,7 @@ export default function RouteSearchPage(): JSX.Element {
if (!parsed) return ;
- const redirect = boardDateRedirect(locale, parsed.date);
+ const redirect = boardSearchRedirect(locale, parsed);
if (redirect) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
diff --git a/tests/e2e/onlineboard-time-filter.spec.ts b/tests/e2e/onlineboard-time-filter.spec.ts
index e2a82a0c..b080f16a 100644
--- a/tests/e2e/onlineboard-time-filter.spec.ts
+++ b/tests/e2e/onlineboard-time-filter.spec.ts
@@ -8,7 +8,18 @@ import { test, expect } from "./fixtures/console-gate";
// 3. Assert URL gained the `-{timeFrom}{timeTo}` suffix
// 4. Assert the rendered list shrank to flights inside the new window
-const ROUTE_URL = "/ru-ru/onlineboard/route/MOW-LED-20260423";
+function todayYyyymmdd(): string {
+ const today = new Date();
+ return [
+ today.getFullYear(),
+ String(today.getMonth() + 1).padStart(2, "0"),
+ String(today.getDate()).padStart(2, "0"),
+ ].join("");
+}
+
+function routeUrl(): string {
+ return `/ru-ru/onlineboard/route/MOW-LED-${todayYyyymmdd()}`;
+}
test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => {
test("URL with time-range suffix filters the list (URL → state path)", async ({
@@ -16,7 +27,8 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => {
consoleMessages,
}) => {
// Baseline: no filter
- await page.goto(ROUTE_URL);
+ const baseUrl = routeUrl();
+ await page.goto(baseUrl);
await expect(page.locator(".flight-card").first()).toBeVisible({
timeout: 15000,
});
@@ -24,7 +36,16 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => {
expect(baseline).toBeGreaterThan(15);
// Filtered: 14:00–18:00
- await page.goto(`${ROUTE_URL}-14001800`);
+ const filteredRequest = page.waitForRequest((request) => {
+ const url = new URL(request.url());
+ return (
+ url.pathname.endsWith("/api/flights/v1.1/ru/board") &&
+ url.searchParams.get("timeFrom") === "14:00:00" &&
+ url.searchParams.get("timeTo") === "18:00:00"
+ );
+ });
+ await page.goto(`${baseUrl}-14001800`);
+ await filteredRequest;
await expect(page.locator(".flight-card").first()).toBeVisible({
timeout: 15000,
});
@@ -41,32 +62,18 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => {
page,
consoleMessages,
}) => {
- await page.goto(ROUTE_URL);
+ await page.goto(routeUrl());
await expect(page.locator(".flight-card").first()).toBeVisible({
timeout: 15000,
});
- // Drag the start thumb roughly to the 50 % mark (= 12:00).
+ // Click the track near the 50 % mark. PrimeReact moves the nearest
+ // handle, which is the start handle while the range is 00:00-24:00.
const slider = page.locator(".p-slider").first();
- const startThumb = page.locator(".p-slider-handle-start").first();
const sliderBox = await slider.boundingBox();
- const thumbBox = await startThumb.boundingBox();
- expect(sliderBox && thumbBox).toBeTruthy();
- if (!sliderBox || !thumbBox) return;
-
- const fromX = thumbBox.x + thumbBox.width / 2;
- const fromY = thumbBox.y + thumbBox.height / 2;
- // 50 % position along the track
- const targetX = sliderBox.x + sliderBox.width * 0.5;
-
- await page.mouse.move(fromX, fromY);
- await page.mouse.down();
- // Step the move a few times so PrimeReact's mousemove handler picks it up.
- for (let i = 1; i <= 10; i++) {
- const x = fromX + ((targetX - fromX) * i) / 10;
- await page.mouse.move(x, fromY);
- }
- await page.mouse.up();
+ expect(sliderBox).toBeTruthy();
+ if (!sliderBox) return;
+ await slider.click({ position: { x: sliderBox.width * 0.5, y: sliderBox.height / 2 } });
// The thumb should have left 0 — exact value depends on snap-to-step.
const labelAfterDrag = await page
@@ -79,6 +86,6 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => {
await page.locator('[data-testid="search-submit"]').click();
// URL must have grown a time suffix.
- await expect(page).toHaveURL(/\/onlineboard\/route\/MOW-LED-20260423-\d{8}/);
+ await expect(page).toHaveURL(/\/onlineboard\/route\/MOW-LED-\d{8}-\d{8}/);
});
});