Implement online board stale data timers
This commit is contained in:
+7
-1
@@ -22,7 +22,13 @@ const plugins = [
|
|||||||
// '{z}/{x}/{y}', so we serialize the whole payload to base64 and decode
|
// '{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
|
// it in the browser at runtime. Base64 output is A-Z/a-z/0-9/+/=/ — no
|
||||||
// braces for the template engine to grab.
|
// 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> = {};
|
const PUBLIC_ENV: Record<string, string> = {};
|
||||||
for (const k of PUBLIC_ENV_KEYS) {
|
for (const k of PUBLIC_ENV_KEYS) {
|
||||||
const v = process.env[k];
|
const v = process.env[k];
|
||||||
|
|||||||
Vendored
+6
@@ -20,6 +20,8 @@ const EnvSchema = z.object({
|
|||||||
// skip the connection when blank so the browser does not emit CORS
|
// skip the connection when blank so the browser does not emit CORS
|
||||||
// errors for an unreachable placeholder host.
|
// errors for an unreachable placeholder host.
|
||||||
SIGNALR_HUB_URL: z.string().default(""),
|
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_ENDPOINT: z.string().url().optional(),
|
||||||
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
||||||
LOGS_ENDPOINT: z.string().url().optional(),
|
LOGS_ENDPOINT: z.string().url().optional(),
|
||||||
@@ -45,6 +47,8 @@ export interface Env {
|
|||||||
PROD_ORIGIN: string;
|
PROD_ORIGIN: string;
|
||||||
API_BASE_URL: string;
|
API_BASE_URL: string;
|
||||||
SIGNALR_HUB_URL: string;
|
SIGNALR_HUB_URL: string;
|
||||||
|
REFRESH_PAUSE_MIN: number;
|
||||||
|
REFRESH_STOP_MIN: number;
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
||||||
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
||||||
LOGS_ENDPOINT?: string;
|
LOGS_ENDPOINT?: string;
|
||||||
@@ -92,6 +96,8 @@ export function getEnv(): Env {
|
|||||||
PROD_ORIGIN: raw.PROD_ORIGIN,
|
PROD_ORIGIN: raw.PROD_ORIGIN,
|
||||||
API_BASE_URL: raw.API_BASE_URL,
|
API_BASE_URL: raw.API_BASE_URL,
|
||||||
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
|
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
|
||||||
|
REFRESH_PAUSE_MIN: raw.REFRESH_PAUSE_MIN,
|
||||||
|
REFRESH_STOP_MIN: raw.REFRESH_STOP_MIN,
|
||||||
ANALYTICS_ENABLED: {
|
ANALYTICS_ENABLED: {
|
||||||
metrica: raw.ANALYTICS_METRICA,
|
metrica: raw.ANALYTICS_METRICA,
|
||||||
ctm: raw.ANALYTICS_CTM,
|
ctm: raw.ANALYTICS_CTM,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PageLayout } from "@/ui/layout/PageLayout.js";
|
|||||||
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
|
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
|
||||||
import { useFlightDetails } from "../hooks/useFlightDetails.js";
|
import { useFlightDetails } from "../hooks/useFlightDetails.js";
|
||||||
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
|
import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
|
||||||
|
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
|
||||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||||
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
|
||||||
import { buildFlightJsonLd } from "../json-ld.js";
|
import { buildFlightJsonLd } from "../json-ld.js";
|
||||||
@@ -31,6 +32,7 @@ import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js";
|
|||||||
import { DetailsBackButton } from "./DetailsBackButton/index.js";
|
import { DetailsBackButton } from "./DetailsBackButton/index.js";
|
||||||
import { FlightSchedule } from "./FlightSchedule/index.js";
|
import { FlightSchedule } from "./FlightSchedule/index.js";
|
||||||
import { FullRouteTimeline } from "./FullRouteTimeline/index.js";
|
import { FullRouteTimeline } from "./FullRouteTimeline/index.js";
|
||||||
|
import { StaleDataOverlay } from "./StaleDataOverlay.js";
|
||||||
import { TransferBar } from "./TransferBar/index.js";
|
import { TransferBar } from "./TransferBar/index.js";
|
||||||
import type { IParsedFlightId, IFlightLeg, FlightStatus as FlightStatusType } from "../types.js";
|
import type { IParsedFlightId, IFlightLeg, FlightStatus as FlightStatusType } from "../types.js";
|
||||||
import {
|
import {
|
||||||
@@ -403,6 +405,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
|||||||
flight,
|
flight,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
const isStale = useStaleDataTimers();
|
||||||
|
|
||||||
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
|
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
|
||||||
|
|
||||||
@@ -619,6 +622,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonLdRenderer data={jsonLd} />
|
<JsonLdRenderer data={jsonLd} />
|
||||||
|
{isStale && (
|
||||||
|
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
|
||||||
|
)}
|
||||||
<PageLayout
|
<PageLayout
|
||||||
headerLeft={<DetailsBackButton locale={locale} />}
|
headerLeft={<DetailsBackButton locale={locale} />}
|
||||||
title={<h1 className="flight-details__flight-number">{pageTitle}</h1>}
|
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 { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||||
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
||||||
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
||||||
|
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
|
||||||
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||||
import { buildOnlineBoardUrl } from "../url.js";
|
import { buildOnlineBoardUrl } from "../url.js";
|
||||||
import { buildFlightListJsonLd } from "../json-ld.js";
|
import { buildFlightListJsonLd } from "../json-ld.js";
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
PobedaAuroraBanner,
|
PobedaAuroraBanner,
|
||||||
shouldShowPobedaAuroraBanner,
|
shouldShowPobedaAuroraBanner,
|
||||||
} from "./PobedaAuroraBanner.js";
|
} from "./PobedaAuroraBanner.js";
|
||||||
|
import { StaleDataOverlay } from "./StaleDataOverlay.js";
|
||||||
import type { SortMode } from "../sortFlights.js";
|
import type { SortMode } from "../sortFlights.js";
|
||||||
import type { OnlineBoardParams } from "../url.js";
|
import type { OnlineBoardParams } from "../url.js";
|
||||||
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
||||||
@@ -377,6 +379,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
flights,
|
flights,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
const isStale = useStaleDataTimers();
|
||||||
|
|
||||||
// Calendar days
|
// Calendar days
|
||||||
const calendarParams = toCalendarParams(params);
|
const calendarParams = toCalendarParams(params);
|
||||||
@@ -463,6 +466,9 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|||||||
data-searching={loading ? "true" : undefined}
|
data-searching={loading ? "true" : undefined}
|
||||||
>
|
>
|
||||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||||
|
{isStale && (
|
||||||
|
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
|
||||||
|
)}
|
||||||
<PageLayout
|
<PageLayout
|
||||||
headerLeft={<PageTabs viewType="onlineboard" />}
|
headerLeft={<PageTabs viewType="onlineboard" />}
|
||||||
title={
|
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;
|
||||||
|
}
|
||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -432,6 +432,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -432,6 +432,7 @@
|
|||||||
"CONNECTION-LIVE": "Онлайн",
|
"CONNECTION-LIVE": "Онлайн",
|
||||||
"CONNECTION-RECONNECTING": "Соединение…",
|
"CONNECTION-RECONNECTING": "Соединение…",
|
||||||
"CONNECTION-OFFLINE": "Нет связи",
|
"CONNECTION-OFFLINE": "Нет связи",
|
||||||
|
"STALE-DATA-REFRESH": "Данные устарели, обновите страницу!",
|
||||||
"INVALID-PARAMS": "Неверные параметры URL.",
|
"INVALID-PARAMS": "Неверные параметры URL.",
|
||||||
"LOADING": "Загрузка…",
|
"LOADING": "Загрузка…",
|
||||||
"A11Y-PREV-PAGE": "Предыдущая страница",
|
"A11Y-PREV-PAGE": "Предыдущая страница",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@
|
|||||||
"CONNECTION-LIVE": "Live",
|
"CONNECTION-LIVE": "Live",
|
||||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||||
"CONNECTION-OFFLINE": "Offline",
|
"CONNECTION-OFFLINE": "Offline",
|
||||||
|
"STALE-DATA-REFRESH": "Data is outdated, refresh the page.",
|
||||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||||
"LOADING": "Loading…",
|
"LOADING": "Loading…",
|
||||||
"A11Y-PREV-PAGE": "Previous page",
|
"A11Y-PREV-PAGE": "Previous page",
|
||||||
|
|||||||
@@ -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")}`;
|
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 {
|
export function formatRuDate(date: Date): string {
|
||||||
return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`;
|
return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -264,6 +264,55 @@ test.describe("Online Board", () => {
|
|||||||
await expect(arrInput).toHaveValue("Самара");
|
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).
|
// Requires live API (calendar days endpoint).
|
||||||
// Skipped when WAF blocks flights.test.aeroflot.ru.
|
// Skipped when WAF blocks flights.test.aeroflot.ru.
|
||||||
test.skip("route search results page shows calendar strip with day numbers", async ({
|
test.skip("route search results page shows calendar strip with day numbers", async ({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test, expect } from "./fixtures/console-gate";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { nextOnlineboardDetailsFixture } from "./helpers/onlineboard-fixtures";
|
||||||
|
|
||||||
// TIRREDESIGN-24 — in the online-board flight details card, the aircraft
|
// 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.
|
// 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,
|
context,
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
}) => {
|
}) => {
|
||||||
|
const details = nextOnlineboardDetailsFixture(onlineboardDetails);
|
||||||
|
|
||||||
await page.route("**/api/flights/v1.1/ru/onlineboard/details?**", async (route) => {
|
await page.route("**/api/flights/v1.1/ru/onlineboard/details?**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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"]');
|
const aircraftRow = page.locator('[data-testid="details-row-aircraft"]');
|
||||||
await expect(aircraftRow).toBeVisible({ timeout: 15000 });
|
await expect(aircraftRow).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test, expect } from "./fixtures/console-gate";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { nextOnlineboardDetailsFixture } from "./helpers/onlineboard-fixtures";
|
||||||
|
|
||||||
const FIXTURE_DIR = path.resolve(
|
const FIXTURE_DIR = path.resolve(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
@@ -16,24 +17,26 @@ test("TIRREDESIGN-7: onlineboard details shows non-scheduled transition even whe
|
|||||||
page,
|
page,
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
}) => {
|
}) => {
|
||||||
const details = structuredClone(baseDetails);
|
const shifted = nextOnlineboardDetailsFixture(JSON.stringify(baseDetails));
|
||||||
|
const details = JSON.parse(shifted.body);
|
||||||
const flight = details.data.routes[0];
|
const flight = details.data.routes[0];
|
||||||
const leg = flight.leg;
|
const leg = flight.leg;
|
||||||
|
const isoDate = `${shifted.compactDate.slice(0, 4)}-${shifted.compactDate.slice(4, 6)}-${shifted.compactDate.slice(6, 8)}`;
|
||||||
|
|
||||||
flight.status = "InFlight";
|
flight.status = "InFlight";
|
||||||
leg.status = "InFlight";
|
leg.status = "InFlight";
|
||||||
leg.transition = {
|
leg.transition = {
|
||||||
registration: {
|
registration: {
|
||||||
start: {
|
start: {
|
||||||
utc: "2026-05-14T07:00:00Z",
|
utc: `${isoDate}T07:00:00Z`,
|
||||||
local: "2026-05-14T10:00:00+03:00",
|
local: `${isoDate}T10:00:00+03:00`,
|
||||||
dayChange: { value: 0, title: "" },
|
dayChange: { value: 0, title: "" },
|
||||||
localTime: "10:00",
|
localTime: "10:00",
|
||||||
tzOffset: 180,
|
tzOffset: 180,
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
utc: "2026-05-14T07:30:00Z",
|
utc: `${isoDate}T07:30:00Z`,
|
||||||
local: "2026-05-14T10:30:00+03:00",
|
local: `${isoDate}T10:30:00+03:00`,
|
||||||
dayChange: { value: 0, title: "" },
|
dayChange: { value: 0, title: "" },
|
||||||
localTime: "10:30",
|
localTime: "10:30",
|
||||||
tzOffset: 180,
|
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"]');
|
const registrationRow = page.locator('[data-testid="details-row-registration"]');
|
||||||
await expect(registrationRow).toBeVisible({ timeout: 15000 });
|
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-tabs")).toBeVisible({ timeout: 15000 });
|
||||||
await expect(page.getByTestId("day-tab-20260519")).toBeEnabled();
|
await expect(page.getByTestId("day-tab-20260519")).toBeEnabled();
|
||||||
|
|
||||||
await page.getByTestId("day-tabs-next").click();
|
|
||||||
const nonOperatingFriday = page.getByTestId("day-tab-20260522");
|
const nonOperatingFriday = page.getByTestId("day-tab-20260522");
|
||||||
await expect(nonOperatingFriday).toBeDisabled();
|
await expect(nonOperatingFriday).toBeDisabled();
|
||||||
await expect(page.getByTestId("day-tab-20260523")).toBeEnabled();
|
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 ({
|
test("schedule route page surfaces the buy ticket button inside an expanded flight body", async ({
|
||||||
page,
|
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);
|
await routeScheduleVvoMjzFixtures(page);
|
||||||
// Pick a calendar week well clear of the 2h pre-departure cutoff so every
|
// 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
|
// flight in the list is inside the buy window. Earlier-this-week URLs hit
|
||||||
|
|||||||
Reference in New Issue
Block a user