Implement online board stale data timers

This commit is contained in:
2026-05-18 18:28:40 +03:00
parent cac3846657
commit f1ab656305
24 changed files with 323 additions and 10 deletions
+7 -1
View File
@@ -22,7 +22,13 @@ const plugins = [
// '{z}/{x}/{y}', so we serialize the whole payload to base64 and decode
// it in the browser at runtime. Base64 output is A-Z/a-z/0-9/+/=/ — no
// braces for the template engine to grab.
const PUBLIC_ENV_KEYS = ["MAP_TILE_URL", "API_BASE_URL", "SIGNALR_HUB_URL"] as const;
const PUBLIC_ENV_KEYS = [
"MAP_TILE_URL",
"API_BASE_URL",
"SIGNALR_HUB_URL",
"REFRESH_PAUSE_MIN",
"REFRESH_STOP_MIN",
] as const;
const PUBLIC_ENV: Record<string, string> = {};
for (const k of PUBLIC_ENV_KEYS) {
const v = process.env[k];
+6
View File
@@ -20,6 +20,8 @@ const EnvSchema = z.object({
// skip the connection when blank so the browser does not emit CORS
// errors for an unreachable placeholder host.
SIGNALR_HUB_URL: z.string().default(""),
REFRESH_PAUSE_MIN: z.coerce.number().nonnegative().default(15),
REFRESH_STOP_MIN: z.coerce.number().nonnegative().default(60),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
LOGS_ENDPOINT: z.string().url().optional(),
@@ -45,6 +47,8 @@ export interface Env {
PROD_ORIGIN: string;
API_BASE_URL: string;
SIGNALR_HUB_URL: string;
REFRESH_PAUSE_MIN: number;
REFRESH_STOP_MIN: number;
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
OTEL_EXPORTER_OTLP_HEADERS?: string;
LOGS_ENDPOINT?: string;
@@ -92,6 +96,8 @@ export function getEnv(): Env {
PROD_ORIGIN: raw.PROD_ORIGIN,
API_BASE_URL: raw.API_BASE_URL,
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
REFRESH_PAUSE_MIN: raw.REFRESH_PAUSE_MIN,
REFRESH_STOP_MIN: raw.REFRESH_STOP_MIN,
ANALYTICS_ENABLED: {
metrica: raw.ANALYTICS_METRICA,
ctm: raw.ANALYTICS_CTM,
@@ -18,6 +18,7 @@ import { PageLayout } from "@/ui/layout/PageLayout.js";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
import { useFlightDetails } from "../hooks/useFlightDetails.js";
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
import { buildFlightJsonLd } from "../json-ld.js";
@@ -31,6 +32,7 @@ import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js";
import { DetailsBackButton } from "./DetailsBackButton/index.js";
import { FlightSchedule } from "./FlightSchedule/index.js";
import { FullRouteTimeline } from "./FullRouteTimeline/index.js";
import { StaleDataOverlay } from "./StaleDataOverlay.js";
import { TransferBar } from "./TransferBar/index.js";
import type { IParsedFlightId, IFlightLeg, FlightStatus as FlightStatusType } from "../types.js";
import {
@@ -403,6 +405,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
flight,
refresh,
);
const isStale = useStaleDataTimers();
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
@@ -619,6 +622,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
return (
<>
<JsonLdRenderer data={jsonLd} />
{isStale && (
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
)}
<PageLayout
headerLeft={<DetailsBackButton locale={locale} />}
title={<h1 className="flight-details__flight-number">{pageTitle}</h1>}
@@ -32,6 +32,7 @@ import "./OnlineBoardSearchPage.scss";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
import { useCalendarDays } from "../hooks/useCalendarDays.js";
import { buildOnlineBoardUrl } from "../url.js";
import { buildFlightListJsonLd } from "../json-ld.js";
@@ -41,6 +42,7 @@ import {
PobedaAuroraBanner,
shouldShowPobedaAuroraBanner,
} from "./PobedaAuroraBanner.js";
import { StaleDataOverlay } from "./StaleDataOverlay.js";
import type { SortMode } from "../sortFlights.js";
import type { OnlineBoardParams } from "../url.js";
import type { SearchFlightsParams, CalendarParams } from "../api.js";
@@ -377,6 +379,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
flights,
refresh,
);
const isStale = useStaleDataTimers();
// Calendar days
const calendarParams = toCalendarParams(params);
@@ -463,6 +466,9 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
data-searching={loading ? "true" : undefined}
>
{jsonLd && <JsonLdRenderer data={jsonLd} />}
{isStale && (
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
)}
<PageLayout
headerLeft={<PageTabs viewType="onlineboard" />}
title={
@@ -0,0 +1,16 @@
.stale-data-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: flex-start;
justify-content: center;
box-sizing: border-box;
padding: 20vh 24px 24px;
background: rgb(255 255 255 / 70%);
color: #111827;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
text-align: center;
}
@@ -0,0 +1,17 @@
import type { FC } from "react";
import "./StaleDataOverlay.scss";
interface StaleDataOverlayProps {
message: string;
}
export const StaleDataOverlay: FC<StaleDataOverlayProps> = ({ message }) => (
<div
className="stale-data-overlay"
data-testid="stale-data-overlay"
role="alert"
aria-live="assertive"
>
{message}
</div>
);
@@ -0,0 +1,72 @@
/**
* @vitest-environment jsdom
*/
import { renderHook, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useStaleDataTimers } from "./useStaleDataTimers.js";
describe("useStaleDataTimers", () => {
const originalLocation = window.location;
const assign = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
assign.mockClear();
Object.defineProperty(window, "location", {
value: { assign, pathname: "/ru/onlineboard" },
writable: true,
});
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(window, "location", {
value: originalLocation,
writable: true,
});
});
it("marks data stale after the pause timeout", () => {
const { result } = renderHook(() =>
useStaleDataTimers({ pauseMinutes: 0.001, stopMinutes: 1 }),
);
expect(result.current).toBe(false);
act(() => {
vi.advanceTimersByTime(60);
});
expect(result.current).toBe(true);
});
it("redirects after the stop timeout", () => {
renderHook(() =>
useStaleDataTimers({
pauseMinutes: 1,
stopMinutes: 0.001,
redirectTo: "/",
}),
);
act(() => {
vi.advanceTimersByTime(60);
});
expect(assign).toHaveBeenCalledWith("/");
});
it("clears timers on unmount", () => {
const { unmount } = renderHook(() =>
useStaleDataTimers({ pauseMinutes: 0.001, stopMinutes: 0.002 }),
);
unmount();
act(() => {
vi.advanceTimersByTime(120);
});
expect(assign).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { getEnv } from "@/env/index.js";
const MILLIS_PER_MINUTE = 60_000;
export interface StaleDataTimersOptions {
pauseMinutes?: number;
stopMinutes?: number;
redirectTo?: string;
}
function toDelay(minutes: number): number {
return Math.max(0, minutes * MILLIS_PER_MINUTE);
}
export function useStaleDataTimers(options: StaleDataTimersOptions = {}): boolean {
const env = getEnv();
const pauseMinutes = options.pauseMinutes ?? env.REFRESH_PAUSE_MIN;
const stopMinutes = options.stopMinutes ?? env.REFRESH_STOP_MIN;
const redirectTo = options.redirectTo ?? "/";
const [stale, setStale] = useState(false);
useEffect(() => {
setStale(false);
const pauseTimer = window.setTimeout(() => {
setStale(true);
}, toDelay(pauseMinutes));
const stopTimer = window.setTimeout(() => {
window.location.assign(redirectTo);
}, toDelay(stopMinutes));
return () => {
window.clearTimeout(pauseTimer);
window.clearTimeout(stopTimer);
};
}, [pauseMinutes, redirectTo, stopMinutes]);
return stale;
}
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -432,6 +432,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+1
View File
@@ -432,6 +432,7 @@
"CONNECTION-LIVE": "Онлайн",
"CONNECTION-RECONNECTING": "Соединение…",
"CONNECTION-OFFLINE": "Нет связи",
"STALE-DATA-REFRESH": "Данные устарели, обновите страницу!",
"INVALID-PARAMS": "Неверные параметры URL.",
"LOADING": "Загрузка…",
"A11Y-PREV-PAGE": "Предыдущая страница",
+1
View File
@@ -392,6 +392,7 @@
"CONNECTION-LIVE": "Live",
"CONNECTION-RECONNECTING": "Reconnecting…",
"CONNECTION-OFFLINE": "Offline",
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
"INVALID-PARAMS": "Invalid URL parameters.",
"LOADING": "Loading…",
"A11Y-PREV-PAGE": "Previous page",
+4
View File
@@ -9,6 +9,10 @@ export function formatYmd(date: Date): string {
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
}
export function formatIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export function formatRuDate(date: Date): string {
return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`;
}
+57
View File
@@ -0,0 +1,57 @@
import { addDays, formatIsoDate, formatYmd } from "./dates";
type DateTimeValue = {
utc?: string;
local?: string;
localTime?: string;
};
type OnlineboardDetailsFixture = {
data: {
routes: Array<{
flightId: {
dateLT?: string;
date: string;
};
leg: {
departure: { times: Record<string, DateTimeValue> };
arrival: { times: Record<string, DateTimeValue> };
daysForTabs?: string[];
};
}>;
};
};
function replaceDatePart(value: string | undefined, isoDate: string): string | undefined {
return value?.replace(/^\d{4}-\d{2}-\d{2}/, isoDate);
}
export function nextOnlineboardDetailsFixture(raw: string): {
body: string;
compactDate: string;
} {
const date = addDays(new Date(), 1);
const isoDate = formatIsoDate(date);
const compactDate = formatYmd(date);
const fixture = JSON.parse(raw) as OnlineboardDetailsFixture;
for (const route of fixture.data.routes) {
route.flightId.date = isoDate;
route.flightId.dateLT = isoDate;
for (const point of [route.leg.departure, route.leg.arrival]) {
for (const value of Object.values(point.times)) {
const utc = replaceDatePart(value.utc, isoDate);
if (utc !== undefined) value.utc = utc;
const local = replaceDatePart(value.local, isoDate);
if (local !== undefined) value.local = local;
}
}
route.leg.daysForTabs = Array.from({ length: 15 }, (_, index) =>
formatYmd(addDays(date, index)),
);
}
return { body: JSON.stringify(fixture), compactDate };
}
+49
View File
@@ -264,6 +264,55 @@ test.describe("Online Board", () => {
await expect(arrInput).toHaveValue("Самара");
});
test("route results show stale-data overlay after inactivity timeout", async ({
page,
consoleMessages,
}) => {
await page.addInitScript(() => {
const target = window as unknown as { __ENV__?: Record<string, string> };
target.__ENV__ = {
...(target.__ENV__ ?? {}),
REFRESH_PAUSE_MIN: "0.01",
REFRESH_STOP_MIN: "1",
};
});
await routeDictionaryFixtures(page);
await routeOnlineboardRouteFixtures(page);
await page.goto(`/ru/onlineboard/route/MOW-KUF-${formatYmd(new Date())}`);
await expect(page.locator('[data-testid="online-board-search"]')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('[data-testid="stale-data-overlay"]')).toHaveText(
"Данные устарели, обновите страницу!",
{ timeout: 2000 },
);
});
test("route results redirect to main page after stale-data stop timeout", async ({
page,
consoleMessages,
}) => {
await page.addInitScript(() => {
const target = window as unknown as { __ENV__?: Record<string, string> };
target.__ENV__ = {
...(target.__ENV__ ?? {}),
REFRESH_PAUSE_MIN: "0.001",
REFRESH_STOP_MIN: "0.02",
};
});
await routeDictionaryFixtures(page);
await routeOnlineboardRouteFixtures(page);
await page.goto(`/ru/onlineboard/route/MOW-KUF-${formatYmd(new Date())}`);
await expect(page.locator('[data-testid="online-board-search"]')).toBeVisible({
timeout: 10000,
});
await page.waitForURL(/\/(ru\/onlineboard)?$/, { timeout: 4000 });
});
// Requires live API (calendar days endpoint).
// Skipped when WAF blocks flights.test.aeroflot.ru.
test.skip("route search results page shows calendar strip with day numbers", async ({
+5 -2
View File
@@ -2,6 +2,7 @@ import { test, expect } from "./fixtures/console-gate";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { nextOnlineboardDetailsFixture } from "./helpers/onlineboard-fixtures";
// TIRREDESIGN-24 — in the online-board flight details card, the aircraft
// title under "Борт" must be a clickable external link that opens in a new tab.
@@ -22,11 +23,13 @@ test("Onlineboard details aircraft title opens Aeroflot plane park in a new tab"
context,
consoleMessages,
}) => {
const details = nextOnlineboardDetailsFixture(onlineboardDetails);
await page.route("**/api/flights/v1.1/ru/onlineboard/details?**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: onlineboardDetails,
body: details.body,
});
});
@@ -38,7 +41,7 @@ test("Onlineboard details aircraft title opens Aeroflot plane park in a new tab"
});
});
await page.goto("/ru-ru/onlineboard/SU6951-20260514");
await page.goto(`/ru-ru/onlineboard/SU6951-${details.compactDate}`);
const aircraftRow = page.locator('[data-testid="details-row-aircraft"]');
await expect(aircraftRow).toBeVisible({ timeout: 15000 });
@@ -2,6 +2,7 @@ import { test, expect } from "./fixtures/console-gate";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { nextOnlineboardDetailsFixture } from "./helpers/onlineboard-fixtures";
const FIXTURE_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -16,24 +17,26 @@ test("TIRREDESIGN-7: onlineboard details shows non-scheduled transition even whe
page,
consoleMessages,
}) => {
const details = structuredClone(baseDetails);
const shifted = nextOnlineboardDetailsFixture(JSON.stringify(baseDetails));
const details = JSON.parse(shifted.body);
const flight = details.data.routes[0];
const leg = flight.leg;
const isoDate = `${shifted.compactDate.slice(0, 4)}-${shifted.compactDate.slice(4, 6)}-${shifted.compactDate.slice(6, 8)}`;
flight.status = "InFlight";
leg.status = "InFlight";
leg.transition = {
registration: {
start: {
utc: "2026-05-14T07:00:00Z",
local: "2026-05-14T10:00:00+03:00",
utc: `${isoDate}T07:00:00Z`,
local: `${isoDate}T10:00:00+03:00`,
dayChange: { value: 0, title: "" },
localTime: "10:00",
tzOffset: 180,
},
end: {
utc: "2026-05-14T07:30:00Z",
local: "2026-05-14T10:30:00+03:00",
utc: `${isoDate}T07:30:00Z`,
local: `${isoDate}T10:30:00+03:00`,
dayChange: { value: 0, title: "" },
localTime: "10:30",
tzOffset: 180,
@@ -51,7 +54,7 @@ test("TIRREDESIGN-7: onlineboard details shows non-scheduled transition even whe
});
});
await page.goto("/ru-ru/onlineboard/SU6951-20260514");
await page.goto(`/ru-ru/onlineboard/SU6951-${shifted.compactDate}`);
const registrationRow = page.locator('[data-testid="details-row-registration"]');
await expect(registrationRow).toBeVisible({ timeout: 15000 });
@@ -52,7 +52,6 @@ test("TIRREDESIGN-26: schedule details day tabs disable non-operating flight dat
await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId("day-tab-20260519")).toBeEnabled();
await page.getByTestId("day-tabs-next").click();
const nonOperatingFriday = page.getByTestId("day-tab-20260522");
await expect(nonOperatingFriday).toBeDisabled();
await expect(page.getByTestId("day-tab-20260523")).toBeEnabled();
@@ -12,6 +12,26 @@ import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures";
test("schedule route page surfaces the buy ticket button inside an expanded flight body", async ({
page,
}) => {
await page.addInitScript(() => {
const fixedTime = new Date("2026-05-17T00:00:00+03:00").getTime();
const RealDate = Date;
const FixedDate = function fixedDate(
this: Date,
...args: unknown[]
) {
return args.length === 0
? new RealDate(fixedTime)
: Reflect.construct(RealDate, args);
} as unknown as DateConstructor;
FixedDate.now = () => fixedTime;
FixedDate.parse = RealDate.parse;
FixedDate.UTC = RealDate.UTC;
Object.defineProperty(FixedDate, "prototype", {
value: RealDate.prototype,
});
Object.setPrototypeOf(FixedDate, RealDate);
window.Date = FixedDate;
});
await routeScheduleVvoMjzFixtures(page);
// Pick a calendar week well clear of the 2h pre-departure cutoff so every
// flight in the list is inside the buy window. Earlier-this-week URLs hit