62 Commits

Author SHA1 Message Date
gnezim da233c6d08 Remove flight share buttons
ci-deploy / build-deploy-test (push) Successful in 1m30s
2026-05-28 13:23:10 +03:00
gnezim 02ee9b5cfd Fix share button URL parity 2026-05-28 12:55:34 +03:00
gnezim 5e33debfb4 Stabilize schedule e2e date fixtures 2026-05-28 11:56:40 +03:00
gnezim 5c309004f0 Fix first city click on flights map
ci-deploy / build-deploy-test (push) Successful in 2m12s
2026-05-26 11:21:14 +03:00
gnezim a346aa071e Include Aeroflot rewrite helper in runtime image 2026-05-22 11:45:50 +03:00
gnezim 6e947f2aa9 Fix Aeroflot shell navigation links 2026-05-22 11:13:16 +03:00
gnezim 1158673fdf Fix flights map transfer fallback timing 2026-05-22 10:04:33 +03:00
gnezim ca9978f003 Ignore malformed direct map routes 2026-05-21 19:22:23 +03:00
gnezim 19dbdc5127 Fix flights map transfer toggle fallback 2026-05-21 19:07:13 +03:00
gnezim 99562c2218 Proxy Aeroflot shell in standalone 2026-05-21 11:05:04 +03:00
gnezim 2b47ca799f Support Angular country-language locales 2026-05-20 20:11:38 +03:00
gnezim 7fd789e06a Keep dev-full running in background 2026-05-20 17:49:59 +03:00
gnezim 16725f013f Hydrate Aeroflot shell in local dev 2026-05-20 17:46:19 +03:00
gnezim 1832b80374 Mirror Angular shell placeholders 2026-05-20 16:30:07 +03:00
gnezim ee08795811 Restore standalone shell chrome 2026-05-20 16:01:30 +03:00
gnezim 80087ded8b Hide board number in flight details 2026-05-19 16:12:09 +03:00
gnezim 9345eb162a Avoid duplicate flight detail SignalR subscribe
ci-deploy / build-deploy-test (push) Successful in 1m31s
2026-05-19 00:53:14 +03:00
gnezim 5c3f49204c Match stale data overlay styling
ci-deploy / build-deploy-test (push) Successful in 1m31s
2026-05-18 18:42:08 +03:00
gnezim f1ab656305 Implement online board stale data timers 2026-05-18 18:28:40 +03:00
gnezim cac3846657 Fix schedule details local date navigation
ci-deploy / build-deploy-test (push) Successful in 1m50s
2026-05-15 21:55:55 +03:00
gnezim f6943a53ce Show onboard services in schedule details 2026-05-15 18:17:03 +03:00
gnezim 3275203303 Align schedule edge-week guard tests 2026-05-15 01:03:47 +03:00
gnezim c96912fbb0 Respect schedule flight operating days 2026-05-15 00:15:17 +03:00
gnezim e0b69bf35f Stabilize schedule and board e2e parity 2026-05-14 23:34:32 +03:00
gnezim 1b183c334d Align schedule filter submit parity 2026-05-14 22:49:45 +03:00
gnezim 43ef9bb710 Allow changed schedule searches after submit 2026-05-14 22:36:07 +03:00
gnezim 17476e4a89 Clamp schedule API dates at window edges 2026-05-14 21:38:48 +03:00
gnezim 0284372385 Allow schedule weeks at date window edges 2026-05-14 19:15:27 +03:00
gnezim 147183ef90 Match Angular transition visibility 2026-05-14 18:11:52 +03:00
gnezim 6cf57596bf Fix schedule aircraft link target 2026-05-14 17:22:11 +03:00
gnezim b3d242e7e0 Fix aircraft link in flight details 2026-05-14 17:02:08 +03:00
gnezim 4f5786ee30 Stop map event propagation on city click 2026-05-14 15:26:37 +03:00
gnezim 30f1ee7873 Preserve online board flight suffixes 2026-05-14 14:41:11 +03:00
gnezim 7fd8faf202 Enable start-page schedule date availability
ci-deploy / build-deploy-test (push) Waiting to run
2026-05-14 14:07:44 +03:00
gnezim 530115d8d1 Apply schedule datepicker popup styles
ci-deploy / build-deploy-test (push) Successful in 1m43s
2026-05-14 13:27:09 +03:00
gnezim 6aa76f5f4d Make disabled schedule dates visible
ci-deploy / build-deploy-test (push) Successful in 1m43s
2026-05-14 12:29:33 +03:00
gnezim 184e280b45 Revert "Keep CI helper scripts in synced app"
ci-deploy / build-deploy-test (push) Waiting to run
This reverts commit 1b5cf23400.
2026-05-14 12:08:44 +03:00
gnezim 1b5cf23400 Keep CI helper scripts in synced app 2026-05-14 12:07:05 +03:00
gnezim 32538635d6 Harden schedule calendar operating-days e2e 2026-05-14 11:14:16 +03:00
gnezim fb6c778d8b Handle dev TrackerHub poll timeouts in proxy 2026-05-07 00:50:27 +03:00
gnezim eadd42cacc Avoid dev TrackerHub poll gateway timeouts 2026-05-07 00:20:13 +03:00
gnezim 6a3c8f2558 Fix dev TrackerHub transport 2026-05-06 23:55:18 +03:00
gnezim f0244d20b8 Proxy dev SignalR hub locally 2026-05-06 23:14:40 +03:00
gnezim bc820ae72a Set dev SignalR hub URL 2026-05-06 22:52:14 +03:00
gnezim 53b48a62dd Fix online board live refresh parity 2026-05-06 22:49:15 +03:00
gnezim 1d32c5d0c6 Align flight number validation translations 2026-05-06 22:29:21 +03:00
gnezim ceab49f34f Support suffixed online-board flight numbers 2026-05-06 21:21:09 +03:00
gnezim 3411d71b00 Fix clipped flights map route arcs 2026-05-06 20:54:52 +03:00
gnezim 65e776273d Fix map calendar relative date labels 2026-05-06 14:38:17 +03:00
gnezim 385a6e55ee Fix flights map calendar lower bound 2026-05-06 14:10:31 +03:00
gnezim eda44d4218 Align flights map date window with Angular 2026-05-06 12:53:21 +03:00
gnezim 19ae50af80 Fix online board details date selection 2026-05-06 00:10:59 +03:00
gnezim cb48dcc706 Prefill schedule popular route requests 2026-05-05 23:43:32 +03:00
gnezim 421a960a82 Execute schedule popular route searches 2026-05-05 23:10:20 +03:00
gnezim 0960b739dd Keep online board time range in sync 2026-05-05 22:36:59 +03:00
gnezim f08ed8b206 Fix Aeroflot buy ticket URLs 2026-05-05 22:01:46 +03:00
gnezim ef8bda8683 Fix schedule buy links in grouped results 2026-05-05 21:48:14 +03:00
gnezim 5589fd189c Fix online board calendar day parity 2026-05-05 20:03:57 +03:00
gnezim 4afecd23a6 Allow changed time range resubmission
ci-deploy / build-deploy-test (push) Successful in 1m51s
2026-05-05 19:04:03 +03:00
gnezim 04a71192fa Fix online board time range guard 2026-05-05 17:11:15 +03:00
gnezim dfea0aec73 Clarify schedule calendar bitmask anchor 2026-05-05 16:33:57 +03:00
gnezim a02befb78d Allow Angular app to compile 2026-05-05 16:16:01 +03:00
158 changed files with 5737 additions and 948 deletions
@@ -150,7 +150,6 @@ export class FlightsMapBodyComponent implements OnInit, AfterViewInit {
{
icon: markerBlueSmall,
title: city.code,
autoPanOnFocus: false,
}
)
.on('click', ()=> this.handleMarkerClick(city.code))
+6 -1
View File
@@ -35,6 +35,10 @@ ARG MAP_TILE_URL=https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg
ENV MAP_TILE_URL=${MAP_TILE_URL}
ARG API_BASE_URL=https://flights.test.aeroflot.ru/api
ENV API_BASE_URL=${API_BASE_URL}
ARG AEROFLOT_SHELL_LOADER_PROXY=1
ENV AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY}
ARG AEROFLOT_SHELL_REFERRER_ORIGIN=https://flights.test.aeroflot.ru
ENV AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN}
RUN pnpm build:standalone
@@ -51,7 +55,8 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist/standalone/ ./dist/standalone/
COPY --from=build /app/src/ ./src/
COPY package.json modern.config.ts module-federation.config.ts ./
COPY scripts/standalone-server.mjs scripts/aeroflot-url-rewrite.mjs ./scripts/
EXPOSE 8080
CMD ["pnpm", "exec", "modern", "serve"]
CMD ["node", "scripts/standalone-server.mjs"]
+5 -1
View File
@@ -37,6 +37,8 @@ PNPM := pnpm
PID_FILE := .dev.pid
LOG_FILE := .dev.log
API_TARGET ?= https://flights.test.aeroflot.ru
TRACKER_TARGET ?= https://platform.test.aeroflot.ru
SIGNALR_HUB_URL ?= http://localhost:8080/tracker/hub
# Development
dev:
@@ -47,10 +49,12 @@ dev:
dev-full:
@echo "Starting dev server with API proxy in background..."
@API_TARGET="$(API_TARGET)" nohup $(PNPM) dev:full > $(LOG_FILE) 2>&1 & echo $$! > $(PID_FILE)
@API_TARGET="$(API_TARGET)" TRACKER_TARGET="$(TRACKER_TARGET)" SIGNALR_HUB_URL="$(SIGNALR_HUB_URL)" nohup node scripts/dev-server.mjs > $(LOG_FILE) 2>&1 & echo $$! > $(PID_FILE)
@echo "Dev server started (PID: $$(cat $(PID_FILE)))"
@echo " App & API: http://localhost:8080"
@echo " API target: $(API_TARGET)"
@echo " Tracker target: $(TRACKER_TARGET)"
@echo " SignalR hub: $(SIGNALR_HUB_URL)"
@echo "View logs: make logs"
stop:
+7 -1
View File
@@ -28,6 +28,12 @@ function build {
if [ -n "${API_BASE_URL:-}" ]; then
docker_args+=(--build-arg "API_BASE_URL=${API_BASE_URL}")
fi
if [ -n "${AEROFLOT_SHELL_LOADER_PROXY:-}" ]; then
docker_args+=(--build-arg "AEROFLOT_SHELL_LOADER_PROXY=${AEROFLOT_SHELL_LOADER_PROXY}")
fi
if [ -n "${AEROFLOT_SHELL_REFERRER_ORIGIN:-}" ]; then
docker_args+=(--build-arg "AEROFLOT_SHELL_REFERRER_ORIGIN=${AEROFLOT_SHELL_REFERRER_ORIGIN}")
fi
docker build -t "aeroflot.$K8NAMESPACE/$1:v$VERSION.$BUILD_NUMBER" \
"${docker_args[@]}" \
-f "$2/Dockerfile" "$2"
@@ -35,4 +41,4 @@ function build {
build flights-ui "../Aeroflot.Flights.Front"
exit 0
exit 0
+4
View File
@@ -43,4 +43,8 @@ spec:
value: "https://flights.test.aeroflot.ru/map/api/tile/{z}/{x}/{y}.jpeg"
- name: API_BASE_URL
value: "https://flights.test.aeroflot.ru/api"
- name: AEROFLOT_SHELL_LOADER_PROXY
value: "1"
- name: AEROFLOT_SHELL_REFERRER_ORIGIN
value: "https://flights.test.aeroflot.ru"
$IMAGE_PULL_SECRETS
+137 -2
View File
@@ -3,6 +3,10 @@ import { moduleFederationPlugin } from "@module-federation/modern-js";
const buildTarget = process.env["BUILD_TARGET"];
const isRemote = buildTarget === "remote";
const plugins = [
appTools({ bundler: "rspack" }),
...(isRemote ? [moduleFederationPlugin()] : []),
];
// Runtime env values that must reach the client bundle. Rspack resolves
// `process.env` at BUILD time to an empty-ish polyfill in the browser, so
@@ -18,7 +22,13 @@ const isRemote = buildTarget === "remote";
// '{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"] 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];
@@ -33,9 +43,130 @@ const publicEnvB64 = Buffer.from(
// Rspack HTML plugin. `Object.create(null)` replaces an empty `{}`.
const PUBLIC_ENV_SCRIPT =
`window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`;
const SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT = `
window.__AFL_SHELL_CONSOLE_WARN__=window.__AFL_SHELL_CONSOLE_WARN__||console.warn.bind(console);
window.__AFL_SHELL_CONSOLE_ERROR__=window.__AFL_SHELL_CONSOLE_ERROR__||console.error.bind(console);
window.__AFL_SHELL_SHOULD_SUPPRESS__=function(args){
return Array.prototype.some.call(args,function(value){
return String(value).indexOf("Cannot find module './afl-frontend-lib/locales/")!==-1;
});
};
console.warn=function(){
if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;}
return window.__AFL_SHELL_CONSOLE_WARN__.apply(console,arguments);
};
console.error=function(){
if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;}
return window.__AFL_SHELL_CONSOLE_ERROR__.apply(console,arguments);
};`;
const AEROFLOT_RUNTIME_CONFIG_SCRIPT = `
window["afl-frontend-runtime-config"]=window["afl-frontend-runtime-config"]||Object.create(null);
window["afl-frontend-runtime-config"].FRONTEND_PROXY=window.location.origin;
window["afl-frontend-runtime-config"].FRONTEND_BACKEND="https://www.aeroflot.ru";`;
const FIX_AEROFLOT_SHELL_LINKS_SCRIPT = `
window.__AFL_SHELL_REWRITE_LINKS__=function(){
var anchors=document.querySelectorAll("afl-component.header a[href],afl-component.footer a[href]");
Array.prototype.forEach.call(anchors,function(anchor){
var href=anchor.getAttribute("href")||"";
if(href.indexOf("/personal/login")===0||href.indexOf("/pkl/app/")===0){
anchor.setAttribute("href","https://www.aeroflot.ru"+href);
}
});
};
window.__AFL_SHELL_LINK_OBSERVER__=new MutationObserver(function(){
window.__AFL_SHELL_REWRITE_LINKS__();
});
window.__AFL_SHELL_LINK_OBSERVER__.observe(document.documentElement,{
childList:true,
subtree:true,
attributes:true,
attributeFilter:["href"]
});
window.addEventListener("DOMContentLoaded",function(){
window.__AFL_SHELL_REWRITE_LINKS__();
});`;
const modernCommand = process.argv.join(" ");
const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? "";
const isDevServer =
/\bdev\b/.test(modernCommand) || npmLifecycleEvent.includes("dev");
const shouldUseShellLoaderProxy =
process.env["AEROFLOT_SHELL_LOADER_PROXY"] === "1";
// Angular uses full `afl-component` loader assets in production
// `index.html`, but its local `index.dev.html` keeps only shell
// placeholders. `dev:full` exposes a same-origin static proxy so local
// Chrome can hydrate the placeholders without CORS failures.
const shouldLoadStandaloneShellLoader =
!isRemote && (!isDevServer || shouldUseShellLoaderProxy);
const standaloneShellLoaderMode = shouldLoadStandaloneShellLoader
? shouldUseShellLoaderProxy
? "proxy"
: "external"
: "placeholder";
const aeroflotStaticBase = shouldUseShellLoaderProxy
? "/frontend/static"
: "https://www.aeroflot.ru/frontend/static";
const standaloneShellTags = isRemote
? []
: [
...(shouldLoadStandaloneShellLoader
? [
...(shouldUseShellLoaderProxy
? [
{
tag: "script",
head: true,
append: true,
children: SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT,
},
{
tag: "script",
head: true,
append: true,
children: AEROFLOT_RUNTIME_CONFIG_SCRIPT,
},
{
tag: "script",
head: true,
append: true,
children: FIX_AEROFLOT_SHELL_LINKS_SCRIPT,
},
]
: []),
{
tag: "link",
head: true,
append: true,
attrs: {
rel: "stylesheet",
href: `${aeroflotStaticBase}/css/afl-frontend-loader.bundle.css`,
},
},
{
tag: "script",
head: false,
append: true,
attrs: {
async: true,
src: `${aeroflotStaticBase}/js/afl-frontend-loader.bundle.js`,
},
},
]
: []),
{
tag: "meta",
head: true,
append: true,
attrs: {
name: "aeroflot-shell-loader",
content: standaloneShellLoaderMode,
},
},
];
export default defineConfig({
plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()],
plugins,
source: {
entriesDir: "./src",
},
@@ -50,6 +181,7 @@ export default defineConfig({
append: false,
children: PUBLIC_ENV_SCRIPT,
},
...standaloneShellTags,
{
tag: "link",
attrs: {
@@ -95,6 +227,9 @@ export default defineConfig({
},
},
tools: {
rspack(config) {
config.cache = false;
},
cssLoader: {
url: false,
},
+7 -22
View File
@@ -7,35 +7,20 @@ const startLocalServer = !process.env.BASE_URL;
// /api/* by source IP; nginx proxy_cache absorbs most repeat fetches but a
// burst can still trip 1-2 of them).
const isCI = !!process.env.CI;
const workers = Number(process.env.E2E_WORKERS || "1");
// Quarantine — tests that fail consistently against the deployed prod build
// for reasons unrelated to deploy plumbing (Angular↔React parity gaps,
// missing section breadcrumbs, day-tab/time-filter UI behavior diffs,
// schedule date-picker week-snap, multi-segment connecting itinerary).
//
// Triaged in docs/superpowers/specs/2026-04-27-ssr-hydration-fix.md ("Out
// of scope" section). When CI_DEPLOY=1 (set by .gitea/workflows/ci-deploy
// only), Playwright skips this list so the deploy gate stays green; the
// release-verify workflow runs the full suite for slower-cadence triage.
const QUARANTINED_PATTERNS = [
"Breadcrumb parity with Angular.*Onlineboard (route page|details page)",
"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",
"connecting itinerary navigates to a multi-segment URL",
];
// Deploy-only quarantine. Keep this list empty unless a documented external
// blocker makes a specific e2e test unsuitable for CI_DEPLOY.
const QUARANTINED_PATTERNS: string[] = [];
const grepInvert = process.env.CI_DEPLOY
&& QUARANTINED_PATTERNS.length > 0
? new RegExp(QUARANTINED_PATTERNS.join("|"))
: undefined;
export default defineConfig({
testDir: "tests/e2e",
timeout: 30000,
workers: isCI ? 1 : undefined,
workers,
retries: isCI ? 2 : 0,
...(grepInvert ? { grepInvert } : {}),
use: {
@@ -53,7 +38,7 @@ export default defineConfig({
...(startLocalServer
? {
webServer: {
command: "pnpm dev",
command: "pnpm dev:full",
url: "http://localhost:8080",
reuseExistingServer: true,
timeout: 30000,
+8
View File
@@ -0,0 +1,8 @@
const SAME_ORIGIN_AEROFLOT_ENDPOINTS =
/https:\/\/(?:www\.)?aeroflot\.ru(?=\/(?:ws2|cms2|personal|offers|feedback|pkl|frontend\/static|media)(?:[/?#]|$))/g;
export function rewriteAeroflotShellUrls(value) {
return value
.replaceAll("https://gw.aeroflot.ru", "/gw")
.replace(SAME_ORIGIN_AEROFLOT_ENDPOINTS, "");
}
+285 -9
View File
@@ -6,19 +6,27 @@
* /api/* → curl → https://flights.test.aeroflot.ru (bypasses WAF via curl TLS)
* /flights/* → curl → https://flights.test.aeroflot.ru
* /map/* → curl → https://flights.test.aeroflot.ru (binary JPEG tiles)
* /tracker/* → https://platform.test.aeroflot.ru (SignalR, same-origin)
* /* → localhost:8081 (Modern.js SSR + HMR)
*/
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware";
import { HttpsProxyAgent } from "https-proxy-agent";
import { execFile, spawn } from "node:child_process";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
const PUBLIC_PORT = 8080;
const MODERNJS_PORT = 8081;
const API_TARGET = process.env.API_TARGET || "https://flights.test.aeroflot.ru";
const TRACKER_TARGET = process.env.TRACKER_TARGET || "https://platform.test.aeroflot.ru";
const AEROFLOT_STATIC_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru";
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1";
const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`;
const shellStaticAssetCache = new Map();
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
// 307-to-self) only runs once per dev-server lifetime, not per request.
@@ -35,12 +43,20 @@ console.log(`Starting Modern.js on :${MODERNJS_PORT}...`);
const modernBin = resolve("node_modules", ".bin", "modern");
const modernProcess = existsSync(modernBin)
? spawn(modernBin, ["dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
PORT: String(MODERNJS_PORT),
AEROFLOT_SHELL_LOADER_PROXY: "1",
},
})
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
PORT: String(MODERNJS_PORT),
AEROFLOT_SHELL_LOADER_PROXY: "1",
},
});
modernProcess.on("error", (err) => {
console.error("Modern.js failed:", err);
@@ -138,16 +154,238 @@ app.use(["/api", "/flights"], (req, res) => {
}
});
function execCurlWithFallback(buildArgs, extraArgs, res) {
function normalizeTrackerCookie(cookie) {
if (!cookie.startsWith("signal-id=")) return cookie;
const parts = cookie
.split(";")
.map((part) => part.trim())
.filter(
(part) =>
part.length > 0 &&
!part.toLowerCase().startsWith("domain=") &&
!part.toLowerCase().startsWith("path=") &&
!part.toLowerCase().startsWith("samesite="),
);
return [...parts, "Path=/tracker/hub", "SameSite=Lax"].join("; ");
}
function applyTrackerCookieHeaders(proxyRes, res) {
const setCookie = proxyRes.headers["set-cookie"];
if (Array.isArray(setCookie)) {
res.setHeader("set-cookie", setCookie.map(normalizeTrackerCookie));
} else if (typeof setCookie === "string") {
res.setHeader("set-cookie", normalizeTrackerCookie(setCookie));
}
}
function isTrackerLongPollTimeout(proxyRes, req) {
const requestUrl = req.originalUrl ?? req.url ?? "";
return (
req.method === "GET" &&
(requestUrl.startsWith("/tracker/hub?id=") ||
requestUrl.startsWith("/hub?id=")) &&
proxyRes.statusCode === 504
);
}
// --- SignalR TrackerHub proxy ---
// Browser-direct localhost → platform.test.aeroflot.ru fails CORS. Keep the
// hub same-origin in development and let proxy-helper / gost route the
// upstream request through the TIM tunnel when HTTPS_PROXY is set.
const trackerProxy = createProxyMiddleware({
target: TRACKER_TARGET,
changeOrigin: true,
secure: false,
ws: true,
logLevel: "warn",
pathRewrite: (path) => `/tracker${path}`,
selfHandleResponse: true,
on: {
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
applyTrackerCookieHeaders(proxyRes, res);
if (isTrackerLongPollTimeout(proxyRes, req)) {
console.warn(`Tracker long poll timed out upstream, returning empty 200 for ${req.originalUrl ?? req.url}`);
res.statusCode = 200;
res.statusMessage = "OK";
res.setHeader("content-type", "text/plain");
return "";
}
return buffer;
}),
},
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
});
app.use("/tracker", trackerProxy);
// --- Aeroflot frontend loader static proxy ---
// The loader derives follow-up asset URLs from its own script origin. Serving
// it through localhost keeps Header/Footer hydration visible in dev Chrome
// without browser CORS errors from www.aeroflot.ru.
app.use(
"/frontend/static",
createProxyMiddleware({
target: AEROFLOT_STATIC_TARGET,
changeOrigin: true,
secure: false,
logLevel: "warn",
pathRewrite: (path) => `/frontend/static${path}`,
selfHandleResponse: true,
on: {
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
const cacheKey = req.originalUrl ?? req.url ?? "";
const contentType = inferShellStaticContentType(
cacheKey,
proxyRes.headers["content-type"],
);
if (!/(css|html|javascript|json|text)/i.test(String(contentType))) {
if (proxyRes.statusCode && proxyRes.statusCode < 500) {
shellStaticAssetCache.set(cacheKey, { body: buffer, contentType });
}
return buffer;
}
if (proxyRes.statusCode && proxyRes.statusCode >= 500) {
const cached = shellStaticAssetCache.get(cacheKey);
if (cached) {
res.statusCode = 200;
res.statusMessage = "OK";
res.setHeader("content-type", cached.contentType);
return cached.body;
}
const retried = await retryAeroflotStaticAsset(cacheKey);
if (retried) {
const body = rewriteAeroflotShellUrls(retried.body);
shellStaticAssetCache.set(cacheKey, { body, contentType });
res.statusCode = 200;
res.statusMessage = "OK";
res.setHeader("content-type", contentType);
return body;
}
}
const body = rewriteAeroflotShellUrls(buffer.toString("utf8"));
if (!proxyRes.statusCode || proxyRes.statusCode < 500) {
shellStaticAssetCache.set(cacheKey, { body, contentType });
}
return body;
}),
},
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
}),
);
function inferShellStaticContentType(requestPath, upstreamContentType) {
const contentType = String(upstreamContentType ?? "");
if (/(css|html|javascript|json|text)/i.test(contentType)) return contentType;
const pathname = requestPath.split("?")[0] ?? "";
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "application/javascript";
if (pathname.endsWith(".css")) return "text/css";
if (pathname.endsWith(".json")) return "application/json";
if (pathname.endsWith(".html")) return "text/html";
return contentType;
}
function retryAeroflotStaticAsset(originalUrl) {
const targetUrl = `${AEROFLOT_STATIC_TARGET}${originalUrl}`;
const buildArgs = (noproxy) => [
"-sS",
"-f",
"-L",
...(noproxy ? ["--noproxy", "*"] : []),
"-H", "Accept: */*",
"-H", "User-Agent: Mozilla/5.0",
targetUrl,
];
return new Promise((resolveRetry) => {
runCurlWithoutStatus(buildArgs(true), (direct) => {
if (!direct.err && direct.stdout) {
resolveRetry({ body: direct.stdout });
return;
}
runCurlWithoutStatus(buildArgs(false), (proxy) => {
resolveRetry(!proxy.err && proxy.stdout ? { body: proxy.stdout } : null);
});
});
});
}
app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
res.status(200).json({ data: null });
});
app.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => {
res.status(200).json({ data: null });
});
app.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => {
res.status(200).json({ data: { currency: "RUB" }, errors: [], isSuccess: true });
});
app.use("/pkl/ws/json/v1/member/get", (_req, res) => {
res.status(200).json({ data: null, errors: [], isSuccess: true });
});
app.use(
["/ws2", "/cms2", "/personal", "/offers", "/feedback"],
express.raw({ type: "*/*" }),
(req, res) => {
const targetUrl = `${AEROFLOT_STATIC_TARGET}${req.originalUrl}`;
const requestBody = req.body?.length
? req.body.toString("utf8").replaceAll(LOCAL_PUBLIC_ORIGIN, API_TARGET)
: "";
const buildArgs = (noproxy) => [
"-s",
"-L",
...(noproxy ? ["--noproxy", "*"] : []),
"-H", `Accept: ${req.headers.accept || "application/json"}`,
"-H", `User-Agent: ${req.headers["user-agent"] || "Mozilla/5.0"}`,
"-H", `Accept-Language: ${req.headers["accept-language"] || "ru"}`,
"-w", "\n%{http_code}",
targetUrl,
];
const bodyArgs =
req.method === "GET" || req.method === "HEAD"
? []
: [
"-X", req.method,
"-H", `Content-Type: ${req.headers["content-type"] || "application/json"}`,
...(requestBody ? ["--data-raw", requestBody] : []),
];
execCurlWithFallback(buildArgs, bodyArgs, res, respondWithAeroflotFrontendResult);
},
);
app.use(
"/media",
createProxyMiddleware({
target: AEROFLOT_STATIC_TARGET,
changeOrigin: true,
secure: false,
logLevel: "warn",
pathRewrite: (path) => `/media${path}`,
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
}),
);
function execCurlWithFallback(buildArgs, extraArgs, res, responder = respondWithCurlResult) {
runCurl([...extraArgs, ...buildArgs(true)], (direct) => {
if (isSuccessfulUpstream(direct)) {
respondWithCurlResult(direct, res);
responder(direct, res);
return;
}
// Direct hit a WAF deny / throttle / network failure — retry through
// the system HTTPS_PROXY (gost VPN tunnel on this host).
runCurl([...extraArgs, ...buildArgs(false)], (proxy) => {
respondWithCurlResult(isSuccessfulUpstream(proxy) ? proxy : direct, res);
responder(isSuccessfulUpstream(proxy) ? proxy : direct, res);
});
});
}
@@ -164,6 +402,18 @@ function runCurl(args, cb) {
});
}
function runCurlWithoutStatus(args, cb) {
execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => {
if (err) {
console.warn(`static curl retry failed: ${err.message}`);
}
if (stderr) {
console.warn(`static curl retry stderr: ${stderr.substring(0, 300)}`);
}
cb({ err, stdout: stdout ?? "" });
});
}
function isSuccessfulUpstream({ err, stdout }) {
if (err) return false;
const lastNewline = stdout.lastIndexOf("\n");
@@ -214,6 +464,25 @@ function respondWithCurlResult({ err, stdout }, res) {
res.send(body);
}
function respondWithAeroflotFrontendResult(result, res) {
if (result.err) {
res.status(502).json({ error: result.err.message });
return;
}
const lastNewline = result.stdout.lastIndexOf("\n");
const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout;
const body = rewriteAeroflotShellUrls(rawBody);
const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200";
const status = parseInt(statusStr) || 200;
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
res.status(status);
res.set("Content-Type", isJson ? "application/json" : "text/html");
res.set("Access-Control-Allow-Origin", "*");
res.send(body);
}
function summarizeBody(body) {
const trimmed = body.trimStart();
if (!trimmed) return "body=empty";
@@ -252,12 +521,19 @@ app.use(modernProxy);
const server = app.listen(PUBLIC_PORT, () => {
console.log(`\n ✓ Dev server: http://localhost:${PUBLIC_PORT}`);
console.log(` /api/* → curl → ${API_TARGET}`);
console.log(` /tracker/* → proxy → ${TRACKER_TARGET}`);
console.log(` /* → Modern.js :${MODERNJS_PORT}\n`);
});
// Forward WebSocket upgrades to Modern.js HMR server explicitly,
// preventing reconnection spam from http-proxy-middleware's built-in ws handling.
server.on("upgrade", modernProxy.upgrade);
server.on("upgrade", (req, socket, head) => {
if (req.url?.startsWith("/tracker")) {
trackerProxy.upgrade(req, socket, head);
return;
}
modernProxy.upgrade(req, socket, head);
});
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });
+276
View File
@@ -0,0 +1,276 @@
/**
* Production-facing standalone server.
*
* The Aeroflot shell loader must be loaded from the same origin, and its CMS
* requests must carry a referrer URL accepted by Aeroflot upstream services.
* Stock ingress/nginx cannot safely rewrite JSON request bodies, so the
* container exposes this small proxy in front of `modern serve`.
*/
import express from "express";
import { createProxyMiddleware, responseInterceptor } from "http-proxy-middleware";
import { HttpsProxyAgent } from "https-proxy-agent";
import { existsSync } from "node:fs";
import http from "node:http";
import https from "node:https";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
const PUBLIC_PORT = Number(process.env.PORT || 8080);
const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081);
const AEROFLOT_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://www.aeroflot.ru";
const AEROFLOT_GW_TARGET = process.env.AEROFLOT_GW_TARGET || "https://gw.aeroflot.ru";
const REFERRER_ORIGIN =
process.env.AEROFLOT_SHELL_REFERRER_ORIGIN || "https://flights.test.aeroflot.ru";
const ENABLE_SHELL_PROXY = process.env.AEROFLOT_SHELL_LOADER_PROXY === "1";
const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const modernBin = resolve("node_modules", ".bin", "modern");
const modernArgs = existsSync(modernBin)
? [modernBin, "serve"]
: [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "serve"];
const modernProcess = spawn(modernArgs[0], modernArgs.slice(1), {
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(MODERNJS_PORT),
},
stdio: "inherit",
});
modernProcess.on("error", (err) => {
console.error(`Failed to start Modern.js: ${err.message}`);
process.exit(1);
});
const app = express();
const shellResponseCache = new Map();
function rewriteAeroflotUrls(value) {
return rewriteAeroflotShellUrls(value);
}
function requestOrigin(req) {
const proto = (req.get("x-forwarded-proto") || req.protocol || "http")
.split(",")[0]
.trim();
return `${proto}://${req.get("host")}`;
}
function rewriteRequestBody(req) {
if (!req.body?.length) return undefined;
return req.body
.toString("utf8")
.replaceAll(requestOrigin(req), REFERRER_ORIGIN.replace(/\/+$/, ""));
}
function sendJson(res, body) {
res.status(200).type("application/json").send(JSON.stringify(body));
}
function stubShellEndpoints(appInstance) {
appInstance.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
sendJson(res, { data: null });
});
appInstance.use("/gw/api/pr/LKAB/Profile/v3/get", (_req, res) => {
sendJson(res, { data: null });
});
appInstance.use(["/ws2/v.0.0.1/json/currency/RU", "/ws2/v.0.0.1/json/calcurr/"], (_req, res) => {
sendJson(res, { data: { currency: "RUB" }, errors: [], isSuccess: true });
});
appInstance.use("/pkl/ws/json/v1/member/get", (_req, res) => {
sendJson(res, { data: null, errors: [], isSuccess: true });
});
}
function proxyStaticShellAssets(appInstance) {
appInstance.use(
"/frontend/static",
createProxyMiddleware({
target: AEROFLOT_TARGET,
changeOrigin: true,
secure: true,
logLevel: "warn",
pathRewrite: (path) =>
path.startsWith("/frontend/static") ? path : `/frontend/static${path}`,
selfHandleResponse: true,
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
on: {
proxyRes: responseInterceptor(async (buffer, proxyRes) => {
const contentType = proxyRes.headers["content-type"] ?? "";
if (!/(css|html|javascript|json|text)/i.test(String(contentType))) {
return buffer;
}
return rewriteAeroflotUrls(buffer.toString("utf8"));
}),
},
}),
);
appInstance.use(
"/media",
createProxyMiddleware({
target: AEROFLOT_TARGET,
changeOrigin: true,
secure: true,
logLevel: "warn",
pathRewrite: (path) => (path.startsWith("/media") ? path : `/media${path}`),
...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}),
}),
);
}
function fallbackForShellResponse(res, cacheKey, upstreamError) {
const cached = shellResponseCache.get(cacheKey);
if (cached) {
res.status(200).type(cached.contentType).send(cached.body);
return;
}
if (upstreamError) {
console.warn(`Aeroflot shell proxy fallback: ${upstreamError}`);
}
sendJson(res, { data: null, errors: [], isSuccess: true });
}
async function proxyAeroflotService(req, res, targetOrigin, targetPath = req.originalUrl) {
const body = rewriteRequestBody(req);
const cacheKey = `${req.method} ${targetOrigin}${targetPath} ${body ?? ""}`;
const headers = {
accept: req.get("accept") || "application/json",
"accept-language": req.get("accept-language") || "ru",
"user-agent": req.get("user-agent") || USER_AGENT,
};
if (body) {
headers["content-type"] = req.get("content-type") || "application/json";
}
try {
const upstream = await requestText(`${targetOrigin}${targetPath}`, {
method: req.method,
headers,
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
});
const rawBody = upstream.body;
const contentType = String(upstream.headers["content-type"] || "application/json");
const responseBody = /(json|html|text|javascript|css)/i.test(contentType)
? rewriteAeroflotUrls(rawBody)
: rawBody;
if (upstream.status < 200 || upstream.status >= 400) {
fallbackForShellResponse(res, cacheKey, `${upstream.status} ${targetOrigin}${targetPath}`);
return;
}
shellResponseCache.set(cacheKey, { body: responseBody, contentType });
res.status(upstream.status).type(contentType).send(responseBody);
} catch (err) {
fallbackForShellResponse(
res,
cacheKey,
err instanceof Error ? err.message : String(err),
);
}
}
function requestText(url, options, redirectCount = 0) {
return new Promise((resolveRequest, rejectRequest) => {
const parsed = new URL(url);
const transport = parsed.protocol === "http:" ? http : https;
const request = transport.request(
parsed,
{
method: options.method,
headers: options.headers,
agent:
SYSTEM_PROXY && parsed.protocol === "https:"
? new HttpsProxyAgent(SYSTEM_PROXY)
: undefined,
},
(response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => {
const status = response.statusCode || 0;
const location = response.headers.location;
if (
status >= 300 &&
status < 400 &&
location &&
redirectCount < 5
) {
const redirectUrl = new URL(location, parsed).toString();
requestText(redirectUrl, options, redirectCount + 1)
.then(resolveRequest)
.catch(rejectRequest);
return;
}
resolveRequest({
status,
headers: response.headers,
body: Buffer.concat(chunks).toString("utf8"),
});
});
},
);
request.on("error", rejectRequest);
if (options.body) {
request.write(options.body);
}
request.end();
});
}
if (ENABLE_SHELL_PROXY) {
stubShellEndpoints(app);
proxyStaticShellAssets(app);
app.use(
["/ws2", "/cms2", "/personal", "/offers", "/feedback", "/pkl"],
express.raw({ type: "*/*" }),
(req, res) => void proxyAeroflotService(req, res, AEROFLOT_TARGET),
);
app.use(
"/gw",
express.raw({ type: "*/*" }),
(req, res) =>
void proxyAeroflotService(
req,
res,
AEROFLOT_GW_TARGET,
req.originalUrl.replace(/^\/gw/, "") || "/",
),
);
}
const modernProxy = createProxyMiddleware({
target: `http://127.0.0.1:${MODERNJS_PORT}`,
changeOrigin: false,
ws: true,
logLevel: "silent",
});
app.use(modernProxy);
const server = app.listen(PUBLIC_PORT, () => {
console.log(`Standalone server listening on :${PUBLIC_PORT}`);
console.log(`Modern.js upstream listening on :${MODERNJS_PORT}`);
console.log(`Aeroflot shell proxy: ${ENABLE_SHELL_PROXY ? "enabled" : "disabled"}`);
});
server.on("upgrade", (req, socket, head) => {
modernProxy.upgrade(req, socket, head);
});
function shutdown() {
modernProcess.kill();
server.close(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+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,
+13 -11
View File
@@ -11,11 +11,13 @@
* routes=MOW..LED → omitted: routes=MOW.LED
*/
const BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
const FIXED_PARAMS =
"adults=1&cabin=economy&children=0&infants=0&autosearch=Y" +
"&utm_source=aflwebbot&utm_medium=referral" +
"&utm_campaign=ref_3015_general_rf_button.index__all_flight.map";
import { buildAeroflotSbSearchUrl } from "@/shared/booking/aeroflot.js";
const MAP_UTM_PARAMS = {
utm_source: "aflwebbot",
utm_medium: "referral",
utm_campaign: "ref_3015_general_rf_button.index__all_flight.map",
} as const;
/**
* Build the SB search URL for a single one-way leg.
@@ -30,12 +32,12 @@ export function buildBuyTicketUrl(
arrival: string,
date: string | undefined,
): string {
// TZ §4.1.24.6 Table 71: route format is {dep}.{date}.{arr} for one-way
// with a date; {dep}.{arr} (no dot-date segment) when no date is known.
const routeTriple = date
? `${departure}.${date}.${arrival}`
: `${departure}.${arrival}`;
return `${BASE}?${FIXED_PARAMS}&routes=${routeTriple}`;
return buildAeroflotSbSearchUrl({
departure,
arrival,
date,
extraParams: MAP_UTM_PARAMS,
});
}
/**
@@ -40,6 +40,25 @@ describe("getMinDate / getMaxDate", () => {
const max = getMaxDate();
expect(max.getTime()).toBe(expected.getTime());
});
it("derives date-only bounds from the provided yyyyMMdd anchor", () => {
const min = getMinDate("20260506");
const max = getMaxDate("20260506");
expect(min.toISOString()).toBe(new Date(2026, 4, 5).toISOString());
expect(max.toISOString()).toBe(new Date(2026, 10, 6).toISOString());
expect(min.getHours()).toBe(0);
expect(max.getHours()).toBe(0);
});
it("falls back to the runtime clock when the provided anchor is invalid", () => {
const min = getMinDate("20269999");
const expected = addDays(new Date(), -1);
expect(min.getFullYear()).toBe(expected.getFullYear());
expect(min.getMonth()).toBe(expected.getMonth());
expect(min.getDate()).toBe(expected.getDate());
});
});
describe("buildDisabledDates", () => {
+34 -3
View File
@@ -12,19 +12,33 @@
import { mapWindowBounds } from "@/shared/dateWindow.js";
/** Today with time set to 00:00:00 local. */
export function today(): Date {
export function today(baseYyyymmdd?: string): Date {
if (baseYyyymmdd) {
const parsed = parseYyyymmdd(baseYyyymmdd);
if (parsed) return parsed;
}
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
/** minDate = today - 1 day (Angular parity). */
export function getMinDate(): Date {
export function getMinDate(baseYyyymmdd?: string): Date {
if (baseYyyymmdd) {
const d = today(baseYyyymmdd);
d.setDate(d.getDate() - 1);
return d;
}
return mapWindowBounds()[0];
}
/** maxDate = today + 6 months (Angular parity). */
export function getMaxDate(): Date {
export function getMaxDate(baseYyyymmdd?: string): Date {
if (baseYyyymmdd) {
const d = today(baseYyyymmdd);
d.setMonth(d.getMonth() + 6);
return d;
}
return mapWindowBounds()[1];
}
@@ -92,3 +106,20 @@ function toYyyymmdd(d: Date): string {
const day = d.getDate().toString().padStart(2, "0");
return `${y}${m}${day}`;
}
function parseYyyymmdd(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 parsed = new Date(y, m - 1, d);
parsed.setHours(0, 0, 0, 0);
if (
parsed.getFullYear() !== y ||
parsed.getMonth() !== m - 1 ||
parsed.getDate() !== d
) {
return null;
}
return parsed;
}
@@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FlightsMapFilter } from "./FlightsMapFilter.js";
import type { IFlightsMapFilterState } from "../types.js";
@@ -40,7 +40,12 @@ vi.mock("@modern-js/runtime/router", () => ({
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string) =>
key === "SHARED.TODAY"
? "Сегодня"
: key === "SHARED.TOMORROW"
? "Завтра"
: key,
i18n: { language: "ru" },
}),
}));
@@ -92,6 +97,25 @@ describe("FlightsMapFilter — Calendar wiring", () => {
expect(max.getTime()).toBe(expectedMax.getTime());
});
it("uses the provided SSR today anchor for date-only calendar bounds", () => {
const onChange = vi.fn();
render(
<FlightsMapFilter
value={filter()}
today="20260506"
onChange={onChange}
/>,
);
const min = lastCalendarProps!["minDate"] as Date;
const max = lastCalendarProps!["maxDate"] as Date;
expect(min.toISOString()).toBe(new Date(2026, 4, 5).toISOString());
expect(max.toISOString()).toBe(new Date(2026, 10, 6).toISOString());
expect(min.getHours()).toBe(0);
expect(min.getMinutes()).toBe(0);
});
it("disables every date when availableDays is empty", () => {
const onChange = vi.fn();
render(
@@ -182,6 +206,83 @@ describe("FlightsMapFilter — Calendar wiring", () => {
});
});
describe("FlightsMapFilter — TIRREDESIGN-22: calendar today/tomorrow labels", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 6, 12, 0, 0));
lastCalendarProps = null;
});
afterEach(() => {
vi.useRealTimers();
});
it("formats today's selected date as a word", () => {
render(
<FlightsMapFilter
value={filter({ departure: "MOW", date: "20260506" })}
onChange={vi.fn()}
/>,
);
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
date: Date,
) => string;
expect(formatDateTime(new Date(2026, 4, 6))).toBe("Сегодня");
});
it("formats tomorrow's selected date as a word", () => {
render(
<FlightsMapFilter
value={filter({ departure: "MOW", date: "20260507" })}
onChange={vi.fn()}
/>,
);
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
date: Date,
) => string;
expect(formatDateTime(new Date(2026, 4, 7))).toBe("Завтра");
});
it("keeps normal dates in dd.MM.yyyy format", () => {
render(
<FlightsMapFilter
value={filter({ departure: "MOW", date: "20260508" })}
onChange={vi.fn()}
/>,
);
const formatDateTime = lastCalendarProps!["formatDateTime"] as (
date: Date,
) => string;
expect(formatDateTime(new Date(2026, 4, 8))).toBe("08.05.2026");
});
it("parses typed today/tomorrow words back to dates", () => {
render(
<FlightsMapFilter
value={filter({ departure: "MOW", date: "20260506" })}
onChange={vi.fn()}
/>,
);
const parseDateTime = lastCalendarProps!["parseDateTime"] as (
text: string,
) => Date;
expect(parseDateTime("Сегодня").toISOString()).toBe(
new Date(2026, 4, 6).toISOString(),
);
expect(parseDateTime("Завтра").toISOString()).toBe(
new Date(2026, 4, 7).toISOString(),
);
});
});
// ---------------------------------------------------------------------------
// TZ §4.1.24.2 R16: Calendar disabled when Город вылета is empty
// ---------------------------------------------------------------------------
@@ -25,6 +25,7 @@ import type { IFlightsMapFilterState } from "../types.js";
export interface FlightsMapFilterProps {
value: IFlightsMapFilterState;
availableDays?: string[];
today?: string;
onChange: (state: IFlightsMapFilterState) => void;
}
@@ -43,6 +44,64 @@ function dateToYyyymmdd(value: Date): string {
return `${y}${m}${d}`;
}
function addDays(base: Date, days: number): Date {
const date = new Date(base);
date.setDate(date.getDate() + days);
date.setHours(0, 0, 0, 0);
return date;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function formatDateInputValue(value: Date, t: (key: string) => string): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (isSameDay(value, today)) return t("SHARED.TODAY");
if (isSameDay(value, addDays(today, 1))) return t("SHARED.TOMORROW");
const d = value.getDate().toString().padStart(2, "0");
const m = (value.getMonth() + 1).toString().padStart(2, "0");
return `${d}.${m}.${value.getFullYear()}`;
}
function parseDateInputValue(value: string, t: (key: string) => string): Date | null {
const text = value.trim();
const today = new Date();
today.setHours(0, 0, 0, 0);
if (text.localeCompare(t("SHARED.TODAY"), undefined, { sensitivity: "accent" }) === 0) {
return today;
}
if (
text.localeCompare(t("SHARED.TOMORROW"), undefined, { sensitivity: "accent" }) === 0
) {
return addDays(today, 1);
}
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(text);
if (!match) return null;
const day = Number(match[1]);
const month = Number(match[2]) - 1;
const year = Number(match[3]);
const date = new Date(year, month, day);
date.setHours(0, 0, 0, 0);
return date.getFullYear() === year &&
date.getMonth() === month &&
date.getDate() === day
? date
: null;
}
/**
* Filter component for the flights map. Controls departure, arrival,
* connections, domestic/international toggles, and date selection.
@@ -50,6 +109,7 @@ function dateToYyyymmdd(value: Date): string {
export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
value,
availableDays,
today: todayYmd,
onChange,
}) => {
const { t } = useTranslation();
@@ -76,8 +136,8 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
);
}, [dictionaries, onChange, value]);
const minDate = useMemo(() => getMinDate(), []);
const maxDate = useMemo(() => getMaxDate(), []);
const minDate = useMemo(() => getMinDate(todayYmd), [todayYmd]);
const maxDate = useMemo(() => getMaxDate(todayYmd), [todayYmd]);
const disabledDates = useMemo(
() => buildDisabledDates(minDate, maxDate, availableDays ?? []),
@@ -151,6 +211,16 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
[value, onChange],
);
const formatDateTime = useCallback(
(date: Date) => formatDateInputValue(date, t),
[t],
);
const parseDateTime = useCallback(
(text: string) => parseDateInputValue(text, t) ?? new Date(Number.NaN),
[t],
);
return (
<div className="flights-map-filter" data-testid="flights-map-filter">
<div className="flights-map-filter-header">
@@ -292,6 +362,8 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
maxDate={maxDate}
disabledDates={disabledDates}
dateFormat="dd.mm.yy"
formatDateTime={formatDateTime}
parseDateTime={parseDateTime}
placeholder={t("SHARED.DATE_FORMAT")}
showIcon
disabled={!value.departure}
@@ -8,7 +8,11 @@ import { FlightsMapStartPage } from "./FlightsMapStartPage.js";
import type * as DictionariesModuleNS from "@/shared/dictionaries/index.js";
import { transformDictionaries } from "@/shared/dictionaries/index.js";
import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js";
import type { FlightsMapSearchParams } from "../types.js";
import type {
FlightsMapCalendarParams,
IFlightsMapFilterState,
FlightsMapSearchParams,
} from "../types.js";
import {
resetCrossSectionStore,
setMapFilter,
@@ -69,10 +73,12 @@ const searchState: {
routes: Array<{ route: string[]; isDirect: boolean }>;
loading: boolean;
error: Error | null;
completedParams: FlightsMapSearchParams | null;
} = {
routes: [],
loading: false,
error: null,
completedParams: null,
};
const searchCalls: Array<FlightsMapSearchParams | null> = [];
vi.mock("../hooks/useFlightsMapSearch.js", () => ({
@@ -82,8 +88,12 @@ vi.mock("../hooks/useFlightsMapSearch.js", () => ({
},
}));
const calendarCalls: Array<FlightsMapCalendarParams | null> = [];
vi.mock("../hooks/useFlightsMapCalendar.js", () => ({
useFlightsMapCalendar: () => ({ availableDays: [] }),
useFlightsMapCalendar: (params: FlightsMapCalendarParams | null) => {
calendarCalls.push(params);
return { availableDays: [] };
},
}));
const dictState: {
@@ -309,6 +319,7 @@ describe("FlightsMapStartPage — polylines from search results (C.3)", () => {
searchState.routes = [];
searchState.loading = false;
searchState.error = null;
searchState.completedParams = null;
});
it("passes an empty polylines array when no routes", () => {
@@ -376,6 +387,7 @@ describe("TZ §4.1.4 Table 7 breadcrumbs — Flight-Map pages (rows 1-3)", () =>
describe("FlightsMapStartPage — C.4 integration", () => {
beforeEach(() => {
lastMapCanvasProps = null;
lastMapFilterProps = null;
searchCalls.length = 0;
dictState.dictionaries = buildDictionaries({
regions: [],
@@ -467,6 +479,28 @@ describe("FlightsMapStartPage — C.4 integration", () => {
expect(popups[1]!.content).toContain("https://www.aeroflot.ru/sb/app/ru-ru");
});
it("does not draw malformed multi-hop direct routes when transfers are off", () => {
searchState.routes = [
{ route: ["SVO", "LED"], isDirect: true },
{ route: ["SVO", "AER", "LED"], isDirect: true },
];
setMapFilter({
departure: "MOW",
arrival: "LED",
date: null,
showInternal: false,
showInternational: false,
showTransfers: false,
});
render(<FlightsMapStartPage />);
const polylines = lastMapCanvasProps!["polylines"] as Array<{ cityIds: string[] }>;
expect(polylines).toEqual([
expect.objectContaining({ cityIds: ["MOW", "LED"] }),
]);
});
it("does not render popups in spider mode (departure only)", () => {
searchState.routes = [{ route: ["MOW", "LED"], isDirect: true }];
render(<FlightsMapStartPage />);
@@ -479,9 +513,9 @@ describe("FlightsMapStartPage — C.4 integration", () => {
expect(popups).toEqual([]);
});
it("auto-fallback: re-issues the search with connections=1 when direct routes come back empty", () => {
it("auto-fallback: re-issues the search with connections=1 when a completed direct search comes back empty", () => {
searchState.routes = [];
render(<FlightsMapStartPage />);
const { rerender } = render(<FlightsMapStartPage />);
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
});
@@ -489,12 +523,78 @@ describe("FlightsMapStartPage — C.4 integration", () => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
});
const direct = [...searchCalls]
.reverse()
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
searchState.completedParams = direct ?? null;
act(() => {
rerender(<FlightsMapStartPage />);
});
const withOne = searchCalls.filter((p) => p?.connections === 1);
expect(withOne.length).toBeGreaterThanOrEqual(1);
});
it("mirror: last search call uses connections=1 after auto-fallback", () => {
it("mirror: last search call uses connections=1 after completed auto-fallback", () => {
searchState.routes = [];
const { rerender } = render(<FlightsMapStartPage />);
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
});
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
});
const direct = [...searchCalls]
.reverse()
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
searchState.completedParams = direct ?? null;
act(() => {
rerender(<FlightsMapStartPage />);
});
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
expect(last?.connections).toBe(1);
});
it("keeps transfer-only toggle off after the user disables auto-fallback", () => {
searchState.routes = [];
const { rerender } = render(<FlightsMapStartPage />);
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
});
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
});
const direct = [...searchCalls]
.reverse()
.find((p) => p?.departure === "MOW" && p?.arrival === "LED" && p?.connections === 0);
searchState.completedParams = direct ?? null;
act(() => {
rerender(<FlightsMapStartPage />);
});
const autoFallbackValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
expect(autoFallbackValue.connections).toBe(true);
act(() => {
(lastMapFilterProps!["onChange"] as (state: IFlightsMapFilterState) => void)({
...autoFallbackValue,
connections: false,
});
});
const userValue = lastMapFilterProps!["value"] as IFlightsMapFilterState;
expect(userValue.connections).toBe(false);
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
expect(last?.connections).toBe(0);
});
it("does not auto-fallback before the current direct search has completed", () => {
searchState.routes = [];
searchState.completedParams = null;
render(<FlightsMapStartPage />);
act(() => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("MOW");
@@ -503,8 +603,9 @@ describe("FlightsMapStartPage — C.4 integration", () => {
(lastMapCanvasProps!["onMarkerClick"] as (id: string) => void)("LED");
});
const last = [...searchCalls].reverse().find((p) => p?.departure && p?.arrival);
expect(last?.connections).toBe(1);
const value = lastMapFilterProps!["value"] as IFlightsMapFilterState;
expect(value.connections).toBe(false);
expect(searchCalls.some((p) => p?.connections === 1)).toBe(false);
});
});
@@ -984,6 +1085,7 @@ describe("§4.1.24.5 R44: routes API uses [-1d, +6mo] date window", () => {
beforeEach(() => {
lastMapFilterProps = null;
searchCalls.length = 0;
calendarCalls.length = 0;
dictState.dictionaries = buildDictionaries();
dictState.loading = false;
dictState.error = null;
@@ -1000,21 +1102,36 @@ describe("§4.1.24.5 R44: routes API uses [-1d, +6mo] date window", () => {
expect(callsWithDeparture).toHaveLength(0);
});
it("4.1.24-R44: dateFrom/dateTo span today to +6 months when departure is set", () => {
it("4.1.24-R44: dateFrom starts yesterday and dateTo spans to +6 months when departure is set", () => {
resetCrossSectionStore();
setMapFilter({
departure: "MOW", arrival: null, date: null,
showInternal: false, showInternational: false, showTransfers: false,
});
render(<FlightsMapStartPage />);
render(<FlightsMapStartPage today="20260506" />);
const callsWithDeparture = searchCalls.filter((p) => p?.departure === "MOW");
if (callsWithDeparture.length > 0) {
const call = callsWithDeparture[0]!;
const today = new Date();
const expectedYear = today.getFullYear().toString();
expect(call.dateFrom.slice(0, 4)).toBe(expectedYear);
expect(call.dateTo.slice(0, 4)).toMatch(/^\d{4}$/);
}
expect(callsWithDeparture).not.toHaveLength(0);
expect(callsWithDeparture[0]).toMatchObject({
departure: "MOW",
dateFrom: "20260505",
dateTo: "20261105",
});
});
it("4.1.24-R44: calendar availability request starts from the same yesterday anchor", () => {
resetCrossSectionStore();
setMapFilter({
departure: "MOW", arrival: null, date: null,
showInternal: false, showInternational: false, showTransfers: false,
});
render(<FlightsMapStartPage today="20260506" />);
const callsWithDeparture = calendarCalls.filter((p) => p?.departure === "MOW");
expect(callsWithDeparture).not.toHaveLength(0);
expect(callsWithDeparture[0]).toMatchObject({
departure: "MOW",
date: "20260505",
});
});
});
@@ -63,6 +63,18 @@ function addMonthsYyyymmdd(base: string, months: number): string {
return `${ry}${rm}${rd}`;
}
function addDaysYyyymmdd(base: string, days: number): string {
const y = Number(base.slice(0, 4));
const m = Number(base.slice(4, 6)) - 1;
const d = Number(base.slice(6, 8));
const date = new Date(y, m, d);
date.setDate(date.getDate() + days);
const ry = date.getFullYear().toString();
const rm = (date.getMonth() + 1).toString().padStart(2, "0");
const rd = date.getDate().toString().padStart(2, "0");
return `${ry}${rm}${rd}`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -94,6 +106,10 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
// there but always called to keep hook order stable.
const fallbackTodayYmd = useRef(todayYyyymmdd()).current;
const todayYmd = today ?? fallbackTodayYmd;
const searchDateFromYmd = useMemo(
() => addDaysYyyymmdd(todayYmd, -1),
[todayYmd],
);
const { t } = useTranslation();
const { language } = useLocale();
@@ -145,6 +161,8 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>(
filterState.connections ? 1 : 0,
);
const [connectionsFallbackSuppressed, setConnectionsFallbackSuppressed] =
useState(false);
const persistFilterState = useCallback((newState: IFlightsMapFilterState) => {
setFilterState(newState);
@@ -171,31 +189,54 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
return {
departure: filterState.departure,
arrival: filterState.arrival,
dateFrom: todayYmd,
dateTo: addMonthsYyyymmdd(todayYmd, 6),
// Angular starts the flights-map destinations window at minDateCalendar
// (today - 1 day). Keeping this aligned preserves Moscow domestic
// airport-internal directions such as DME-SVO and ZIA-SVO.
dateFrom: searchDateFromYmd,
dateTo: addMonthsYyyymmdd(searchDateFromYmd, 6),
connections: filterState.arrival ? effectiveConnections : 0,
};
}, [filterState.departure, filterState.arrival, effectiveConnections, todayYmd]);
}, [
filterState.departure,
filterState.arrival,
effectiveConnections,
searchDateFromYmd,
]);
// Build calendar params
const calendarParams = useMemo<FlightsMapCalendarParams | null>(() => {
if (!filterState.departure) return null;
return {
date: todayYmd,
date: searchDateFromYmd,
departure: filterState.departure,
arrival: filterState.arrival,
connections: filterState.arrival ? filterState.connections : false,
};
}, [filterState.departure, filterState.arrival, filterState.connections, todayYmd]);
}, [
filterState.departure,
filterState.arrival,
filterState.connections,
searchDateFromYmd,
]);
const { routes, loading, error } = useFlightsMapSearch(searchParams);
const { routes, loading, error, completedParams } =
useFlightsMapSearch(searchParams);
const { availableDays } = useFlightsMapCalendar(calendarParams);
// Auto-fallback: empty result, route mode, connections=0 → retry with 1.
useEffect(() => {
if (loading || error) return;
if (!searchParams || searchParams.connections !== 0) return;
const directSearchCompleted =
completedParams?.departure === searchParams.departure &&
completedParams?.arrival === searchParams.arrival &&
completedParams?.dateFrom === searchParams.dateFrom &&
completedParams?.dateTo === searchParams.dateTo &&
completedParams?.connections === 0;
if (!directSearchCompleted) return;
if (effectiveConnections !== 0) return;
if (connectionsFallbackSuppressed) return;
if (!filterState.departure || !filterState.arrival) return;
if (routes.length > 0) return;
setEffectiveConnections(1);
@@ -203,21 +244,53 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
loading,
error,
effectiveConnections,
connectionsFallbackSuppressed,
completedParams,
filterState.departure,
filterState.arrival,
routes,
searchParams,
]);
// Reflect fallback in the UI toggle once.
useEffect(() => {
if (filterState.arrival && effectiveConnections === 1 && !filterState.connections) {
if (
filterState.arrival &&
effectiveConnections === 1 &&
!filterState.connections &&
!connectionsFallbackSuppressed
) {
setFilterState((prev) => ({ ...prev, connections: true }));
}
}, [effectiveConnections, filterState.arrival, filterState.connections]);
}, [
effectiveConnections,
filterState.arrival,
filterState.connections,
connectionsFallbackSuppressed,
]);
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
const sameRoute =
newState.departure === filterState.departure &&
newState.arrival === filterState.arrival;
const turnedConnectionsOff =
sameRoute && filterState.connections && !newState.connections;
setConnectionsFallbackSuppressed(
turnedConnectionsOff
? true
: sameRoute && !newState.connections
? connectionsFallbackSuppressed
: false,
);
persistFilterState(newState);
}, [persistFilterState]);
}, [
connectionsFallbackSuppressed,
filterState.arrival,
filterState.connections,
filterState.departure,
persistFilterState,
]);
const handleMarkerClick = useCallback(
(markerId: string) => {
@@ -364,6 +437,7 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
<FlightsMapFilter
value={filterState}
availableDays={availableDays}
today={todayYmd}
onChange={handleFilterChange}
/>
}
@@ -40,11 +40,14 @@ interface MockMap {
_zoom: number;
_eventHandlers: Record<string, Array<() => void>>;
_addedLayers: Set<MockLayerGroup>;
_container: HTMLElement;
on: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
addLayer: ReturnType<typeof vi.fn>;
removeLayer: ReturnType<typeof vi.fn>;
hasLayer: ReturnType<typeof vi.fn>;
fitBounds: ReturnType<typeof vi.fn>;
getContainer: ReturnType<typeof vi.fn>;
getZoom: ReturnType<typeof vi.fn>;
setZoom: (z: number) => void;
fireZoomend: () => void;
@@ -138,10 +141,12 @@ vi.mock("leaflet", () => {
}
function mapFn(_container: unknown, _opts: unknown) {
const container = document.createElement("div");
const m: MockMap = {
_zoom: 5,
_eventHandlers: {},
_addedLayers: new Set<MockLayerGroup>(),
_container: container,
on: vi.fn((evt: string, fn: () => void) => {
m._eventHandlers[evt] ??= [];
m._eventHandlers[evt]!.push(fn);
@@ -165,6 +170,8 @@ vi.mock("leaflet", () => {
);
return owning ? m._addedLayers.has(owning) : false;
}),
fitBounds: vi.fn(() => m),
getContainer: vi.fn(() => container),
getZoom: vi.fn(() => m._zoom),
setZoom: (z: number) => {
m._zoom = z;
@@ -189,6 +196,10 @@ vi.mock("leaflet", () => {
return { lat, lng };
}
function latLngBounds(points: unknown[]) {
return points;
}
function polyline(latlngs: unknown, opts: unknown) {
const p: MockPolyline = {
latlngs,
@@ -214,7 +225,7 @@ vi.mock("leaflet", () => {
stop: vi.fn(),
};
const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, polyline, popup, DomEvent };
const L = { marker, layerGroup, map: mapFn, tileLayer, icon, latLng, latLngBounds, polyline, popup, DomEvent };
return { default: L, ...L };
});
@@ -223,6 +234,7 @@ vi.mock("leaflet", () => {
// ---------------------------------------------------------------------------
import { MapCanvas } from "./MapCanvas.js";
import L from "leaflet";
// ---------------------------------------------------------------------------
// Helpers
@@ -305,6 +317,74 @@ describe("MapCanvas — legacy (flat) path", () => {
clickHandlers?.[0]?.({} as unknown);
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
it("stops tooltip pre-click events so the first city-label click is not consumed by the map", () => {
const onMarkerClick = vi.fn();
renderCanvas(
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
{ onMarkerClick },
);
const marker = createdMarkers[0];
if (!marker) {
throw new Error("expected marker to be created");
}
const mousedownHandlers = marker.tooltipHandlers["mousedown"];
expect(mousedownHandlers?.length).toBeGreaterThan(0);
vi.mocked(L.DomEvent.stop).mockClear();
mousedownHandlers?.[0]?.({} as unknown);
expect(L.DomEvent.stop).toHaveBeenCalled();
expect(onMarkerClick).not.toHaveBeenCalled();
marker.tooltipHandlers["click"]?.[0]?.({} as unknown);
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
it("stops marker click propagation before selecting the city", () => {
const onMarkerClick = vi.fn();
renderCanvas(
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
{ onMarkerClick },
);
const marker = createdMarkers[0];
if (!marker) {
throw new Error("expected marker to be created");
}
const markerClick = marker.on.mock.calls.find((call) => call[0] === "click")?.[1] as
| ((event: unknown) => void)
| undefined;
expect(markerClick).toBeDefined();
vi.mocked(L.DomEvent.stop).mockClear();
markerClick?.({});
expect(L.DomEvent.stop).toHaveBeenCalled();
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
it("selects marker icon clicks at the map container capture layer", () => {
const onMarkerClick = vi.fn();
renderCanvas(
[{ id: "MOW", lat: 1, lng: 2, style: "blue-small", label: "Москва", tooltipPermanent: true }],
{ onMarkerClick },
);
const map = createdMaps[0];
if (!map) {
throw new Error("expected map to be created");
}
const markerIcon = document.createElement("img");
markerIcon.className = "leaflet-marker-icon";
markerIcon.setAttribute("title", "MOW");
map._container.append(markerIcon);
markerIcon.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(onMarkerClick).toHaveBeenCalledWith("MOW");
});
});
describe("MapCanvas — categorized: visibility predicate", () => {
@@ -503,10 +583,7 @@ describe("MapCanvas — polylines (C.3)", () => {
expect(createdPolylines.length).toBe(0);
});
it("draws multi-hop polylines through every city in the index", () => {
// Route/domestic filtering happens upstream in filterRoutes — by the time
// a polyline reaches MapCanvas, every city on its path is expected to be
// drawn, independent of zoom-tier visibility.
it("skips hidden intermediate cities when drawing multi-hop polylines", () => {
render(
<MapCanvas
markers={[cm("A"), cm("B", "other"), cm("C")]}
@@ -517,6 +594,10 @@ describe("MapCanvas — polylines (C.3)", () => {
);
expect(createdPolylines.length).toBeGreaterThan(0);
const latlngs = createdPolylines[0]!.latlngs as unknown[];
expect(latlngs).toHaveLength(2);
const hiddenIntermediate = createdMarkers.find((m) => m.options.title === "B")!;
expect(hiddenIntermediate.getLatLng).not.toHaveBeenCalled();
});
it("applies connecting style for non-direct routes", () => {
@@ -573,6 +654,25 @@ describe("MapCanvas — polylines (C.3)", () => {
expect(createdPolylines.length).toBe(0);
});
it("fits the map to route markers when polylines change", () => {
render(
<MapCanvas
markers={[
cm("A"),
cm("B"),
]}
polylines={[pl("line", ["A", "B"])]}
tileUrl="t"
/>,
);
const map = createdMaps[0]!;
expect(map.fitBounds).toHaveBeenCalled();
const [bounds, options] = map.fitBounds.mock.calls.at(-1)!;
expect(bounds).toHaveLength(2);
expect(options).toMatchObject({ padding: [24, 24], maxZoom: 6 });
});
it("does not force-open route endpoint tooltip when city is not intermediate", () => {
render(
<MapCanvas
@@ -45,6 +45,16 @@ function getIcon(style: MarkerStyle): L.Icon {
return L.icon(opts);
}
function findMarkerIdFromEventTarget(target: EventTarget | null): string | undefined {
if (!(target instanceof Element)) return undefined;
const tooltip = target.closest<HTMLElement>(".city-label[data-marker-id]");
if (tooltip?.dataset.markerId) return tooltip.dataset.markerId;
const marker = target.closest<HTMLElement>(".leaflet-marker-icon[title]");
return marker?.getAttribute("title") ?? undefined;
}
// ---------------------------------------------------------------------------
// Polyline styles
// ---------------------------------------------------------------------------
@@ -268,8 +278,8 @@ export const MapCanvas: FC<MapCanvasProps> = ({
const resolved: L.Marker[] = [];
for (const cityId of pl.cityIds) {
const m = markerIndexRef.current.get(cityId);
if (m && map.hasLayer(m)) resolved.push(m);
const marker = markerIndexRef.current.get(cityId);
if (marker && map.hasLayer(marker)) resolved.push(marker);
}
if (resolved.length < 2) continue;
@@ -286,6 +296,25 @@ export const MapCanvas: FC<MapCanvasProps> = ({
}
}
function syncRouteBounds(): void {
const map = mapRef.current;
if (!map) return;
const points: L.LatLng[] = [];
for (const pl of polylinesRef.current) {
for (const cityId of pl.cityIds) {
const marker = markerIndexRef.current.get(cityId);
if (marker && map.hasLayer(marker)) points.push(marker.getLatLng());
}
}
if (points.length < 2) return;
map.fitBounds(L.latLngBounds(points), {
padding: [24, 24],
maxZoom: maxZoomRef.current,
});
}
// --- Initialize map ---
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
@@ -306,6 +335,24 @@ export const MapCanvas: FC<MapCanvasProps> = ({
minZoom: minZoomRef.current,
}).addTo(map);
const container = map.getContainer();
const stopCityPointer = (event: Event) => {
const markerId = findMarkerIdFromEventTarget(event.target);
if (!markerId || !markerIndexRef.current.has(markerId)) return;
event.stopPropagation();
};
const selectCityFromDom = (event: Event) => {
const markerId = findMarkerIdFromEventTarget(event.target);
if (!markerId || !markerIndexRef.current.has(markerId)) return;
event.preventDefault();
event.stopPropagation();
onMarkerClickRef.current?.(markerId);
};
container.addEventListener("pointerdown", stopCityPointer, true);
container.addEventListener("mousedown", stopCityPointer, true);
container.addEventListener("touchstart", stopCityPointer, true);
container.addEventListener("click", selectCityFromDom, true);
markersLayerRef.current = L.layerGroup().addTo(map);
polylinesLayerRef.current = L.layerGroup().addTo(map);
popupsLayerRef.current = L.layerGroup().addTo(map);
@@ -337,6 +384,10 @@ export const MapCanvas: FC<MapCanvasProps> = ({
mapRef.current = map;
return () => {
container.removeEventListener("pointerdown", stopCityPointer, true);
container.removeEventListener("mousedown", stopCityPointer, true);
container.removeEventListener("touchstart", stopCityPointer, true);
container.removeEventListener("click", selectCityFromDom, true);
map.remove();
mapRef.current = null;
markersLayerRef.current = null;
@@ -388,13 +439,47 @@ export const MapCanvas: FC<MapCanvasProps> = ({
});
const tooltip = marker.getTooltip();
const attachTooltipDomHandlers = () => {
const element = tooltip?.getElement();
if (!element || element.dataset.markerClickBound === m.id) return;
element.dataset.markerClickBound = m.id;
element.dataset.markerId = m.id;
const stopTooltipPointerEvent = (event: Event) => {
event.stopPropagation();
};
const selectFromTooltip = (event: Event) => {
event.preventDefault();
event.stopPropagation();
onMarkerClickRef.current?.(m.id);
};
element.addEventListener("pointerdown", stopTooltipPointerEvent, true);
element.addEventListener("pointerup", stopTooltipPointerEvent, true);
element.addEventListener("mousedown", stopTooltipPointerEvent, true);
element.addEventListener("mouseup", stopTooltipPointerEvent, true);
element.addEventListener("touchstart", stopTooltipPointerEvent, true);
element.addEventListener("touchend", stopTooltipPointerEvent, true);
element.addEventListener("dblclick", stopTooltipPointerEvent, true);
element.addEventListener("click", selectFromTooltip, true);
};
const stopTooltipEvent = (event: L.LeafletMouseEvent) => {
L.DomEvent.stop(event);
};
marker.on("tooltipopen", attachTooltipDomHandlers);
tooltip?.on("mousedown", stopTooltipEvent);
tooltip?.on("mouseup", stopTooltipEvent);
tooltip?.on("dblclick", stopTooltipEvent);
tooltip?.on("preclick", stopTooltipEvent);
tooltip?.on("click", (event: L.LeafletMouseEvent) => {
L.DomEvent.stop(event);
onMarkerClickRef.current?.(m.id);
});
}
marker.on("click", () => {
marker.on("click", (event: L.LeafletMouseEvent) => {
L.DomEvent.stop(event);
onMarkerClickRef.current?.(m.id);
});
@@ -426,6 +511,7 @@ export const MapCanvas: FC<MapCanvasProps> = ({
// --- Sync polylines ---
useEffect(() => {
syncPolylines();
syncRouteBounds();
}, [polylines]);
// --- Sync popups ---
@@ -102,6 +102,49 @@ describe("filterRoutes — connections", () => {
});
});
describe("filterRoutes — route mode without transfer-only toggle", () => {
it("drops malformed direct routes that contain intermediate cities", () => {
const routes: IFlightRoute[] = [
{ route: ["SVO", "LED"], isDirect: true },
{ route: ["SVO", "AER", "LED"], isDirect: true },
{ route: ["SVO", "AER", "LED"], isDirect: false },
];
const out = filterRoutes(
routes,
filter({ departure: "MOW", arrival: "LED", connections: false }),
d,
);
expect(out).toEqual([{ route: ["SVO", "LED"], isDirect: true }]);
});
it("keeps connecting routes in route mode when transfer-only toggle is active", () => {
const routes: IFlightRoute[] = [
{ route: ["SVO", "LED"], isDirect: true },
{ route: ["SVO", "AER", "LED"], isDirect: false },
];
const out = filterRoutes(
routes,
filter({ departure: "MOW", arrival: "LED", connections: true }),
d,
);
expect(out).toEqual([{ route: ["SVO", "AER", "LED"], isDirect: false }]);
});
it("does not apply endpoint-direct filtering in spider mode", () => {
const routes: IFlightRoute[] = [
{ route: ["SVO", "AER", "LED"], isDirect: true },
];
const out = filterRoutes(routes, filter({ departure: "MOW" }), d);
expect(out).toEqual(routes);
});
});
describe("filterRoutes — airport-code normalization", () => {
it("treats airport codes (SVO, JFK) as their city codes (MOW, NYC)", () => {
const routes: IFlightRoute[] = [
+16 -1
View File
@@ -17,7 +17,10 @@ import type { IFlightRoute, IFlightsMapFilterState } from "./types.js";
export function filterRoutes(
routes: IFlightRoute[],
filter: Pick<IFlightsMapFilterState, "domestic" | "international" | "connections">,
filter: Pick<
IFlightsMapFilterState,
"departure" | "arrival" | "domestic" | "international" | "connections"
>,
dictionaries: IDictionaries,
): IFlightRoute[] {
const { domestic, international, connections } = filter;
@@ -32,6 +35,17 @@ export function filterRoutes(
r.route.some((code) => dictionaries.otherCityCodes.has(toCityCode(code)));
const hasConnections = (r: IFlightRoute): boolean => !r.isDirect;
const isEndpointDirectRoute = (r: IFlightRoute): boolean => {
if (!filter.departure || !filter.arrival) return true;
if (!r.isDirect) return false;
const normalized = r.route.map(toCityCode);
return (
normalized.length === 2 &&
normalized[0] === toCityCode(filter.departure) &&
normalized[1] === toCityCode(filter.arrival)
);
};
const predicates: Array<(r: IFlightRoute) => boolean> = [];
@@ -39,6 +53,7 @@ export function filterRoutes(
else if (international && !domestic) predicates.push(isInternational);
if (connections) predicates.push(hasConnections);
else if (filter.arrival) predicates.push(isEndpointDirectRoute);
if (predicates.length === 0) return routes;
return routes.filter((r) => predicates.every((p) => p(r)));
@@ -17,6 +17,7 @@ export interface UseFlightsMapSearchResult {
routes: IFlightRoute[];
loading: boolean;
error: ApiError | null;
completedParams: FlightsMapSearchParams | null;
refresh: () => void;
}
@@ -31,6 +32,8 @@ export function useFlightsMapSearch(
const [routes, setRoutes] = useState<IFlightRoute[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const [completedParams, setCompletedParams] =
useState<FlightsMapSearchParams | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const paramsRef = useRef(params);
@@ -44,18 +47,21 @@ export function useFlightsMapSearch(
if (!paramsRef.current) {
setRoutes([]);
setLoading(false);
setCompletedParams(null);
return;
}
const requestParams = { ...paramsRef.current };
let cancelled = false;
setLoading(true);
setError(null);
setRoutes([]);
searchDestinations(client, paramsRef.current)
searchDestinations(client, requestParams)
.then((response: IDestinationsResponse) => {
if (!cancelled) {
setRoutes(response.data.routes);
setCompletedParams(requestParams);
setLoading(false);
}
})
@@ -63,6 +69,7 @@ export function useFlightsMapSearch(
if (!cancelled) {
setError(err);
setRoutes([]);
setCompletedParams(requestParams);
setLoading(false);
}
});
@@ -80,5 +87,5 @@ export function useFlightsMapSearch(
refreshKey,
]);
return { routes, loading, error, refresh };
return { routes, loading, error, completedParams, refresh };
}
+13
View File
@@ -289,6 +289,19 @@ describe("getCalendarDays", () => {
expect(result).toEqual(["2025-01-15", "2025-01-16", "2025-01-17"]);
});
it("parses bitmask days from the requested base date", async () => {
const { client } = createMockClient({ days: "0101" });
const result = await getCalendarDays(client, {
date: "2025-01-15",
searchType: "route",
departure: "SVO",
arrival: "JFK",
});
expect(result).toEqual(["20250116", "20250118"]);
});
it("returns empty array for empty days string", async () => {
const { client } = createMockClient({ days: "" });
+6 -8
View File
@@ -118,8 +118,8 @@ export async function getFlightDetails(
* Maps to: `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`
*
* The API returns `{ days: "1110101..." }` — a 31-char bitmask where each
* character represents a day starting from (baseDate - 1). '1' = flights
* available, '0' = no flights. This function converts enabled positions
* character represents a day starting from the requested base date. '1' =
* flights available, '0' = no flights. This function converts enabled positions
* into yyyyMMdd date strings.
*/
export async function getCalendarDays(
@@ -157,10 +157,10 @@ function buildCalendarSearchSegment(params: CalendarParams): string {
* Parse a calendar days bitmask into an array of yyyyMMdd date strings.
*
* The API returns a 31-char string of '1' and '0'. Each position maps to
* a day starting from (baseDate - 1 day). Positions with '1' are enabled.
* a day starting from baseDate. Positions with '1' are enabled.
*
* Matches Angular's search-page-base.component.ts logic:
* date.setDate(date.getDate() - 1);
* Matches Angular's updateCalendar() logic:
* date.setDate(date.getDate() - 1); // caller-provided base date
* for (var i = 0; i < res.days.length; i++) { ... date.setDate(date.getDate() + 1); }
*/
function parseCalendarDays(days: string, baseDate: string): string[] {
@@ -177,7 +177,7 @@ function parseCalendarDays(days: string, baseDate: string): string[] {
/**
* Convert a bitmask string to yyyyMMdd date strings.
* Base date is the search date; iteration starts from (baseDate - 1 day).
* Base date is the first calendar date returned by the endpoint.
*/
function bitmaskToDates(bitmask: string, baseDate: string): string[] {
// Parse baseDate — could be "yyyy-MM-ddT00:00:00" or "yyyyMMdd"
@@ -193,9 +193,7 @@ function bitmaskToDates(bitmask: string, baseDate: string): string[] {
day = parseInt(baseDate.slice(6, 8), 10);
}
// Start from baseDate - 1 day (matching Angular)
const cursor = new Date(year, month, day);
cursor.setDate(cursor.getDate() - 1);
const result: string[] = [];
for (let i = 0; i < bitmask.length; i++) {
@@ -35,7 +35,6 @@ export const BoardDetailsHeader: FC<BoardDetailsHeaderProps> = ({ flight, locale
<FlightActions
flight={flight}
locale={locale}
showShare
showPrint={false}
/>
</div>
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { parseISO, format } from "date-fns";
import { useTranslation } from "@/i18n/provider.js";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import type { ISimpleFlight } from "../../types.js";
import "./actions.scss";
@@ -24,9 +27,11 @@ function buildBuyTicketUrl(flight: ISimpleFlight): string {
if (!firstLeg || !lastLeg) return "";
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depDate = parseISO(firstLeg.departure.times.scheduledDeparture.utc);
const date = format(depDate, "yyyyMMdd");
return `https://www.aeroflot.ru/sb/app/ru-ru#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${date}.${arr}&autosearch=Y`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
}
export const BuyTicketButton: FC<BuyTicketButtonProps> = ({ flight }) => {
@@ -88,6 +88,13 @@ describe("DetailsHeaderBadge", () => {
expect(screen.getByText("SU 0022")).toBeTruthy();
});
it("TIRREDESIGN-13: renders suffix in primary flight number", () => {
const flight = makeDirect();
flight.flightId = { ...flight.flightId, flightNumber: "0038", suffix: "D" };
render(<DetailsHeaderBadge flight={flight} locale="ru" />);
expect(screen.getByText("SU 0038D")).toBeTruthy();
});
it("renders operator-logo", () => {
render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
expect(screen.getByTestId("operator-logo")).toBeTruthy();
@@ -39,7 +39,7 @@ export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
}) => {
const { t } = useTranslation();
const codeshareLegs = getCodeshareLegs(flight);
const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}${flight.flightId.suffix ?? ""}`;
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
return (
@@ -78,9 +78,9 @@ function makeFlight(): ISimpleFlight {
}
describe("FlightActions", () => {
it("renders share, buy, register by default (status hidden)", () => {
it("renders buy and register by default, without share or status", () => {
render(<FlightActions flight={makeFlight()} locale="ru" />);
expect(screen.getByTestId("share-button")).toBeTruthy();
expect(screen.queryByTestId("share-button")).toBeNull();
expect(screen.getByTestId("buy-ticket-button")).toBeTruthy();
expect(screen.getByTestId("registration-button")).toBeTruthy();
expect(screen.queryByTestId("flight-status-button")).toBeNull();
@@ -91,11 +91,6 @@ describe("FlightActions", () => {
expect(screen.getByTestId("flight-status-button")).toBeTruthy();
});
it("hides share button when showShare=false", () => {
render(<FlightActions flight={makeFlight()} locale="ru" showShare={false} />);
expect(screen.queryByTestId("share-button")).toBeNull();
});
it("hides buy ticket when canBuyTicket returns false", async () => {
const mod = await import("./visibility/buyTicketVisibility.js");
vi.mocked(mod.canBuyTicket).mockReturnValue(false);
@@ -8,7 +8,6 @@ import { canViewFlightStatus } from "./visibility/flightStatusVisibility.js";
import { BuyTicketButton } from "./BuyTicketButton.js";
import { RegistrationButton } from "./RegistrationButton.js";
import { FlightStatusButton } from "./FlightStatusButton.js";
import { ShareButton } from "./ShareButton.js";
import { PrintButton } from "./PrintButton.js";
export interface FlightActionsProps {
@@ -16,7 +15,6 @@ export interface FlightActionsProps {
locale: string;
showStatus?: boolean;
showPrint?: boolean;
showShare?: boolean;
showRegister?: boolean;
showBuy?: boolean;
/**
@@ -34,7 +32,6 @@ export const FlightActions: FC<FlightActionsProps> = ({
locale,
showStatus = false,
showPrint = false,
showShare = true,
showRegister = true,
showBuy = true,
forceBuy = false,
@@ -49,12 +46,9 @@ export const FlightActions: FC<FlightActionsProps> = ({
showStatus &&
canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS);
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
return (
<div className="flight-actions" data-testid="flight-actions">
{showPrint && <PrintButton flight={flight} />}
{showShare && <ShareButton url={shareUrl} locale={locale} />}
{canBuy && <BuyTicketButton flight={flight} locale={locale} />}
{canReg && <RegistrationButton flight={flight} />}
{canStatus && <FlightStatusButton flight={flight} locale={locale} />}
@@ -87,8 +87,8 @@ describe("LastUpdate", () => {
expect(ts.textContent).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/);
});
it("renders share button", () => {
it("does not render a share button", () => {
render(<LastUpdate flight={makeFlight("2026-04-20T12:34:00Z")} locale="ru" />);
expect(screen.getByTestId("share-button")).toBeTruthy();
expect(screen.queryByTestId("share-button")).toBeNull();
});
});
@@ -2,7 +2,6 @@ import { type FC, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import { ShareButton } from "./ShareButton.js";
export interface LastUpdateProps {
flight: ISimpleFlight;
@@ -21,7 +20,7 @@ function formatStamp(d: Date): string {
* timestamp. We mirror that here: capture `Date.now()` the first time we
* see a given flight.id, then re-capture whenever the id changes.
*/
export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
export const LastUpdate: FC<LastUpdateProps> = ({ flight }) => {
const { t } = useTranslation();
const [loadedAt, setLoadedAt] = useState<Date>(() => new Date());
const seenFlightIdRef = useRef<string | null>(null);
@@ -34,11 +33,9 @@ export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
}, [flight.id]);
const timestamp = formatStamp(loadedAt);
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
return (
<div className="last-update">
<ShareButton url={shareUrl} locale={locale} />
<span className="last-update__description">
<span>{t("SHARED.LAST-UPDATE")}:</span>
<span className="last-update__time" data-testid="last-update-timestamp">
@@ -1,35 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ShareButton } from "./ShareButton.js";
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
describe("ShareButton", () => {
it("renders button with testid share-button", () => {
render(<ShareButton url="https://example.com" locale="en" />);
expect(screen.getByTestId("share-button")).toBeTruthy();
});
it("panel is closed by default", () => {
render(<ShareButton url="https://example.com" locale="en" />);
expect(screen.queryByTestId("share-panel")).toBeNull();
});
it("click opens the share panel", () => {
render(<ShareButton url="https://example.com" locale="en" />);
fireEvent.click(screen.getByTestId("share-button"));
expect(screen.getByTestId("share-panel")).toBeTruthy();
});
it("second click closes the share panel", () => {
render(<ShareButton url="https://example.com" locale="en" />);
const btn = screen.getByTestId("share-button");
fireEvent.click(btn);
expect(screen.getByTestId("share-panel")).toBeTruthy();
fireEvent.click(btn);
expect(screen.queryByTestId("share-panel")).toBeNull();
});
});
@@ -1,45 +0,0 @@
import { type FC, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { SharePanel } from "./SharePanel.js";
import "./actions.scss";
export interface ShareButtonProps {
url: string;
locale: string;
}
export const ShareButton: FC<ShareButtonProps> = ({ url, locale }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="share-button-wrap">
<button
type="button"
className="flight-action-btn flight-action-btn--transparent"
data-testid="share-button"
title={t("BOARD.SHARE")}
onClick={() => setOpen((v) => !v)}
aria-label={t("BOARD.SHARE")}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{open && <SharePanel url={url} locale={locale} onClose={() => setOpen(false)} />}
</div>
);
};
@@ -1,55 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SharePanel } from "./SharePanel.js";
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
describe("SharePanel", () => {
let writeTextMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
writable: true,
configurable: true,
});
});
it("renders 3 social links for en locale (no Weibo)", () => {
render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
expect(screen.getByTestId("share-facebook")).toBeTruthy();
expect(screen.getByTestId("share-vk")).toBeTruthy();
expect(screen.getByTestId("share-twitter")).toBeTruthy();
expect(screen.queryByTestId("share-weibo")).toBeNull();
});
it("renders 4 social links for zh locale (includes Weibo)", () => {
render(<SharePanel url="https://example.com/flight" locale="zh" onClose={() => {}} />);
expect(screen.getByTestId("share-facebook")).toBeTruthy();
expect(screen.getByTestId("share-vk")).toBeTruthy();
expect(screen.getByTestId("share-twitter")).toBeTruthy();
expect(screen.getByTestId("share-weibo")).toBeTruthy();
});
it("Facebook href contains encoded URL", () => {
render(<SharePanel url="https://example.com/flight?a=1" locale="en" onClose={() => {}} />);
const link = screen.getByTestId("share-facebook") as HTMLAnchorElement;
expect(link.href).toContain(encodeURIComponent("https://example.com/flight?a=1"));
});
it("copy button calls navigator.clipboard.writeText and onClose", async () => {
const onClose = vi.fn();
render(<SharePanel url="https://example.com/flight" locale="en" onClose={onClose} />);
fireEvent.click(screen.getByTestId("share-copy"));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("https://example.com/flight");
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
@@ -1,96 +0,0 @@
import { type FC, type MouseEvent, useEffect } from "react";
import { useTranslation } from "@/i18n/provider.js";
import "./actions.scss";
export interface SharePanelProps {
url: string;
locale: string;
onClose: () => void;
}
export const SharePanel: FC<SharePanelProps> = ({ url, locale, onClose }) => {
const { t } = useTranslation();
const encoded = encodeURIComponent(url);
// Close on Escape — matches Angular's PrimeNG p-overlayPanel dismissable behaviour.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCopy = async (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(url);
onClose();
} catch {
// silent
}
};
return (
<div className="share-panel" data-testid="share-panel" role="dialog" aria-label={t("BOARD.SHARE")}>
<div className="share-elements">
<div>
<a
className="share-element facebook"
data-testid="share-facebook"
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
{t("SHARE.FACEBOOK")}
</a>
</div>
<div>
<a
className="share-element vk"
data-testid="share-vk"
href={`https://vk.com/share.php?url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
{t("SHARE.VK")}
</a>
</div>
<div>
<a
className="share-element twitter"
data-testid="share-twitter"
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
{t("SHARE.TWITTER")}
</a>
</div>
{locale === "zh" && (
<div>
<a
className="share-element weibo"
data-testid="share-weibo"
href={`https://service.weibo.com/share/share.php?url=${encoded}`}
target="_blank"
rel="noopener noreferrer"
>
{t("SHARE.WEIBO")}
</a>
</div>
)}
<div>
<a
className="share-element copy"
data-testid="share-copy"
href="#"
onClick={handleCopy}
>
{t("SHARE.COPY")}
</a>
</div>
</div>
</div>
);
};
@@ -155,6 +155,22 @@ describe("DayTabs", () => {
expect(screen.getByTestId("day-select")).toBeTruthy();
});
it("deduplicates available dates before rendering the mobile select", () => {
render(
<DayTabs
selectedDate="20260416"
availableDates={["20260416", "20260416", "20260417"]}
daysBefore={1}
daysAfter={2}
locale="en"
onNavigate={() => {}}
/>,
);
const options = screen.getByTestId("day-select").querySelectorAll("option");
expect(options).toHaveLength(2);
});
// ── TZ §4.1.13.1 compliance tests ──────────────────────────────────────
it("4.1.13.1: active range today-1 to today+14 yields 16 total dates", () => {
@@ -68,8 +68,15 @@ export const DayTabs: FC<DayTabsProps> = ({
// the route has no day data). Treat every date as tappable in that
// case — matches Angular where the tabs stay enabled until we *know*
// the upstream reports no flights for a given day.
const availableSet = useMemo(() => new Set(availableDates), [availableDates]);
const disableByAvailability = availableDates.length > 0;
const uniqueAvailableDates = useMemo(
() => Array.from(new Set(availableDates)),
[availableDates],
);
const availableSet = useMemo(
() => new Set(uniqueAvailableDates),
[uniqueAvailableDates],
);
const disableByAvailability = uniqueAvailableDates.length > 0;
const visibleDates = allDates.slice(
currentPage * PAGE_SIZE,
@@ -150,7 +157,7 @@ export const DayTabs: FC<DayTabsProps> = ({
</div>
<DaySelect
selectedDate={selectedDate}
availableDates={availableDates}
availableDates={uniqueAvailableDates}
locale={locale}
onNavigate={onNavigate}
{...(mobileCaptionKey ? { captionKey: mobileCaptionKey } : {})}
@@ -60,6 +60,15 @@ describe("FlightsMiniListItem", () => {
expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
});
it("TIRREDESIGN-13: renders suffix in flight number", () => {
const flight = makeDirectFlight({
id: "SU0038D-20260514",
flightId: { carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" },
});
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByText(/SU\s*0038D/)).toBeTruthy();
});
it("renders departure and arrival times", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
@@ -12,6 +12,7 @@ import { Link } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
import { buildOnlineBoardUrl } from "../../url.js";
import { getFlightSearchDate } from "../../flightSearchDate.js";
import {
formatLocalTime,
formatDayMonthYear,
@@ -51,7 +52,7 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
carrier: flight.flightId.carrier,
flightNumber: flight.flightId.flightNumber,
...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}),
date: flight.flightId.date,
date: getFlightSearchDate(flight),
})}`;
const depIso = getDepTimeIso(dep);
@@ -126,14 +127,14 @@ export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniList
<div className="mini-list__flight-number">
{(() => {
const childIds = (flight as typeof flight & {
_childFlightIds?: { carrier: string; flightNumber: string }[];
_childFlightIds?: { carrier: string; flightNumber: string; suffix?: string }[];
})._childFlightIds;
if (childIds && childIds.length > 1) {
return childIds
.map((c) => `${c.carrier} ${c.flightNumber}`)
.map((c) => `${c.carrier} ${c.flightNumber}${c.suffix ?? ""}`)
.join(", ");
}
return `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
return `${flight.flightId.carrier} ${flight.flightId.flightNumber}${flight.flightId.suffix ?? ""}`;
})()}
<span
className={`mini-list__status-icon ${iconColor}`}
@@ -173,6 +173,29 @@ describe("OnlineBoardDetailsPage", () => {
expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1);
});
it("TIRREDESIGN-13: renders suffix in the details page flight number", () => {
const suffixFlight = {
...mockFlight,
id: "SU0038D-20260514",
flightId: { carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" },
} as IDirectFlight;
mockState = {
flight: suffixFlight,
allFlights: [suffixFlight],
daysOfFlight: ["20260514"],
loading: false,
error: null,
};
render(
<OnlineBoardDetailsPage
flightId={{ carrier: "SU", flightNumber: "0038", suffix: "D", date: "20260514" }}
locale="ru"
canonicalOrigin="https://www.aeroflot.ru"
/>,
);
expect(screen.getAllByText("SU 0038D").length).toBeGreaterThanOrEqual(1);
});
it("renders loading skeleton", () => {
mockState = { flight: null, allFlights: [], daysOfFlight: [], loading: true, error: null };
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
@@ -440,6 +463,20 @@ describe("OnlineBoardDetailsPage", () => {
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU1234-20260515");
});
it("TIRREDESIGN-13: flight context keeps suffix in breadcrumb back URL", () => {
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-flight-SU0038D-20260514");
const { container } = render(
<OnlineBoardDetailsPage
flightId={mockFlightId}
locale="ru"
canonicalOrigin="https://example.com"
/>,
);
const link = getCrumbLink(container, "BREADCRUMBS.FLIGHT-NUMBER");
expect(link).toBeTruthy();
expect(link?.getAttribute("href")).toContain("/ru/onlineboard/flight/SU0038D-20260514");
});
it("route context → leaf 'Маршрут: …' linking back to /route/", () => {
mockSearchParamsInstance = new URLSearchParams("request=onlineboard-route-MOW-LED-20260515");
const { container } = render(
@@ -18,10 +18,12 @@ 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";
import { buildOnlineBoardUrl } from "../url.js";
import { buildOnlineBoardUrl, parseFlightUrlParams } from "../url.js";
import { getFlightSearchDate } from "../flightSearchDate.js";
import { useCityName, useStationDisplayName } from "@/shared/hooks/useDictionaries.js";
import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js";
import { FlightsMiniList } from "./FlightsMiniList/index.js";
@@ -30,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 {
@@ -64,6 +67,10 @@ export interface OnlineBoardDetailsPageProps {
canonicalOrigin: string;
}
function parseParentFlightRequest(flightNumber: string, date: string): IParsedFlightId | null {
return parseFlightUrlParams(`${flightNumber}-${date}`);
}
/**
* One side of a leg's station block — station code + airport name + city
* + terminal, plus scheduled / expected / actual times formatted as
@@ -384,19 +391,21 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}T00:00:00`,
};
const { flight: firstFlight, allFlights, daysOfFlight, loading, error } = useFlightDetails(detailsParams);
const { flight: firstFlight, allFlights, daysOfFlight, loading, error, refresh } = useFlightDetails(detailsParams);
// Pick the flight matching the URL's flightId (date-based match). The API
// response may contain multiple flights with the same flight number on
// different dates; we need the one the user actually navigated to.
// different dates; match Angular's dateToSearchBy, not backend flightId.date.
const flight =
allFlights.find((f) => f.flightId.date === flightId.date) ?? firstFlight;
allFlights.find((f) => getFlightSearchDate(f) === flightId.date) ?? firstFlight;
// Live updates via SignalR
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
flightId,
flight?.flightId ?? null,
flight,
refresh,
);
const isStale = useStaleDataTimers();
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
@@ -447,13 +456,14 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
const backUrl = (() => {
switch (parentRequest.kind) {
case "flight": {
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
if (!m || !m[1] || !m[2]) return `/${locale}/onlineboard`;
const parsed = parseParentFlightRequest(parentRequest.flightNumber, parentRequest.date);
if (!parsed) return `/${locale}/onlineboard`;
return `/${locale}/${buildOnlineBoardUrl({
type: "flight",
carrier: m[1],
flightNumber: m[2],
date: parentRequest.date,
carrier: parsed.carrier,
flightNumber: parsed.flightNumber,
...(parsed.suffix ? { suffix: parsed.suffix } : {}),
date: parsed.date,
})}`;
}
case "departure":
@@ -481,8 +491,10 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
switch (parentRequest.kind) {
case "flight": {
// Angular renders "Рейс: SU 6188" — carrier and number space-separated
const m = parentRequest.flightNumber.match(/^([A-Z]{2,3})(\d+)$/);
const formatted = m?.[1] && m?.[2] ? `${m[1]} ${m[2]}` : parentRequest.flightNumber;
const parsed = parseParentFlightRequest(parentRequest.flightNumber, parentRequest.date);
const formatted = parsed
? `${parsed.carrier} ${parsed.flightNumber}${parsed.suffix ?? ""}`
: parentRequest.flightNumber;
return t("BREADCRUMBS.FLIGHT-NUMBER", { flightNumber: formatted });
}
case "departure":
@@ -592,7 +604,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
}
const legs = getLegs(displayFlight);
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}${displayFlight.flightId.suffix ?? ""}`;
const firstLeg = legs[0];
const lastLeg = legs[legs.length - 1];
@@ -610,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>}
@@ -14,6 +14,11 @@ import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
// ---------------------------------------------------------------------------
const mockNavigate = vi.fn();
const calendarDaysMock = vi.hoisted(() => ({
days: [] as string[],
loaded: false,
params: [] as unknown[],
}));
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
@@ -35,7 +40,14 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
// useCalendarDays would otherwise pull the api provider into the test
// tree just for the disabled-dates wiring added in TIRREDESIGN-12.
vi.mock("../hooks/useCalendarDays.js", () => ({
useCalendarDays: () => ({ days: [], loading: false }),
useCalendarDays: (params: unknown) => {
calendarDaysMock.params.push(params);
return {
days: calendarDaysMock.days,
loading: false,
loaded: calendarDaysMock.loaded,
};
},
}));
vi.mock("@/shared/state/crossSectionNavigation.js", () => ({
@@ -46,10 +58,15 @@ vi.mock("@/shared/state/crossSectionNavigation.js", () => ({
vi.mock("primereact/calendar", () => ({
Calendar: (props: Record<string, unknown>) => {
const inputRef = props["inputRef"] as React.RefObject<HTMLInputElement> | undefined;
const formatLocalYmd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return (
<input
data-testid={props["data-testid"] as string}
placeholder={props["placeholder"] as string}
data-disabled-dates={((props["disabledDates"] as Date[] | undefined) ?? [])
.map(formatLocalYmd)
.join(",")}
readOnly
ref={inputRef}
/>
@@ -96,13 +113,21 @@ function renderRouteTab() {
render(<OnlineBoardFilter initialTab="route" />);
}
function resetTestState() {
vi.clearAllMocks();
calendarDaysMock.days = [];
calendarDaysMock.loaded = false;
calendarDaysMock.params = [];
_sliderOnChange = null;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("OnlineBoardFilter clear-button (X) per TZ §4.1.9 Tables 11/12", () => {
beforeEach(() => {
vi.clearAllMocks();
resetTestState();
});
// -------------------------------------------------------------------------
@@ -214,8 +239,7 @@ describe("OnlineBoardFilter clear-button (X) per TZ §4.1.9 Tables 11/12", (
describe("OnlineBoardFilter time slider 1h minimum gap per TZ §4.1.9 Tables 12", () => {
beforeEach(() => {
vi.clearAllMocks();
_sliderOnChange = null;
resetTestState();
});
it("4.1.9-R: time slider enforces 1h minimum gap (zero gap → to clamped to from + 60)", () => {
@@ -247,11 +271,166 @@ describe("OnlineBoardFilter time slider 1h minimum gap per TZ §4.1.9 Tables
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("23:00 — 24:00");
});
it("TIRREDESIGN-11: syncs the slider label when URL time params change", () => {
const { rerender } = render(
<OnlineBoardFilter
initialTab="route"
initialTimeFrom="0900"
initialTimeTo="2100"
/>,
);
const value = screen.getByTestId("time-selector").querySelector(".time-selector__value");
expect(value?.textContent).toBe("09:00 — 21:00");
rerender(
<OnlineBoardFilter
initialTab="route"
initialTimeFrom="1400"
initialTimeTo="1800"
/>,
);
expect(value?.textContent).toBe("14:00 — 18:00");
rerender(<OnlineBoardFilter initialTab="route" />);
expect(value?.textContent).toBe("00:00 — 24:00");
});
});
describe("OnlineBoardFilter submit lock", () => {
beforeEach(() => {
resetTestState();
});
it("keeps the same submitted route search locked", () => {
render(
<OnlineBoardFilter
initialTab="route"
initialDeparture="MOW"
initialArrival="LED"
initialDate="20260515"
/>,
);
fireEvent.submit(screen.getByTestId("search-form"));
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", true);
});
it("unlocks route search after the user changes the time range", () => {
render(
<OnlineBoardFilter
initialTab="route"
initialDeparture="MOW"
initialArrival="LED"
initialDate="20260515"
/>,
);
fireEvent.submit(screen.getByTestId("search-form"));
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", true);
act(() => {
_sliderOnChange?.({ value: [600, 1440] });
});
expect(screen.getByTestId("search-submit")).toHaveProperty("disabled", false);
});
});
describe("OnlineBoardFilter operating days calendar parity (TIRREDESIGN-12)", () => {
beforeEach(() => {
resetTestState();
});
it("requests board calendar days from the calendar minimum date", () => {
calendarDaysMock.days = ["20260505"];
calendarDaysMock.loaded = true;
render(
<OnlineBoardFilter
initialTab="route"
initialDeparture="SVO"
initialArrival="GDX"
today="20260505"
/>,
);
expect(calendarDaysMock.params).toContainEqual({
date: "2026-05-04",
searchType: "route",
departure: "SVO",
arrival: "GDX",
});
});
it("disables dates missing from a loaded board operating-days response", () => {
calendarDaysMock.days = ["20260505"];
calendarDaysMock.loaded = true;
render(
<OnlineBoardFilter
initialTab="route"
initialDeparture="SVO"
initialArrival="GDX"
today="20260505"
/>,
);
const disabledDates = screen
.getByTestId("date-input")
.getAttribute("data-disabled-dates")
?.split(",");
expect(disabledDates).toContain("2026-05-04");
expect(disabledDates).not.toContain("2026-05-05");
expect(disabledDates).toContain("2026-05-06");
});
it("treats a loaded all-zero board bitmask as no available dates", () => {
calendarDaysMock.days = [];
calendarDaysMock.loaded = true;
render(
<OnlineBoardFilter
initialTab="flight"
initialFlightNumber="9999"
today="20260505"
/>,
);
const disabledDates = screen
.getByTestId("date-input")
.getAttribute("data-disabled-dates")
?.split(",");
expect(disabledDates).toContain("2026-05-04");
expect(disabledDates).toContain("2026-05-05");
});
it("requests flight calendar days with a normalized suffix flight number", () => {
render(
<OnlineBoardFilter
initialTab="flight"
initialFlightNumber="38d"
today="20260505"
/>,
);
expect(calendarDaysMock.params).toContainEqual({
date: "2026-05-04",
searchType: "flight",
flightNumber: "SU0038D",
});
});
});
describe("OnlineBoardFilter flight-number validation per TZ §4.1.9.3", () => {
beforeEach(() => {
vi.clearAllMocks();
resetTestState();
});
it("4.1.9.3-R: '38' submits as SU0038 in URL", () => {
@@ -288,7 +467,26 @@ describe("OnlineBoardFilter flight-number validation per TZ §4.1.9.3", () =
expect(url).toMatch(/SU1234/);
});
it("4.1.9.3-R: letters in flight number show error and block submit", () => {
it("TIRREDESIGN-13: suffix flight number submits as normalized SU0038D URL", () => {
render(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "38d" },
});
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalled();
const url = mockNavigate.mock.calls[0]![0] as string;
expect(url).toContain("/ru-ru/onlineboard/flight/SU0038D-20260601");
});
it("TIRREDESIGN-13: suffix flight number normalizes on blur like Angular", () => {
render(<OnlineBoardFilter initialTab="flight" />);
const input = screen.getByTestId("flight-number-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "123d" } });
fireEvent.blur(input);
expect(input.value).toBe("0123D");
});
it("4.1.9.3-R: letters without digits show error and block submit", () => {
render(<OnlineBoardFilter initialTab="flight" />);
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "abc" },
@@ -18,10 +18,7 @@ import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.j
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { useCalendarDays } from "../hooks/useCalendarDays.js";
import {
todayYyyymmdd,
yyyymmddToIso,
} from "@/shared/utils/datetime/index.js";
import { todayYyyymmdd } from "@/shared/utils/datetime/index.js";
import type { CalendarParams } from "../api.js";
import { buildOnlineBoardUrl } from "../url.js";
import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js";
@@ -49,28 +46,52 @@ function dateToYyyymmdd(value: Date): string {
return `${y}${m}${d}`;
}
function dateToIsoYmd(value: Date): string {
const y = value.getFullYear().toString();
const m = (value.getMonth() + 1).toString().padStart(2, "0");
const d = value.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
interface NormalizedFlightNumber {
flightNumber: string;
suffix?: string;
display: string;
}
/**
* Validates a flight number string per TZ §4.1.9.3:
* - Must be 1-4 digits only (no letters, no spaces).
* - Error message: "только из цифр и не должен быть длиннее 4-х символов".
* Shorter inputs (1-3 digits) are valid; they get zero-padded at submit time.
* Normalise the Angular flight-number field:
* `38` -> `0038`, `38D` -> `0038D`, `123d` -> `0123D`.
*/
function normalizeFlightNumberInput(value: string): NormalizedFlightNumber | null {
const raw = value.trim();
const match = /^(\d{1,4})([A-Za-z])?$/.exec(raw);
if (!match) return null;
const digits = match[1];
if (!digits) return null;
const flightNumber = digits.padStart(4, "0");
const suffix = match[2]?.toUpperCase();
return suffix
? { flightNumber, suffix, display: `${flightNumber}${suffix}` }
: { flightNumber, display: flightNumber };
}
function formatFlightNumberInput(value: string): string {
const normalized = normalizeFlightNumberInput(value);
return normalized?.display ?? value.toUpperCase();
}
function validateFlightNumber(value: string): string | null {
if (!value.trim()) {
const raw = value.trim();
if (!raw) {
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
}
const reg = /^\d{1,4}$/;
if (!reg.test(value.trim())) {
if (!normalizeFlightNumberInput(raw)) {
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
}
return null;
}
/** Zero-pads a 1-3 digit flight number to 4 digits per TZ §4.1.9.3 (e.g. "38" → "0038"). */
function padFlightNumber(value: string): string {
return value.trim().padStart(4, "0");
}
export interface OnlineBoardFilterProps {
/** Pre-populate filter from URL params on search results pages */
initialDeparture?: string;
@@ -164,7 +185,6 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
const fallbackTodayYmd = useRef(todayYyyymmdd()).current;
const todayYmd = today ?? fallbackTodayYmd;
const todayDate = useMemo(() => yyyymmddToDate(todayYmd), [todayYmd]);
const todayIsoStr = useMemo(() => yyyymmddToIso(todayYmd), [todayYmd]);
const { t } = useTranslation();
const navigate = useNavigate();
const { locale, language } = useLocale();
@@ -202,6 +222,10 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
// lifetime updates the calendar window correctly on subsequent renders.
const boardMinDate = useMemo(() => getBoardMinDate(todayDate), [todayDate]);
const boardMaxDate = useMemo(() => getBoardMaxDate(todayDate), [todayDate]);
const boardCalendarBaseDate = useMemo(
() => dateToIsoYmd(boardMinDate),
[boardMinDate],
);
// TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the
// current tab so non-operating days in the [minDate, maxDate] window
@@ -210,14 +234,14 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
// flight number or empty route produces no API call.
const flightCalendarParams = useMemo<CalendarParams | null>(() => {
if (activeTab !== "flight") return null;
const digits = flightNumber.trim();
if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null;
const normalized = normalizeFlightNumberInput(flightNumber);
if (!normalized) return null;
return {
date: todayIsoStr,
date: boardCalendarBaseDate,
searchType: "flight",
flightNumber: `SU${padFlightNumber(digits)}`,
flightNumber: `SU${normalized.flightNumber}${normalized.suffix ?? ""}`,
};
}, [activeTab, flightNumber, todayIsoStr]);
}, [activeTab, flightNumber, boardCalendarBaseDate]);
const routeCalendarParams = useMemo<CalendarParams | null>(() => {
if (activeTab !== "route") return null;
@@ -226,34 +250,41 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
if (!dep && !arr) return null;
if (dep && arr) {
if (dep === arr) return null;
return { date: todayIsoStr, searchType: "route", departure: dep, arrival: arr };
return { date: boardCalendarBaseDate, searchType: "route", departure: dep, arrival: arr };
}
if (dep) return { date: todayIsoStr, searchType: "departure", departure: dep };
return { date: todayIsoStr, searchType: "arrival", arrival: arr };
}, [activeTab, routeDepartureCode, routeArrivalCode, todayIsoStr]);
if (dep) return { date: boardCalendarBaseDate, searchType: "departure", departure: dep };
return { date: boardCalendarBaseDate, searchType: "arrival", arrival: arr };
}, [activeTab, routeDepartureCode, routeArrivalCode, boardCalendarBaseDate]);
const { days: flightAvailableDays } = useCalendarDays(flightCalendarParams);
const { days: routeAvailableDays } = useCalendarDays(routeCalendarParams);
const {
days: flightAvailableDays,
loaded: flightCalendarLoaded,
} = useCalendarDays(flightCalendarParams);
const {
days: routeAvailableDays,
loaded: routeCalendarLoaded,
} = useCalendarDays(routeCalendarParams);
const flightDisabledDates = useMemo(
() =>
flightAvailableDays.length === 0
!flightCalendarLoaded
? []
: computeDisabledDates(flightAvailableDays, boardMinDate, boardMaxDate),
[flightAvailableDays, boardMinDate, boardMaxDate],
[flightAvailableDays, flightCalendarLoaded, boardMinDate, boardMaxDate],
);
const routeDisabledDates = useMemo(
() =>
routeAvailableDays.length === 0
!routeCalendarLoaded
? []
: computeDisabledDates(routeAvailableDays, boardMinDate, boardMaxDate),
[routeAvailableDays, boardMinDate, boardMaxDate],
[routeAvailableDays, routeCalendarLoaded, boardMinDate, boardMaxDate],
);
// §4.1.10 — submit button locked for 30 seconds after each search.
// Value is the timestamp when the lock expires (or 0 if unlocked).
// The 30-second constant is intentionally hardcoded (not configurable).
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
const [lockedSearchSignature, setLockedSearchSignature] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
// Tick every second while the lock is active so the disabled state
// updates reactively.
@@ -262,9 +293,38 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
const id = setTimeout(() => setNow(Date.now()), 1000);
return () => clearTimeout(id);
}, [submitLockedUntil, now]);
const flightSearchSignature = useMemo(() => {
const dateParam = dateToYyyymmdd(flightDate ?? todayDate);
return [
"flight",
flightNumber.trim(),
dateParam,
].join("|");
}, [flightNumber, flightDate, todayDate]);
const routeSearchSignature = useMemo(() => {
const dateParam = dateToYyyymmdd(routeDate ?? todayDate);
const timePart =
timeRange[0] !== 0 || timeRange[1] !== 1440
? `${minutesToHhmm(timeRange[0])}-${minutesToHhmm(timeRange[1])}`
: "full-day";
return [
"route",
routeDepartureCode.trim().toUpperCase(),
routeArrivalCode.trim().toUpperCase(),
dateParam,
timePart,
].join("|");
}, [routeDepartureCode, routeArrivalCode, routeDate, timeRange, todayDate]);
const activeSearchSignature =
activeTab === "flight" ? flightSearchSignature : routeSearchSignature;
const isSubmitLocked = useMemo(
() => submitLockedUntil > 0 && now < submitLockedUntil,
[submitLockedUntil, now],
() =>
submitLockedUntil > 0 &&
now < submitLockedUntil &&
lockedSearchSignature === activeSearchSignature,
[submitLockedUntil, now, lockedSearchSignature, activeSearchSignature],
);
// Swap the Calendar input's display text to "Сегодня" / "Завтра" per
@@ -301,6 +361,8 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
date: initialDate,
tab: initialTab,
flightNumber: initialFlightNumber,
timeFrom: initialTimeFrom,
timeTo: initialTimeTo,
});
useEffect(() => {
const prev = lastInitialRef.current;
@@ -322,14 +384,30 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
if (prev.flightNumber !== initialFlightNumber) {
setFlightNumber(initialFlightNumber ?? "");
}
if (prev.timeFrom !== initialTimeFrom || prev.timeTo !== initialTimeTo) {
setTimeRange([
hhmmToMinutes(initialTimeFrom, 0),
hhmmToMinutes(initialTimeTo, 1440),
]);
}
lastInitialRef.current = {
departure: initialDeparture,
arrival: initialArrival,
date: initialDate,
tab: initialTab,
flightNumber: initialFlightNumber,
timeFrom: initialTimeFrom,
timeTo: initialTimeTo,
};
}, [initialDeparture, initialArrival, initialDate, initialTab, initialFlightNumber]);
}, [
initialDeparture,
initialArrival,
initialDate,
initialTab,
initialFlightNumber,
initialTimeFrom,
initialTimeTo,
]);
const handleTabClick = useCallback((tab: AccordionTab) => {
setActiveTab((prev) => (prev === tab ? prev : tab));
@@ -354,14 +432,13 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
// the placeholder ДД.ММ.ГГГГ is left untouched.
const dateParam = dateToYyyymmdd(flightDate ?? new Date());
const carrier = "SU";
// TZ §4.1.9.3: zero-pad to 4 digits (38 → 0038, 383 → 0383).
const num = padFlightNumber(flightNumber);
if (!num) return;
const normalized = normalizeFlightNumberInput(flightNumber);
if (!normalized) return;
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "flight-number",
flightNumber: `${carrier}${num}`,
flightNumber: `${carrier}${normalized.flightNumber}${normalized.suffix ?? ""}`,
date: dateParam,
timeFrom: "0000",
timeTo: "2400",
@@ -369,13 +446,28 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
});
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
setLockedSearchSignature(flightSearchSignature);
setSubmitLockedUntil(Date.now() + 30_000);
setNow(Date.now());
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
const urlParams = normalized.suffix
? {
type: "flight" as const,
carrier,
flightNumber: normalized.flightNumber,
suffix: normalized.suffix,
date: dateParam,
}
: {
type: "flight" as const,
carrier,
flightNumber: normalized.flightNumber,
date: dateParam,
};
const url = buildOnlineBoardUrl(urlParams);
void navigate(`/${locale}/${url}`);
},
[flightNumber, flightDate, navigate, locale, isSubmitLocked],
[flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature],
);
const handleRouteSubmit = useCallback(
@@ -448,11 +540,12 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras });
}
// Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable)
setLockedSearchSignature(routeSearchSignature);
setSubmitLockedUntil(Date.now() + 30_000);
setNow(Date.now());
void navigate(`/${locale}/${url}`);
},
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked],
[routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked, routeSearchSignature],
);
return (
@@ -516,6 +609,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
setFlightNumber(e.target.value);
if (flightNumberError) setFlightNumberError(null);
}}
onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))}
data-testid="flight-number-input"
/>
{flightNumber && (
@@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({
PageTabs: () => <div data-testid="page-tabs" />,
}));
let capturedFilterProps: Record<string, unknown> | undefined;
vi.mock("./OnlineBoardFilter.js", () => ({
OnlineBoardFilter: () => <div data-testid="online-board-filter" />,
OnlineBoardFilter: (props: Record<string, unknown>) => {
capturedFilterProps = props;
return <div data-testid="online-board-filter" />;
},
}));
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
@@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
useFeatureFlag: () => false,
}));
let capturedSearchParams: Record<string, unknown> | undefined;
vi.mock("../hooks/useOnlineBoard.js", () => ({
useOnlineBoard: () => ({
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
}),
useOnlineBoard: (params: Record<string, unknown>) => {
capturedSearchParams = params;
return {
flights: [],
loading: false,
error: null,
refresh: vi.fn(),
};
},
}));
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
@@ -143,6 +151,8 @@ describe("OnlineBoardSearchPage", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedInitialCurrentFlightId = undefined;
capturedFilterProps = undefined;
capturedSearchParams = undefined;
});
afterEach(() => {
@@ -176,6 +186,23 @@ describe("OnlineBoardSearchPage", () => {
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
it("TIRREDESIGN-13: preserves suffix in flight API params and filter value", () => {
render(
<OnlineBoardSearchPage
params={{
type: "flight",
carrier: "SU",
flightNumber: "0100",
suffix: "D",
date: "20250115",
}}
/>,
);
expect(capturedSearchParams?.flightNumber).toBe("SU0100D");
expect(capturedFilterProps?.initialFlightNumber).toBe("0100D");
});
it("renders for route search type", () => {
render(
<OnlineBoardSearchPage
@@ -32,18 +32,22 @@ 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";
import { sortFlights } from "../sortFlights.js";
import { getFlightSearchDate } from "../flightSearchDate.js";
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";
import type { FlightRequestType, ISimpleFlight } from "../types.js";
import { boardWindowBounds } from "@/shared/dateWindow.js";
export interface OnlineBoardSearchPageProps {
/** Parsed and validated URL params from the route */
@@ -64,6 +68,13 @@ function formatDateForApi(yyyymmdd: string): string {
return yyyymmdd.includes("T") ? yyyymmdd : `${yyyymmdd}T00:00:00`;
}
function formatDateOnlyForApi(value: Date): string {
const y = value.getFullYear().toString();
const m = (value.getMonth() + 1).toString().padStart(2, "0");
const d = value.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Return the yyyyMMdd date one day after `yyyymmdd`. The API treats the
* board range as a half-open interval: `dateFrom=D, dateTo=D` yields zero
@@ -106,7 +117,7 @@ function toSearchParams(
switch (params.type) {
case "flight":
base.flightNumber = `${params.carrier}${params.flightNumber}`;
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
break;
case "departure":
base.departure = params.station;
@@ -133,29 +144,24 @@ function toSearchParams(
/**
* Convert parsed params into calendar API params.
*
* TIRREDESIGN-8: the 31-day availability bitmask is always anchored to
* today (Angular parity — `updateCalendar()` builds `date = new Date();
* date.setDate(date.getDate()-1)` regardless of the URL-selected day).
* Using `params.date` as the anchor shifts the window forward as the
* user navigates and causes DayTabs inside the -1/+14 range to fall
* outside the returned bitmask, grey-listing them even when flights run
* that day.
* TIRREDESIGN-12: the 31-day availability bitmask is anchored to the
* first board-calendar date (today - 1), matching Angular's
* `updateCalendar()` (`date = new Date(); date.setDate(date.getDate()-1)`).
* Using the URL-selected date as the anchor shifts the window as the
* user navigates and corrupts disabled-day parity.
*/
function toCalendarParams(
params: OnlineBoardSearchPageProps["params"],
): CalendarParams {
const today = new Date();
const y = today.getFullYear().toString();
const m = (today.getMonth() + 1).toString().padStart(2, "0");
const d = today.getDate().toString().padStart(2, "0");
const [boardMinDate] = boardWindowBounds();
const base: CalendarParams = {
date: `${y}-${m}-${d}T00:00:00`,
date: formatDateOnlyForApi(boardMinDate),
searchType: params.type as FlightRequestType,
};
switch (params.type) {
case "flight":
base.flightNumber = `${params.carrier}${params.flightNumber}`;
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
break;
case "departure":
base.departure = params.station;
@@ -225,7 +231,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
: params.type === "arrival" ? params.station
: undefined;
const flightNumber = isFlightNumber
? `${params.carrier} ${params.flightNumber}`
? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}`
: undefined;
const labelParts: string[] = [];
if (departure) labelParts.push(departure);
@@ -252,6 +258,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
: undefined,
params.type === "flight" ? params.carrier : undefined,
params.type === "flight" ? params.flightNumber : undefined,
params.type === "flight" ? params.suffix : undefined,
params.date,
]);
@@ -291,7 +298,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
if (dateLabel) searchHeading += `, ${dateLabel}`;
break;
case "flight":
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`;
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
if (dateLabel) searchHeading += `, ${dateLabel}`;
break;
default:
@@ -370,7 +377,9 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
liveBoardParams,
flights,
refresh,
);
const isStale = useStaleDataTimers();
// Calendar days
const calendarParams = toCalendarParams(params);
@@ -385,13 +394,13 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
carrier: flight.flightId.carrier,
flightNumber: flight.flightId.flightNumber,
suffix: flight.flightId.suffix,
date: flight.flightId.date,
date: getFlightSearchDate(flight),
}
: {
type: "details",
carrier: flight.flightId.carrier,
flightNumber: flight.flightId.flightNumber,
date: flight.flightId.date,
date: getFlightSearchDate(flight),
};
const detailsUrl = buildOnlineBoardUrl(detailsParams);
void navigate(`/${locale}/${detailsUrl}`);
@@ -457,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={
@@ -499,7 +511,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
}
: params.type === "flight"
? {
initialFlightNumber: params.flightNumber,
initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
initialDate: params.date,
initialTab: "flight" as const,
}
@@ -616,7 +628,6 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
<FlightActions
flight={flight}
locale={language}
showShare={false}
/>
)}
/>
@@ -17,6 +17,7 @@ import {
setBoardFilter,
type ScheduleFilterSnapshot,
} from "@/shared/state/crossSectionNavigation.js";
import { sessionStore } from "@/shared/storage.js";
// ---------------------------------------------------------------------------
// Hook mocks for geo + viewport — controlled per test
@@ -63,6 +64,12 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
>
Popular
</button>
<button
data-testid="popular-click-schedule-route"
onClick={() => onRequestClick?.({ mode: "Route", departure: "MOW", arrival: "MMK", type: "Schedule" })}
>
Schedule Route
</button>
</div>
),
}));
@@ -77,6 +84,7 @@ vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
getCityCodeByAirportCode: () => undefined,
}));
vi.mock("../hooks/useCalendarDays.js", () => ({
@@ -163,6 +171,7 @@ describe("buildOnlineBoardPrefillState", () => {
describe("OnlineBoardStartPage", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStore.clear();
});
it("renders start page with page layout structure", () => {
@@ -264,6 +273,25 @@ describe("OnlineBoardStartPage", () => {
// Same-page click — the filter remounts via key bump, no nav.
expect(mockNavigate).not.toHaveBeenCalled();
});
it("TIRREDESIGN-9: schedule Route popular click opens Schedule with prefilled data", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 15, 12, 0, 0));
try {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByTestId("popular-click-schedule-route"));
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
expect(JSON.parse(sessionStore.getRaw("afl-prefill:schedule") ?? "{}")).toEqual({
departure: "MOW",
arrival: "MMK",
withReturn: false,
dateFrom: "20260514",
dateTo: "20260517",
});
} finally {
vi.useRealTimers();
}
});
});
// ---------------------------------------------------------------------------
@@ -244,16 +244,10 @@ export const OnlineBoardStartPage: FC<OnlineBoardStartPageProps> = ({ today }) =
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
// Schedule-type requests navigate to the schedule feature. City
// codes come off the popular-request API as airport codes in some
// cases (SVO/LED instead of MOW/LED); resolve them to owning city
// so the destination form pre-fills with a city rather than an
// airport pin.
// Schedule-type requests open the Schedule start page with the form
// prefilled from the clicked popular item, matching Angular's shared
// filter-state behavior.
if (request.type === "Schedule") {
// TZ §4.1.5: Schedule-bound popular clicks prefill the date range
// to the current ISO week (Mon-Sun); round-trip additionally sets
// the return range to the following ISO week. Without these the
// Schedule calendar renders empty until submit.
const state: Record<string, unknown> =
request.mode === "Route" || request.mode === "RouteWithBack"
? (() => {
@@ -0,0 +1,11 @@
.stale-data-overlay {
position: fixed;
top: 0;
left: 0;
text-align: center;
padding-top: 20%;
width: 100%;
height: 100%;
background-color: rgb(255 255 255 / 70%);
z-index: 9999;
}
@@ -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>
);
@@ -67,22 +67,12 @@ describe("AircraftPanel", () => {
expect(screen.getByTestId("aircraft-panel")).toBeTruthy();
});
// §4.1.15.4: tail number (registration mark) in aircraft panel
it("4.1.15.4-TailNumber: renders tail number when aircraft.registration is present", () => {
it("TIRREDESIGN-29: omits tail number when aircraft.registration is present", () => {
const eq: IEquipmentFull = {
aircraft: { actual: { title: "Airbus A320", registration: "VP-BQS" } },
};
render(<AircraftPanel equipment={eq} />);
expect(screen.getByText("VP-BQS")).toBeTruthy();
// label key is AIRPLANE.TAIL-NUMBER (t mock returns key)
expect(screen.getByText("AIRPLANE.TAIL-NUMBER")).toBeTruthy();
});
it("4.1.15.4-TailNumber-Absent: omits tail-number row when registration is not present", () => {
const eq: IEquipmentFull = {
aircraft: { actual: { title: "Airbus A320" } },
};
render(<AircraftPanel equipment={eq} />);
expect(screen.queryByText("VP-BQS")).toBeNull();
expect(screen.queryByText("AIRPLANE.TAIL-NUMBER")).toBeNull();
});
@@ -112,11 +112,6 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({
className?: string;
}> = [];
if (aircraft?.name) props.push({ label: t("AIRPLANE.NAME"), value: aircraft.name });
// §4.1.15.4: tail number (registration mark, e.g. "VP-BQS") from the
// aircraft.actual.registration field. Shown when present.
if (aircraft?.registration) {
props.push({ label: t("AIRPLANE.TAIL-NUMBER"), value: aircraft.registration });
}
if (total > 0) props.push({ label: t("AIRPLANE.SEATS-TOTAL"), value: total });
if (economy > 0) props.push({ label: t("AIRPLANE.SEATS-ECONOMY"), value: economy });
if (comfort > 0) props.push({ label: t("AIRPLANE.SEATS-COMFORT"), value: comfort });
@@ -113,6 +113,16 @@
word-break: break-word;
}
&__subtitle-link {
color: inherit;
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
// Status label (Закончена / Идет / Ожидается). Angular sizes it 16px
// and uses Aeroflot red (#C8102E) for the 'Finished' state.
&__status {
@@ -112,6 +112,27 @@ describe("FlightDetailsAccordion", () => {
expect(screen.getByText("Airbus A320")).toBeTruthy();
});
it("TIRREDESIGN-24: renders aircraft title as an external plane-park link", () => {
const leg = makeLeg({
equipment: { name: "A320", aircraft: { actual: { title: "Airbus A320" } } },
});
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" locale="ru-ru" />);
const link = screen.getByRole("link", { name: "Airbus A320" });
expect(link.getAttribute("href")).toBe("http://www.aeroflot.ru/cms/ru/flight/plane_park");
expect(link.getAttribute("target")).toBe("_blank");
});
it("TIRREDESIGN-24: uses locale language in the plane-park link", () => {
const leg = makeLeg({
equipment: { name: "A320", aircraft: { scheduled: { title: "Airbus A320" } } },
});
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" locale="en-us" />);
expect(screen.getByRole("link", { name: "Airbus A320" }).getAttribute("href"))
.toBe("http://www.aeroflot.ru/cms/en/flight/plane_park");
});
it("renders meal tab when equipment.meal has items", () => {
const leg = makeLeg({
equipment: { name: "A320", meal: [{ type: "Economy" }] },
@@ -48,6 +48,11 @@ interface RowDef {
isTransition?: boolean;
}
function aircraftParkHref(locale: string | undefined): string {
const language = locale?.split("-")[0] || "ru";
return `http://www.aeroflot.ru/cms/${language}/flight/plane_park`;
}
// Registration — person with a badge/ID on the chest, mirroring Angular's
// sprite #service icon (check-in agent silhouette).
const ICON_REGISTRATION: JSX.Element = (
@@ -210,7 +215,16 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
id: "aircraft",
icon: ICON_AIRCRAFT,
title: t("SHARED.PLANE"),
subtitle: title,
subtitle: title ? (
<a
className="details-row__subtitle-link"
href={aircraftParkHref(locale)}
target="_blank"
rel="noreferrer"
>
{title}
</a>
) : null,
body: (
<AircraftPanel
equipment={leg.equipment}
@@ -27,18 +27,19 @@ describe("shouldShowTransition", () => {
expect(shouldShowTransition(undefined, "Scheduled", "Onlineboard")).toBe(false);
});
// TIRREDESIGN-7: the rule now reads the API's isActual flag rather
// than inferring from status, so a transition marked inactive hides
// even if its status has left Scheduled (e.g. a cancelled boarding
// phase that hasn't been cleared to Scheduled).
it("returns false when item.isActual is false", () => {
const inactive = { ...validItem, isActual: false };
expect(shouldShowTransition(inactive, "Scheduled", "Onlineboard")).toBe(false);
it("returns false when transition status is Scheduled", () => {
const scheduled = { ...validItem, status: "Scheduled" as const };
expect(shouldShowTransition(scheduled, "Scheduled", "Onlineboard")).toBe(false);
});
it("returns true when item.isActual is true on Onlineboard", () => {
it("returns true when transition status has left Scheduled on Onlineboard", () => {
expect(shouldShowTransition(validItem, "Scheduled", "Onlineboard")).toBe(true);
});
it("ignores isActual on the full details page like Angular", () => {
const inactiveButInProgress = { ...validItem, isActual: false };
expect(shouldShowTransition(inactiveButInProgress, "Scheduled", "Onlineboard")).toBe(true);
});
});
describe("shouldShowAircraft", () => {
@@ -8,14 +8,11 @@ import type {
export type DetailsViewType = "Onlineboard" | "Schedule";
/**
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts,
* tightened for TIRREDESIGN-7 to use the payload's `isActual` flag. The
* API sets `isActual=true` precisely when a transition block is in its
* current operational phase — the backend computes that from status +
* clocked times, so consumers shouldn't rediscover it locally.
* Matches Angular's `showBoardProperty` in flight-details-wrapper.component.ts.
*
* Transition panels remain hidden for Schedule mode or Cancelled legs
* regardless of the flag (those contexts never show transition detail).
* Full online-board details hide only scheduled transition blocks. This differs
* from the inline board row body, where Angular gates registration/boarding/
* deboarding on `isActual`.
*/
export function shouldShowTransition(
item: IFlightTransitionItem | undefined,
@@ -25,7 +22,7 @@ export function shouldShowTransition(
if (viewType === "Schedule") return false;
if (legStatus === "Cancelled") return false;
if (!item) return false;
return item.isActual === true;
return item.status !== "Scheduled";
}
/**
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { getFlightSearchDate } from "./flightSearchDate.js";
import type { IDirectFlight, IMultiLegFlight, IFlightLeg } from "./types.js";
function leg(local: string): IFlightLeg {
return {
arrival: {
scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local,
localTime: "",
tzOffset: 0,
utc: "",
},
},
},
dayChange: 0,
departure: {
scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
checkingStatus: "Scheduled",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local,
localTime: "",
tzOffset: 0,
utc: "",
},
},
},
equipment: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
flyingTime: "",
index: 0,
operatingBy: {},
status: "Scheduled",
updated: "",
};
}
describe("getFlightSearchDate", () => {
it("uses scheduled local departure date instead of backend flightId.date", () => {
const flight: IDirectFlight = {
id: "SU6951",
routeType: "Direct",
flyingTime: "",
operatingBy: {},
status: "Arrived",
flightId: {
carrier: "SU",
flightNumber: "6951",
suffix: "",
date: "2026-05-04",
dateLT: "2026-05-05",
},
leg: leg("2026-05-05T00:30:00+03:00"),
};
expect(getFlightSearchDate(flight)).toBe("20260505");
});
it("uses the first leg for multi-leg flights", () => {
const flight: IMultiLegFlight = {
id: "SU100",
routeType: "MultiLeg",
flyingTime: "",
operatingBy: {},
status: "Scheduled",
flightId: {
carrier: "SU",
flightNumber: "0100",
suffix: "",
date: "20260504",
},
legs: [
leg("2026-05-05T23:30:00+03:00"),
leg("2026-05-06T01:30:00+03:00"),
],
};
expect(getFlightSearchDate(flight)).toBe("20260505");
});
it("falls back to dateLT and then flightId.date when leg date is unavailable", () => {
const flight: IDirectFlight = {
id: "SU42",
routeType: "Direct",
flyingTime: "",
operatingBy: {},
status: "Scheduled",
flightId: {
carrier: "SU",
flightNumber: "0042",
suffix: "",
date: "2026-05-04",
dateLT: "2026-05-05",
},
leg: leg("10:00"),
};
expect(getFlightSearchDate(flight)).toBe("20260505");
const { dateLT: _dateLT, ...flightIdWithoutDateLt } = flight.flightId;
const withoutDateLt: IDirectFlight = {
...flight,
flightId: flightIdWithoutDateLt,
};
expect(getFlightSearchDate(withoutDateLt)).toBe("20260504");
});
});
@@ -0,0 +1,34 @@
import type { IFlightLeg, ISimpleFlight } from "./types.js";
function compactDate(value: string | undefined): string | null {
if (!value) return null;
if (/^\d{8}$/.test(value)) return value;
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (!match) return null;
return `${match[1]}${match[2]}${match[3]}`;
}
function getFirstLeg(flight: ISimpleFlight): IFlightLeg | null {
if (flight.routeType === "Direct") return flight.leg;
return flight.legs[0] ?? null;
}
/**
* Angular's FlightDateUtils.getFlightDate derives the details URL date from
* the first leg's scheduled local departure date, not from flightId.date.
* Overnight flights can have flightId.date set to the previous backend
* service day while dateLT / scheduled local departure belongs to the board
* day the user clicked.
*/
export function getFlightSearchDate(flight: ISimpleFlight): string {
const firstLeg = getFirstLeg(flight);
return (
compactDate(firstLeg?.departure.times.scheduledDeparture.local) ??
compactDate(flight.flightId.dateLT) ??
compactDate(flight.flightId.date) ??
flight.flightId.date.replace(/-/g, "")
);
}
@@ -15,6 +15,7 @@ import type { CalendarParams } from "../api.js";
export interface UseCalendarDaysResult {
days: string[];
loading: boolean;
loaded: boolean;
}
/**
@@ -30,28 +31,33 @@ export function useCalendarDays(
const client = useApiClient();
const [days, setDays] = useState<string[]>([]);
const [loading, setLoading] = useState(Boolean(params));
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!params) {
setDays([]);
setLoading(false);
setLoaded(false);
return;
}
let cancelled = false;
setLoading(true);
setLoaded(false);
getCalendarDays(client, params)
.then((result) => {
if (!cancelled) {
setDays(result);
setLoading(false);
setLoaded(true);
}
})
.catch(() => {
if (!cancelled) {
setDays([]);
setLoading(false);
setLoaded(false);
}
});
@@ -67,5 +73,5 @@ export function useCalendarDays(
params?.arrival,
]);
return { days, loading };
return { days, loading, loaded };
}
@@ -8,7 +8,7 @@
* @module
*/
import { useState, useEffect, useRef } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
import { useApiClient } from "@/shared/api/provider.js";
import { getFlightDetails } from "../api.js";
import type { FlightDetailsParams } from "../api.js";
@@ -21,6 +21,7 @@ export interface UseFlightDetailsResult {
daysOfFlight: string[];
loading: boolean;
error: ApiError | null;
refresh: () => void;
}
/**
@@ -35,10 +36,15 @@ export function useFlightDetails(
const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiError | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const paramsRef = useRef(params);
paramsRef.current = params;
const refresh = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -62,7 +68,7 @@ export function useFlightDetails(
return () => {
cancelled = true;
};
}, [client, params.flights, params.dates]);
}, [client, params.flights, params.dates, refreshKey]);
return {
flight: allFlights[0] ?? null,
@@ -70,5 +76,6 @@ export function useFlightDetails(
daysOfFlight,
loading,
error,
refresh,
};
}
@@ -30,6 +30,7 @@ function createMockHub() {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? [];
handlers[method] = list;
@@ -179,7 +180,7 @@ describe("useLiveBoardSearch", () => {
expect(result.current.flights).toEqual(initial);
});
it("subscribes to the correct channel", async () => {
it("subscribes to Angular TrackerHub refresh and invokes SubscribeDate", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = {
date: "20250115",
@@ -193,17 +194,19 @@ describe("useLiveBoardSearch", () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith(
"board:20250115:SVO:LED",
expect.any(Function),
expect(hub.on).toHaveBeenCalledWith("RefreshDate", expect.any(Function));
expect(hub.invoke).toHaveBeenCalledWith(
"SubscribeDate",
"20250115",
"SVO",
"LED",
);
});
it("updates flights when a SignalR message arrives", async () => {
it("updates flights when a legacy array message arrives", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { date: "20250115" };
const initial = [makeFlight("f1")];
const channel = "board:20250115::";
const { result } = renderHook(() =>
useLiveBoardSearch(params, initial),
@@ -215,12 +218,30 @@ describe("useLiveBoardSearch", () => {
const updated = [makeFlight("f2")];
act(() => {
hub._simulateMessage(channel, updated);
hub._simulateMessage("RefreshDate", updated);
});
expect(result.current.flights).toEqual(updated);
});
it("refreshes API data when TrackerHub sends RefreshDate", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { date: "20250115" };
const onRefresh = vi.fn();
renderHook(() => useLiveBoardSearch(params, [], onRefresh));
await act(async () => {
await vi.runAllTimersAsync();
});
act(() => {
hub._simulateMessage("RefreshDate", "20250115");
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation
@@ -49,13 +49,24 @@ export function buildBoardChannelKey(params: LiveBoardSearchParams): string {
export function useLiveBoardSearch(
params: LiveBoardSearchParams,
initialFlights: ISimpleFlight[],
onRefresh?: () => void,
): UseLiveBoardSearchResult {
const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>(
() => ({
hubUrl: getEnv().SIGNALR_HUB_URL,
channelKey: buildBoardChannelKey,
subscription: {
eventName: "RefreshDate",
invokeMethodName: "SubscribeDate",
args: (p) => [p.date, p.departure, p.arrival],
},
onMessage: (message) => {
if (Array.isArray(message)) return message;
onRefresh?.();
return undefined;
},
}),
[],
[onRefresh],
);
const { data, connectionStatus } = useLiveFlights<
@@ -9,6 +9,7 @@ import {
} from "@/shared/signalr/connection.js";
import {
buildFlightChannelKey,
buildTrackerFlightSubscriptionKey,
useLiveFlightDetails,
} from "./useLiveFlightDetails.js";
import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js";
@@ -32,6 +33,7 @@ function createMockHub() {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? [];
handlers[method] = list;
@@ -160,6 +162,33 @@ describe("buildFlightChannelKey", () => {
});
});
describe("buildTrackerFlightSubscriptionKey", () => {
it("uses compact URL date when dateLT is unavailable", () => {
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "20250115",
};
expect(buildTrackerFlightSubscriptionKey(id)).toBe("SU100A@20250115");
});
it("uses API dateLT as-is when available", () => {
const id: IFlightId = {
carrier: "SU",
flightNumber: "0100",
suffix: "",
date: "2025-01-15",
dateLT: "2025-01-15T00:00:00",
};
expect(buildTrackerFlightSubscriptionKey(id)).toBe(
"SU0100@2025-01-15T00:00:00",
);
});
});
describe("useLiveFlightDetails", () => {
const HUB_URL = "https://hub.test/tracker";
@@ -211,13 +240,30 @@ describe("useLiveFlightDetails", () => {
expect(result.current.flight).toBeNull();
});
it("subscribes to the correct channel", async () => {
it("does not subscribe before the API flight id is available", async () => {
const { hub, conn } = installMockHub(HUB_URL);
const subscribeSpy = vi.spyOn(conn, "subscribe");
const { result } = renderHook(() =>
useLiveFlightDetails(null, null),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.connectionStatus).toBe("idle");
expect(subscribeSpy).not.toHaveBeenCalled();
expect(hub.invoke).not.toHaveBeenCalled();
});
it("subscribes to Angular TrackerHub refresh and invokes Subscribe", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "2025-01-15",
date: "20250115",
};
renderHook(() => useLiveFlightDetails(id, null));
@@ -226,20 +272,17 @@ describe("useLiveFlightDetails", () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith(
"flight:SU100A@2025-01-15",
expect.any(Function),
);
expect(hub.on).toHaveBeenCalledWith("Refresh", expect.any(Function));
expect(hub.invoke).toHaveBeenCalledWith("Subscribe", "SU100A@20250115");
});
it("updates flight when a SignalR message arrives", async () => {
it("updates flight when a legacy array message arrives", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "2025-01-15",
};
const channel = "flight:SU100@2025-01-15";
const initial = makeFlight("f1");
const { result } = renderHook(() =>
@@ -252,13 +295,35 @@ describe("useLiveFlightDetails", () => {
const updated = [makeFlight("f2")];
act(() => {
hub._simulateMessage(channel, updated);
hub._simulateMessage("Refresh", updated);
});
// useLiveFlights replaces the full array; our hook takes [0]
expect(result.current.flight).toEqual(updated[0]);
});
it("refreshes API data when TrackerHub sends Refresh", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "20250115",
};
const onRefresh = vi.fn();
renderHook(() => useLiveFlightDetails(id, null, onRefresh));
await act(async () => {
await vi.runAllTimersAsync();
});
act(() => {
hub._simulateMessage("Refresh", "SU0100@20250115");
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation
@@ -16,7 +16,7 @@ import {
type UseLiveFlightsConfig,
} from "@/shared/hooks/useLiveFlights.js";
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
import type { ISimpleFlight, IParsedFlightId } from "../types.js";
import type { ISimpleFlight, IFlightId, IParsedFlightId } from "../types.js";
import { getEnv } from "@/env/index.js";
// ---------------------------------------------------------------------------
@@ -32,24 +32,51 @@ export interface UseLiveFlightDetailsResult {
// Channel key builder (exported for testing)
// ---------------------------------------------------------------------------
export function buildFlightChannelKey(id: IParsedFlightId): string {
type LiveFlightId = IParsedFlightId | IFlightId;
const DISABLED_ID: LiveFlightId = {
carrier: "",
flightNumber: "",
suffix: "",
date: "",
};
export function buildFlightChannelKey(id: LiveFlightId): string {
return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`;
}
export function buildTrackerFlightSubscriptionKey(id: LiveFlightId): string {
const date = "dateLT" in id && id.dateLT ? id.dateLT : id.date.replace(/-/g, "");
return `${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${date}`;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useLiveFlightDetails(
id: IParsedFlightId,
id: LiveFlightId | null,
initialFlight: ISimpleFlight | null,
onRefresh?: () => void,
): UseLiveFlightDetailsResult {
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>(
const env = getEnv();
const effectiveId = id ?? DISABLED_ID;
const config = useMemo<UseLiveFlightsConfig<LiveFlightId>>(
() => ({
hubUrl: getEnv().SIGNALR_HUB_URL,
hubUrl: id ? env.SIGNALR_HUB_URL : "",
channelKey: buildFlightChannelKey,
subscription: {
eventName: "Refresh",
invokeMethodName: "Subscribe",
args: (flightId) => [buildTrackerFlightSubscriptionKey(flightId)],
},
onMessage: (message) => {
if (Array.isArray(message)) return message;
onRefresh?.();
return undefined;
},
}),
[],
[env.SIGNALR_HUB_URL, id, onRefresh],
);
// useLiveFlights expects an array — wrap/unwrap the single flight
@@ -59,9 +86,9 @@ export function useLiveFlightDetails(
);
const { data, connectionStatus } = useLiveFlights<
IParsedFlightId,
LiveFlightId,
ISimpleFlight
>(id, initialData, config);
>(effectiveId, initialData, config);
const flight = data[0] ?? null;
@@ -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;
}
@@ -94,6 +94,14 @@ describe("buildFlightJsonLd", () => {
expect(result.flightNumber).toBe("SU0100");
});
it("TIRREDESIGN-13: includes suffix in flightNumber", () => {
const flight = makeDirectFlight({ carrier: "SU", flightNumber: "0038" });
flight.flightId.suffix = "D";
const result = buildFlightJsonLd(flight);
expect(result.flightNumber).toBe("SU0038D");
});
it("maps departure airport", () => {
const flight = makeDirectFlight({ depCode: "SVO", depAirport: "Sheremetyevo" });
const result = buildFlightJsonLd(flight);
+2 -2
View File
@@ -48,11 +48,11 @@ export function buildFlightJsonLd(flight: ISimpleFlight): Flight {
const firstLeg = getFirstLeg(flight);
const lastLeg = getLastLeg(flight);
const { carrier, flightNumber } = flight.flightId;
const { carrier, flightNumber, suffix } = flight.flightId;
const result: Flight = {
"@type": "Flight",
flightNumber: `${carrier}${flightNumber}`,
flightNumber: `${carrier}${flightNumber}${suffix ?? ""}`,
provider: {
"@type": "Airline",
name: "Aeroflot",
+1 -1
View File
@@ -145,7 +145,7 @@ export async function getScheduleCalendarDays(
* Handles two shapes:
* - legacy comma-separated ("2025-01-01,2025-01-02,…")
* - bitmask ("1111000…") where each position maps to a day starting from
* `baseDate - 1`. This is what the upstream actually returns today.
* the requested base date. This is what the upstream actually returns today.
*/
function parseCalendarDays(days: string, baseDate: string): string[] {
if (!days) return [];
@@ -278,6 +278,32 @@ describe("schedule search results body — action strip", () => {
});
});
describe("schedule search results — inline buy link", () => {
it("threads buyUrlFor into grouped multi-day FlightList rows", () => {
const firstDay = makeDirectFlight({
depLocal: "2026-05-06T08:00:00+03:00",
arrLocal: "2026-05-06T10:30:00+03:00",
});
const secondDay = makeDirectFlight({
depLocal: "2026-05-07T14:00:00+03:00",
arrLocal: "2026-05-07T16:30:00+03:00",
});
render(
<DayGroupedFlightList
flights={[
{ ...firstDay, id: "f-first-day" },
{ ...secondDay, id: "f-second-day" },
] as ISimpleFlight[]}
buyUrlFor={(flight) => `https://example.test/buy/${flight.id}`}
/>,
);
const firstBuyLink = screen.getByTestId("flight-card-buy-link");
expect(firstBuyLink.getAttribute("href")).toBe("https://example.test/buy/f-first-day");
});
});
// ---------------------------------------------------------------------------
// §4.1.14.3 — DayGroupedFlightList grouping + day headers
// ---------------------------------------------------------------------------
@@ -363,6 +363,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(buyUrlFor ? { buyUrlFor } : {})}
{...(resolvedInitialFlightId
? { initialCurrentFlightId: resolvedInitialFlightId }
: {})}
@@ -60,8 +60,14 @@ vi.mock("@/i18n/resolver.js", () => ({
}));
// Mutable for individual test overrides
let mockScheduleDetailsResult: { flights: unknown[]; loading: boolean; error: unknown } = {
let mockScheduleDetailsResult: {
flights: unknown[];
daysOfFlight: string[];
loading: boolean;
error: unknown;
} = {
flights: [],
daysOfFlight: [],
loading: true,
error: null,
};
@@ -145,7 +151,9 @@ vi.mock("@/features/online-board/components/FlightsMiniList/index.js", () => ({
}));
vi.mock("@/features/online-board/components/DayTabs/index.js", () => ({
DayTabs: () => <div data-testid="day-tabs" />,
DayTabs: ({ availableDates }: { availableDates: string[] }) => (
<div data-testid="day-tabs" data-available-dates={availableDates.join(",")} />
),
}));
vi.mock("@/ui/flights/FlightCard.js", () => ({
@@ -187,7 +195,7 @@ const flightId: IScheduleFlightId = {
describe("ScheduleDetailsPage breadcrumbs", () => {
beforeEach(() => {
mockSearchParamsGet = () => null;
mockScheduleDetailsResult = { flights: [], loading: true, error: null };
mockScheduleDetailsResult = { flights: [], daysOfFlight: [], loading: true, error: null };
});
it("shows 1-item trail when no ?request= param (share-link)", () => {
@@ -253,7 +261,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
beforeEach(() => {
mockSearchParamsGet = () => null;
// Reset to loading state (breadcrumb tests rely on this)
mockScheduleDetailsResult = { flights: [], loading: true, error: null };
mockScheduleDetailsResult = { flights: [], daysOfFlight: [], loading: true, error: null };
// The new ScheduleFlightsMiniList scrolls the highlighted row into
// view on mount; JSDOM doesn't ship `scrollIntoView`, so stub it.
Element.prototype.scrollIntoView = vi.fn();
@@ -288,6 +296,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
},
},
],
daysOfFlight: ["20260515", "20260516"],
loading: false,
error: null,
};
@@ -333,6 +342,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
},
},
],
daysOfFlight: ["20260515", "20260516"],
loading: false,
error: null,
};
@@ -345,6 +355,9 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
);
// Success state renders DayTabs in stickyContent (Schedule window = +330 days)
expect(screen.getByTestId("day-tabs")).toBeTruthy();
expect(screen.getByTestId("day-tabs").getAttribute("data-available-dates")).toBe(
"20260515,20260516",
);
});
it("4.1.16.1-R4: back link navigates to scheduleHref (success state)", () => {
@@ -377,6 +390,7 @@ describe("ScheduleDetailsPage structure (§4.1.16.1 + §4.1.16.2 + §4.1.16.3)",
},
},
],
daysOfFlight: ["20260515", "20260516"],
loading: false,
error: null,
};
@@ -12,7 +12,6 @@ import type { FC } from "react";
import { Fragment, useCallback, useMemo } from "react";
import { Link, useNavigate, useSearchParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { localeToLanguage, normalizeLocaleParam, DEFAULT_LANGUAGE } from "@/i18n/resolver.js";
import { FlightCard } from "@/ui/flights/FlightCard.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { IFlyWarning } from "@/ui/flights/IFlyWarning.js";
@@ -20,6 +19,10 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import { buildScheduleUrl } from "../url.js";
import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
@@ -92,7 +95,7 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
arrival: "",
};
const { flights, loading, error } = useScheduleDetails(detailsParams);
const { flights, daysOfFlight, loading, error } = useScheduleDetails(detailsParams);
const scheduleHref = `/${locale}/schedule`;
@@ -235,10 +238,8 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
const selectedDate = flightIds[0]?.date ?? "";
// `Купить билет` link — navigates to Aeroflot's booking flow in a new
// tab. Mirrors BoardDetailsHeader's BuyTicketButton / Schedule search
// page. Returns null when we can't assemble the query (missing legs).
const language =
localeToLanguage(normalizeLocaleParam(locale) ?? "ru-ru") ?? DEFAULT_LANGUAGE;
// tab. Mirrors Angular's hardcoded ru-ru SB URL. Returns null when we
// can't assemble the query (missing legs).
const buyUrlFor = useCallback(
(flight: ISimpleFlight): string | null => {
const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs;
@@ -247,14 +248,13 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
if (!firstLeg || !lastLeg) return null;
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
const depDate = new Date(depUtc);
const yyyy = depDate.getFullYear().toString();
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
const dd = depDate.getDate().toString().padStart(2, "0");
return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
},
[language],
[],
);
const backLink = (
<Link
@@ -382,7 +382,7 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
// TZ §4.1.16.3 R22-R28: day tabs (Schedule window: [-1, +330] from today)
<DayTabs
selectedDate={selectedDate}
availableDates={[]}
availableDates={daysOfFlight}
daysBefore={scheduleSearchFrom}
daysAfter={scheduleSearchTo}
locale={locale}
@@ -473,7 +473,10 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
<FlightCard flight={flight} direction="schedule" />
<IFlyWarning flightNumber={flight.flightId.flightNumber} />
{renderBody(flight)}
<ScheduleLegDetails flight={flight as unknown as ISimpleFlight} />
<ScheduleLegDetails
flight={flight as unknown as ISimpleFlight}
locale={locale}
/>
{/* Angular hides the weekly operating schedule on
multi-leg chains; keep it on direct flights. */}
@@ -90,7 +90,10 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
CityAutocomplete: (props: Record<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
value={(props["value"] as string) ?? ""}
onChange={(e) => {
(props["onChange"] as (value: string) => void)(e.currentTarget.value);
}}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
@@ -467,4 +470,63 @@ describe("ScheduleFilter validation per TZ §4.1.9.4", () => {
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
});
it("clears stale return dates when return flights toggle changes", () => {
render(
<ScheduleFilter
initialDeparture="SVO"
initialArrival="LED"
initialDateFrom="20260601"
initialDateTo="20260607"
initialReturnFlights={true}
initialReturnDateFrom="20260608"
initialReturnDateTo="20260614"
/>,
);
expect(screen.queryByTestId("schedule-return-date-clear")).toBeTruthy();
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
fireEvent.click(screen.getByTestId("schedule-return-flights").querySelector("input")!);
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
});
});
describe("ScheduleFilter submit parity", () => {
beforeEach(() => {
vi.clearAllMocks();
scheduleCalendarMock.days = [];
scheduleCalendarMock.loaded = false;
scheduleCalendarMock.params = [];
_sliderOnChange = null;
_sliderOnChanges.length = 0;
});
it("allows immediate repeated submits like Angular", () => {
render(
<ScheduleFilter
initialDeparture="SVO"
initialArrival="LED"
initialDateFrom="20260601"
initialDateTo="20260607"
/>,
);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByTestId("schedule-arrival-input"), {
target: { value: "KUF" },
});
expect((screen.getByTestId("search-submit") as HTMLButtonElement).disabled).toBe(false);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).toHaveBeenCalledTimes(3);
expect(mockNavigate).toHaveBeenLastCalledWith(
"/ru-ru/schedule/route/SVO-KUF-20260601-20260607",
);
});
});
@@ -251,20 +251,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
);
// §4.1.11 — submit button locked for 30 seconds after each search.
// The 30-second constant is intentionally hardcoded (not configurable).
const [submitLockedUntil, setSubmitLockedUntil] = useState(0);
const [nowTs, setNowTs] = useState(() => Date.now());
useEffect(() => {
if (submitLockedUntil === 0 || nowTs >= submitLockedUntil) return;
const id = setTimeout(() => setNowTs(Date.now()), 1000);
return () => clearTimeout(id);
}, [submitLockedUntil, nowTs]);
const isSubmitLocked = useMemo(
() => submitLockedUntil > 0 && nowTs < submitLockedUntil,
[submitLockedUntil, nowTs],
);
// TZ §4.1.9.4 Table 16: when the outbound range moves forward such
// that the already-chosen return starts before the new outbound
// dateTo, blank the return picker and any coupled error so the user
@@ -366,7 +352,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isSubmitLocked) return;
const dep = departure.trim().toUpperCase();
const arr = arrival.trim().toUpperCase();
if (!dep || !arr) return;
@@ -489,9 +474,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
: {}),
searchExecuted: true,
});
// Lock submit for 30 seconds (§4.1.11 — hardcoded, not configurable)
setSubmitLockedUntil(Date.now() + 30_000);
setNowTs(Date.now());
void navigate(`/${locale}/${url}`);
},
[
@@ -505,7 +487,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
returnTimeRange,
navigate,
locale,
isSubmitLocked,
],
);
@@ -662,7 +643,11 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<input
type="checkbox"
checked={returnFlights}
onChange={(e) => setReturnFlights(e.target.checked)}
onChange={(e) => {
setReturnFlights(e.target.checked);
setReturnDateRange([null, null]);
setReturnBeforeOutboundError(null);
}}
/>
<span>{t("SHARED.RETURN_FLIGHT_VIEW")}</span>
</label>
@@ -765,8 +750,6 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
type="submit"
className="search-button"
data-testid="search-submit"
disabled={isSubmitLocked}
aria-disabled={isSubmitLocked}
>
<span>{t("SHARED.SCHEDULES_VIEW")}</span>
</button>
@@ -397,7 +397,6 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
<FlightActions
flight={flight}
locale={locale}
showShare
showBuy
showRegister
showStatus
@@ -61,7 +61,8 @@
border-bottom: none;
}
&--meals .schedule-leg-details__label {
&--meals .schedule-leg-details__label,
&--services .schedule-leg-details__label {
align-self: flex-start;
padding-top: 8px;
}
@@ -99,7 +100,8 @@
font-size: fonts.$font-size-m;
}
&__meals {
&__meals,
&__services {
display: flex;
align-items: flex-start;
gap: vars.$space-xl;
@@ -125,4 +127,34 @@
&__meal-caption {
color: colors.$blue;
}
&__service {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 120px;
padding: vars.$space-s2;
border-radius: vars.$border-radius;
color: colors.$blue-light;
font-size: fonts.$font-size-s;
font-weight: fonts.$font-medium;
text-align: center;
text-decoration: none;
&:hover {
background-color: colors.$blue-extra-light;
color: colors.$blue;
}
}
&__service-icon {
width: 32px;
height: 32px;
}
&__service-caption {
color: inherit;
line-height: 16px;
}
}
@@ -2,13 +2,20 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ScheduleLegDetails } from "./ScheduleLegDetails.js";
import type { ISimpleFlight, MealType } from "@/features/online-board/types.js";
import type {
IOnBoardService,
ISimpleFlight,
MealType,
} from "@/features/online-board/types.js";
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
function makeFlight(
meal: MealType[] | undefined,
services: IOnBoardService[] = [],
): ISimpleFlight {
return {
routeType: "Direct",
id: "x",
@@ -34,7 +41,13 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
} as never,
equipment: {
name: "Sukhoi SuperJet 100",
aircraft: { scheduled: { title: "Sukhoi SuperJet 100" } },
aircraft: {
scheduled: { title: "Sukhoi SuperJet 100" },
actual: {
title: "Sukhoi SuperJet 100",
onBoardServices: services,
},
},
...(meal !== undefined ? { meal: meal.map((type) => ({ type })) } : {}),
} as never,
} as never,
@@ -42,6 +55,30 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
}
describe("ScheduleLegDetails Питание sub-icons", () => {
it("links the aircraft title to Angular's Aeroflot plane park URL", () => {
render(<ScheduleLegDetails flight={makeFlight([])} locale="ru-ru" />);
const link = screen.getByRole("link", { name: "Sukhoi SuperJet 100" });
expect(link.getAttribute("href")).toBe(
"http://www.aeroflot.ru/cms/ru/flight/plane_park",
);
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
it("uses the language prefix from locale for the plane park URL", () => {
render(<ScheduleLegDetails flight={makeFlight([])} locale="en-us" />);
expect(
screen
.getByRole("link", { name: "Sukhoi SuperJet 100" })
.getAttribute("href"),
).toBe(
"http://www.aeroflot.ru/cms/en/flight/plane_park",
);
});
it("renders no meal-class sub-icons when equipment.meal is empty", () => {
render(<ScheduleLegDetails flight={makeFlight([])} />);
expect(screen.queryByText("FOOD.ECONOMY")).toBeNull();
@@ -64,4 +101,52 @@ describe("ScheduleLegDetails Питание sub-icons", () => {
expect(screen.getByText("FOOD.COMFORT")).toBeTruthy();
expect(screen.getByText("FOOD.BUSINESS")).toBeTruthy();
});
it("renders onboard services from actual aircraft data", () => {
render(
<ScheduleLegDetails
flight={makeFlight(["Economy"], [
{
id: "2",
title: "Space+",
url: "http://www.aeroflot.ru/cms/ru/flight/space_plus",
},
{ id: "5", title: "Интернет на борту" },
{ id: "8", title: "Выбор места" },
])}
/>,
);
expect(screen.getByText("SHARED.SERVICE")).toBeTruthy();
expect(screen.getByText("Space+")).toBeTruthy();
expect(screen.getByText("Интернет на борту")).toBeTruthy();
expect(screen.getByText("Выбор места")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-2")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-5")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-8")).toBeTruthy();
expect(screen.getByText("Space+").closest("a")?.getAttribute("href")).toBe(
"http://www.aeroflot.ru/cms/ru/flight/space_plus",
);
expect(
screen.getByText("Интернет на борту").closest("a")?.getAttribute("href"),
).toBe("https://aeroflot.ru");
});
it("does not render onboard services when only scheduled aircraft has services", () => {
const flight = makeFlight(["Economy"]);
const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
expect(leg).toBeTruthy();
if (!leg) return;
leg.equipment.aircraft = {
scheduled: {
title: "Sukhoi SuperJet 100",
onBoardServices: [{ id: "2", title: "Space+" }],
},
};
render(<ScheduleLegDetails flight={flight} />);
expect(screen.queryByText("SHARED.SERVICE")).toBeNull();
expect(screen.queryByText("Space+")).toBeNull();
});
});
@@ -3,12 +3,15 @@
* each flight leg. Mirrors Angular's `flight-details-wrapper` accordion
* (schedule view): two rows when the data exists.
*
* 1. Борт — aircraft type with a link to its Aeroflot info page.
* 1. Борт — aircraft type with a link to Aeroflot's aircraft park.
* 2. Питание на борту — meal-class sub-icons (Эконом / Комфорт /
* Бизнес), each rendered ONLY when the API's
* `equipment.meal[]` array contains the matching `type`. Matches
* Angular's `*ngIf="hasEconomyMeal"` etc. — flights with no meal
* data show just the cutlery icon + caption with no sub-icons.
* 3. Услуги на борту — service icons from
* `equipment.aircraft.actual.onBoardServices`, matching Angular's
* `model.showServices`.
*
* Expanded by default, toggled via the header.
*
@@ -17,26 +20,41 @@
import { useState, type FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "@/features/online-board/types.js";
import type {
IOnBoardService,
ISimpleFlight,
} from "@/features/online-board/types.js";
import {
SERVICE_ICON_FALLBACK,
SERVICE_ICON_MAP,
} from "@/features/online-board/components/details-panels/shared.js";
import economIcon from "@/features/online-board/components/details-panels/icons/econom.svg";
import comfortIcon from "@/features/online-board/components/details-panels/icons/comfort.svg";
import businessIcon from "@/features/online-board/components/details-panels/icons/business.svg";
import shoppingIcon from "@/features/online-board/components/details-panels/icons/shopping.svg";
import spaceIcon from "@/features/online-board/components/details-panels/icons/space.svg";
import taxiIcon from "@/features/online-board/components/details-panels/icons/taxi.svg";
import wifiIcon from "@/features/online-board/components/details-panels/icons/wifi.svg";
import gsmIcon from "@/features/online-board/components/details-panels/icons/gsm.svg";
import entertainmentIcon from "@/features/online-board/components/details-panels/icons/entertaintment.svg";
import seatReservationIcon from "@/features/online-board/components/details-panels/icons/seat_reservation.svg";
import comfortPlusIcon from "@/features/online-board/components/details-panels/icons/comfort-plus.svg";
import "./ScheduleLegDetails.scss";
export interface ScheduleLegDetailsProps {
flight: ISimpleFlight;
locale?: string;
}
/** Slug used for the aircraft catalog deep-link on www.aeroflot.ru. */
function aircraftSlug(title: string | undefined | null): string | null {
if (!title) return null;
return title
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
function aircraftParkHref(locale: string | undefined): string {
const language = locale?.split("-")[0] || "ru";
return `http://www.aeroflot.ru/cms/${language}/flight/plane_park`;
}
export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({
flight,
locale,
}) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<boolean>(true);
@@ -47,13 +65,8 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
leg.equipment?.aircraft?.actual?.title ??
leg.equipment?.aircraft?.scheduled?.title ??
"";
const slug = aircraftSlug(aircraft);
// External Aeroflot page that describes the aircraft model. Angular
// uses a curated slug map; the simple kebab-case works for common
// models (e.g. sukhoi-superjet-100).
const planeUrl = slug
? `https://www.aeroflot.ru/ru-ru/about/aircrafts/${slug}`
: null;
const services = leg.equipment?.aircraft?.actual?.onBoardServices ?? [];
const planeUrl = aircraftParkHref(locale);
return (
<div className="schedule-leg-details" data-testid="schedule-leg-details">
@@ -117,18 +130,14 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
<span className="schedule-leg-details__label">
{t("SHARED.PLANE")}
</span>
{planeUrl ? (
<a
className="schedule-leg-details__link"
href={planeUrl}
target="_blank"
rel="noopener noreferrer"
>
{aircraft}
</a>
) : (
<span className="schedule-leg-details__value">{aircraft}</span>
)}
<a
className="schedule-leg-details__link"
href={planeUrl}
target="_blank"
rel="noopener noreferrer"
>
{aircraft}
</a>
</div>
)}
@@ -202,6 +211,50 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({ flight }) => {
</div>
);
})()}
{services.length > 0 && (
<div className="schedule-leg-details__row schedule-leg-details__row--services">
<span
className="schedule-leg-details__icon schedule-leg-details__icon--services"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 47"
width="36"
height="36"
>
<g fill="none" stroke="currentColor" strokeWidth="2">
<path d="M31.087 15.427A9.551 9.551 0 0 1 31 14.136c0-4.81 3.582-8.71 8-8.71s8 3.9 8 8.71a9.551 9.551 0 0 1-.087 1.29" />
<path d="m31 16 .806 1.812A2 2 0 0 0 33.633 19h10.734a2 2 0 0 0 1.828-1.188L47 16" />
<path d="M3 7.093V3h1.639c1.561 0 0 3.349 0 3.349v1.079h1.093c.585 0 2.459 5.209 2.459 5.209h6.517v3.014H4.951A23.353 23.353 0 0 1 3 7.093Z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.474 15.651c2.77 0 .928 3.721 0 3.721H8.463c-2.537 0-2.927-2.046-3.512-3.721Z" strokeLinecap="round" strokeLinejoin="round" />
<path d="m15.1 15.651 3.9 2.828" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.086 12.339H9.929" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.4 32.821c-1.12 0-1.917-.365-1.917-1.246V31H5.519v.576c0 .881-.8 1.246-1.918 1.246H3v4.734c0 5.934 7.877 10.114 8.212 10.289l.3.156.293-.166c.339-.192 8.31-4.759 8.2-10.279v-4.735Z" />
<path d="m46.435 35.6.38.838.866-.308a1.829 1.829 0 0 1 .708-.13.452.452 0 0 1 .321.136.851.851 0 0 1 .252.789 1.187 1.187 0 0 1-.655.685l-.842.427.379.865a14.856 14.856 0 0 1 .686 1.908l.014.049.013.032a1.255 1.255 0 0 1 0 .2c0 .068-.01.133-.017.212 0 .027-.005.055-.008.085-.01.107-.021.248-.021.389 0 .908-.026 1.818-.052 2.744v.006c-.023.813-.046 1.639-.052 2.47h-2.155V44.87l-1.119.13a52.486 52.486 0 0 1-12.255 0l-1.118-.133V47h-2.17c-.005-1.343-.04-2.68-.074-4.008q-.015-.587-.03-1.172c0-.136-.012-.272-.021-.377l-.007-.085c-.007-.081-.013-.149-.017-.219a1.462 1.462 0 0 1 0-.191v-.033l.009-.028.061-.186c.206-.63.4-1.211.638-1.763l.378-.865-.842-.427a1.694 1.694 0 0 1-.618-.481.373.373 0 0 1-.068-.15.315.315 0 0 1 .023-.16l.025-.07.014-.074c.083-.42.258-.561.385-.62a1.192 1.192 0 0 1 .881.04l.866.308.379-.838c.115-.254.223-.56.32-.842l.049-.142c.086-.248.169-.491.261-.729a5.421 5.421 0 0 1 .324-.718 1.089 1.089 0 0 1 .224-.3l.057-.043.05-.051a4.824 4.824 0 0 1 2.24-.925 20.565 20.565 0 0 1 7.826.009 4.826 4.826 0 0 1 2.223.916 3.611 3.611 0 0 1 .639 1.154c.086.216.165.433.248.66l.062.17c.105.273.218.57.34.84Z" />
</g>
<g fill="currentColor">
<rect width="2" height="2.647" rx="1" transform="translate(38 3)" />
<rect width="20" height="2" rx="1" transform="translate(29 14.385)" />
<path d="M21.243 26.215h2.486V28.7a1.243 1.243 0 1 0 2.486 0v-2.485H28.7a1.243 1.243 0 1 0 0-2.486h-2.485v-2.486a1.243 1.243 0 1 0-2.486 0v2.486h-2.486a1.243 1.243 0 1 0 0 2.486Z" />
<path d="M8.875 39.375h1.75v1.75a.875.875 0 0 0 1.75 0v-1.75h1.75a.875.875 0 0 0 0-1.75h-1.75v-1.75a.875.875 0 0 0-1.75 0v1.75h-1.75a.875.875 0 0 0 0 1.75Z" />
</g>
</svg>
</span>
<span className="schedule-leg-details__label">
{t("SHARED.SERVICE")}
</span>
<div className="schedule-leg-details__services">
{services.map((service, index) => (
<ServicePill
key={`${service.id}-${index}`}
service={service}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -233,3 +286,49 @@ const MealPill: FC<MealPillProps> = ({ icon, labelKey, t }) => (
<span className="schedule-leg-details__meal-caption">{t(labelKey)}</span>
</span>
);
const SERVICE_ICON_BY_NAME: Record<string, string> = {
shopping: shoppingIcon,
space: spaceIcon,
taxi: taxiIcon,
wifi: wifiIcon,
gsm: gsmIcon,
entertaintment: entertainmentIcon,
seat_reservation: seatReservationIcon,
"comfort-plus": comfortPlusIcon,
};
function serviceIconSrc(service: IOnBoardService): string {
const numericId = typeof service.id === "string" ? Number(service.id) : service.id;
const iconName = SERVICE_ICON_MAP[numericId] ?? SERVICE_ICON_FALLBACK;
return (
SERVICE_ICON_BY_NAME[iconName] ??
SERVICE_ICON_BY_NAME[SERVICE_ICON_FALLBACK] ??
""
);
}
const ServicePill: FC<{ service: IOnBoardService }> = ({ service }) => {
const iconSrc = serviceIconSrc(service);
const title = service.title ?? "";
return (
<a
className="schedule-leg-details__service"
href={service.url || "https://aeroflot.ru"}
target="_blank"
rel="noopener noreferrer"
>
<img
className="schedule-leg-details__service-icon"
src={iconSrc}
alt=""
aria-hidden="true"
data-testid={`schedule-service-icon-${service.id}`}
/>
{title && (
<span className="schedule-leg-details__service-caption">{title}</span>
)}
</a>
);
};
@@ -40,11 +40,11 @@
}
}
// §4.1.11 — block filter/tabs/breadcrumbs while loading
// Angular parity: loading blocks only the sticky results controls.
// The sidebar filter stays interactive so users can correct criteria
// while the previous request is still in-flight.
&[data-searching="true"] {
.page-layout__left,
.page-layout__sticky,
.page-layout__breadcrumbs {
.page-layout__sticky {
pointer-events: none;
opacity: 0.6;
}
@@ -30,8 +30,13 @@ 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 {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import { buildScheduleFlightListJsonLd } from "../json-ld.js";
import type { ScheduleParams } from "../url.js";
import type { IScheduleSearchRequest, ISimpleFlight } from "../types.js";
@@ -48,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;
@@ -71,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<ScheduleSearchPageProps> = ({ params }) => {
@@ -103,13 +138,20 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
childIds && childIds.length > 1
? childIds
: [flight.flightId];
const buildSeg = (id: typeof flight.flightId): string =>
buildFlightUrlParams({
const buildSeg = (
id: typeof flight.flightId,
leg?: typeof allLegs[number],
): string => {
const localDate = leg?.departure.times.scheduledDeparture.local
?.slice(0, 10)
.replace(/-/g, "");
return buildFlightUrlParams({
carrier: id.carrier,
flightNumber: id.flightNumber,
...(id.suffix ? { suffix: id.suffix } : {}),
date: id.date,
date: localDate || id.date,
});
};
// Interleave airport codes between flight segments so the URL
// round-trips through `parseFlightSegments` (3-char codes are
// skipped) AND keeps Angular-compatible structure for SEO.
@@ -117,7 +159,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
flightIds.forEach((id, i) => {
const leg = allLegs[i] ?? allLegs[allLegs.length - 1];
if (i === 0 && leg) parts.push(leg.departure.scheduled.airportCode);
parts.push(buildSeg(id));
parts.push(buildSeg(id, leg));
if (leg) parts.push(leg.arrival.scheduled.airportCode);
});
const segment = parts.join("/");
@@ -156,9 +198,8 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
[locale, navigate, outbound, inbound],
);
// Builds the Aeroflot booking URL for a flight — shares shape with
// BoardDetailsHeader's BuyTicketButton:
// https://www.aeroflot.ru/sb/app/{lang}-{lang}#/search?…&routes={dep}.{yyyyMMdd}.{arr}
// Builds the Aeroflot booking URL for a flight. Angular's
// BuyTicketLogic hardcodes the SB path to ru-ru for every UI locale.
// Returns null when the flight lacks the data we need to assemble it.
const buyUrlFor = useCallback(
(flight: ISimpleFlight): string | null => {
@@ -168,14 +209,13 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
if (!firstLeg || !lastLeg) return null;
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
const depDate = new Date(depUtc);
const yyyy = depDate.getFullYear().toString();
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
const dd = depDate.getDate().toString().padStart(2, "0");
return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
},
[language],
[],
);
// Round-trip schedules render only one direction at a time, with a
@@ -257,7 +297,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
flights: inboundFlights,
loading: inboundLoading,
cancel: cancelInbound,
} = useScheduleSearch(inboundRequest);
} = useScheduleSearch(inboundRequest, Boolean(inbound));
const isLoading = outboundLoading || (inbound ? inboundLoading : false);
@@ -102,6 +102,10 @@ vi.mock("@/shared/dictionaries/index.js", () => ({
getCityCodeByAirportCode: () => undefined,
}));
vi.mock("../hooks/useScheduleCalendar.js", () => ({
useScheduleCalendar: () => ({ days: [], loading: false, loaded: false }),
}));
let geoMockEnabled = false;
vi.mock("@/shared/hooks/useGeoCityDefault.js", () => ({
@@ -195,9 +199,6 @@ describe("ScheduleStartPage", () => {
// 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17
// `from` is clamped to today1 = 2026-05-14 so the route guard does
// not redirect the search back to the start page.
// Same-page Schedule click updates form state directly (navigate to
// the same route would no-op), so we assert visible form state and
// submit the form to verify the dates landed in component state.
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
expect(mockNavigate).not.toHaveBeenCalled();
@@ -206,7 +207,6 @@ describe("ScheduleStartPage", () => {
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false);
expect(screen.queryByTestId("return-date-range-input")).toBeNull();
// Submit drives the dates from state into the URL — proves they were set.
fireEvent.submit(screen.getByTestId("schedule-search-form"));
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
});
@@ -1,13 +1,14 @@
/**
* Schedule start page -- search form for route-based schedule search.
*
* No API calls on load. Pure form that navigates to the appropriate
* search route on submit.
* No schedule-search API calls on load. Once both cities are selected,
* fetches route operating days so unavailable dates are greyed out before
* submit, matching Angular's schedule-filter.
*
* @module
*/
import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react";
import { type FC, useState, useCallback, useEffect, useMemo, useRef, type FormEvent } from "react";
import { useNavigate } from "@modern-js/runtime/router";
import { useLocale } from "@/i18n/useLocale.js";
import { Calendar } from "primereact/calendar";
@@ -36,6 +37,8 @@ import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
import { buildScheduleUrl } from "../url.js";
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js";
import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js";
import type { IScheduleCalendarParams } from "../types.js";
import "./ScheduleStartPage.scss";
function toCityCode(code: string, dictionaries: IDictionaries | null): string {
@@ -56,6 +59,33 @@ function dateToYyyymmdd(value: Date): string {
return `${y}${m}${d}`;
}
function dateToIsoYmd(value: Date): string {
const y = value.getFullYear().toString();
const m = (value.getMonth() + 1).toString().padStart(2, "0");
const d = value.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
function computeDisabledDates(
availableYmd: string[],
minDate: Date,
maxDate: Date,
): Date[] {
const available = new Set(availableYmd);
const disabled: Date[] = [];
const cursor = new Date(minDate);
cursor.setHours(0, 0, 0, 0);
while (cursor.getTime() <= maxDate.getTime()) {
if (!available.has(dateToIsoYmd(cursor))) {
disabled.push(new Date(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return disabled;
}
function addDays(base: Date, days: number): Date {
const result = new Date(base);
result.setDate(result.getDate() + days);
@@ -245,6 +275,59 @@ export const ScheduleStartPage: FC = () => {
const scheduleMinDate = useRef(getScheduleMinDate()).current;
const scheduleMaxDate = useRef(getScheduleMaxDate()).current;
const scheduleCalendarBaseDate = useMemo(
() => dateToIsoYmd(scheduleMinDate),
[scheduleMinDate],
);
const outboundCalendarParams = useMemo<IScheduleCalendarParams | null>(() => {
const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries);
const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries);
if (!dep || !arr || dep === arr) return null;
return {
date: scheduleCalendarBaseDate,
departure: dep,
arrival: arr,
connections: !directOnly,
};
}, [departureCode, arrivalCode, dictionaries, directOnly, scheduleCalendarBaseDate]);
const returnCalendarParams = useMemo<IScheduleCalendarParams | null>(() => {
if (!isRoundTrip) return null;
const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries);
const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries);
if (!dep || !arr || dep === arr) return null;
return {
date: scheduleCalendarBaseDate,
departure: arr,
arrival: dep,
connections: !directOnly,
};
}, [departureCode, arrivalCode, dictionaries, directOnly, isRoundTrip, scheduleCalendarBaseDate]);
const {
days: outboundAvailableDays,
loaded: outboundCalendarLoaded,
} = useScheduleCalendar(outboundCalendarParams);
const {
days: returnAvailableDays,
loaded: returnCalendarLoaded,
} = useScheduleCalendar(returnCalendarParams);
const outboundDisabledDates = useMemo(
() =>
!outboundCalendarLoaded
? []
: computeDisabledDates(outboundAvailableDays, scheduleMinDate, scheduleMaxDate),
[outboundAvailableDays, outboundCalendarLoaded, scheduleMinDate, scheduleMaxDate],
);
const returnDisabledDates = useMemo(
() =>
!returnCalendarLoaded
? []
: computeDisabledDates(returnAvailableDays, scheduleMinDate, scheduleMaxDate),
[returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate],
);
// TZ §4.1.9 Table 14 / Angular CalendarInputWeekComponent: when the
// selected date range contains today, the Calendar input shows
@@ -383,12 +466,9 @@ export const ScheduleStartPage: FC = () => {
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
// Deviation from Angular: every popular-request click on Schedule
// populates the form in-place and stays on the page. Angular
// redirects Arrival/Departure/FlightNumber clicks to /onlineboard
// unconditionally; we instead treat the click as a Schedule prefill
// because users land here to plan a trip, not to switch sections.
// Codes sometimes arrive as airport (SVO/LED) — normalize to city.
// Popular route clicks prefill the Schedule form from the clicked
// item. This mirrors Angular: users land on the Schedule page with
// route/date fields ready, then submit when they want results.
switch (request.mode) {
case "Route":
case "RouteWithBack": {
@@ -489,6 +569,7 @@ export const ScheduleStartPage: FC = () => {
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
disabledDates={outboundDisabledDates}
selectOtherMonths
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
@@ -555,6 +636,7 @@ export const ScheduleStartPage: FC = () => {
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
disabledDates={returnDisabledDates}
selectOtherMonths
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
@@ -21,6 +21,7 @@ export interface UseScheduleDetailsParams {
export interface UseScheduleDetailsResult {
flights: ISimpleFlight[];
daysOfFlight: string[];
loading: boolean;
error: ApiError | null;
}
@@ -34,6 +35,7 @@ export function useScheduleDetails(
): UseScheduleDetailsResult {
const client = useApiClient();
const [flights, setFlights] = useState<ISimpleFlight[]>([]);
const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiError | null>(null);
@@ -53,6 +55,7 @@ export function useScheduleDetails(
.then((response) => {
if (!cancelled) {
setFlights(response.data.routes);
setDaysOfFlight(response.data.daysOfFlight ?? []);
setLoading(false);
}
})
@@ -68,5 +71,5 @@ export function useScheduleDetails(
};
}, [client, flightsKey, datesKey, params.departure, params.arrival]);
return { flights, loading, error };
return { flights, daysOfFlight, loading, error };
}
@@ -29,10 +29,11 @@ export interface UseScheduleSearchResult {
*/
export function useScheduleSearch(
params: IScheduleSearchRequest,
enabled = true,
): UseScheduleSearchResult {
const client = useApiClient();
const [flights, setFlights] = useState<IFlight[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<ApiError | null>(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,
]);
+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",
+2 -1
View File
@@ -23,7 +23,7 @@
"FLIGHT_NUMBER": "Flight number",
"FLIGHT_NUMBER-ERROR-BIG": "Incorrect flight number. The flight number may not be longer than 4 digits",
"FLIGHT_NUMBER-ERROR-EMPTY": "Specify flight number",
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number may only consist of digits and must not exceed 4 characters.",
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number must consist of 34 digits and may include a single Latin letter suffix at the end.",
"GPS-BUTTON": "Detect my location",
"GPS-HELP": "Enable geolocation in your browser to detect the city automatically. Geolocation will not work if any anonymizers are enabled.",
"NOT-FOUND-LOCATION": "You are seeing this page because we could not access your current location. \nAllow the app to access your location to view flights to your destination.",
@@ -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",
+2 -1
View File
@@ -23,7 +23,7 @@
"FLIGHT_NUMBER": "Номер рейса",
"FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов",
"FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса",
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса может состоять только из цифр и не должен быть длиннее 4-х символов.",
"FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса должен состоять из 3-4 цифр и может содержать в конце одну латинскую букву-суффикс.",
"GPS-BUTTON": "Определить мое местоположение",
"GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.",
"NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",
@@ -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",
+14 -2
View File
@@ -31,12 +31,18 @@ describe("isLanguage", () => {
});
describe("resolveLocaleFromPath", () => {
it("extracts BCP-47 locale from the first path segment", () => {
it("extracts Angular country-language locale from the first path segment", () => {
expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru");
expect(resolveLocaleFromPath("/en-en/onlineboard/flight/SU100")).toBe("en-en");
expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de");
});
it("accepts mixed country-language URLs and resolves language from the second part", () => {
expect(resolveLocaleFromPath("/ru-en/onlineboard")).toBe("ru-en");
expect(resolveLocaleFromPath("/ru-de/schedule")).toBe("ru-de");
expect(resolveLocaleFromPath("/ad-fr/onlineboard")).toBe("ad-fr");
});
it("auto-promotes a bare short language code to its BCP-47 cousin", () => {
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru-ru");
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-en");
@@ -47,6 +53,8 @@ describe("resolveLocaleFromPath", () => {
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/xx-xx/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/russia-en/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/ru-eng/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/")).toBeNull();
expect(resolveLocaleFromPath("")).toBeNull();
});
@@ -58,7 +66,7 @@ describe("resolveLocaleFromPath", () => {
});
describe("stripLocaleFromPath", () => {
it("strips BCP-47 locale and returns the rest", () => {
it("strips Angular country-language locale and returns the rest", () => {
expect(stripLocaleFromPath("/ru-ru/onlineboard")).toEqual({
locale: "ru-ru",
rest: "/onlineboard",
@@ -67,6 +75,10 @@ describe("stripLocaleFromPath", () => {
locale: "en-en",
rest: "/onlineboard/flight/SU100",
});
expect(stripLocaleFromPath("/ru-en/onlineboard")).toEqual({
locale: "ru-en",
rest: "/onlineboard",
});
});
it("auto-promotes short codes when stripping", () => {
+18 -28
View File
@@ -2,25 +2,16 @@
* Locale codes used in URLs vs the short language code used for i18n
* file lookup, API path segment, and `Accept-Language` header.
*
* Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`,
* `/en-en/`, `/zh-zh/`...), backend + translation files use the short
* 2-letter language part only (`ru`, `en`, `zh`...). This split keeps
* the customer's URL contract while reusing a single set of locale
* Mirrors Angular's `LocalizationService`: URL is `/{country}-{language}`
* (`/ru-ru/`, `/ru-en/`, `/ru-de/`...), while backend + translation files
* use the second 2-letter segment only (`ru`, `en`, `de`...). This split
* keeps the customer's URL contract while reusing a single set of locale
* resources.
*/
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
export type LocaleCode =
| "ru-ru"
| "en-en"
| "es-es"
| "fr-fr"
| "it-it"
| "ja-ja"
| "ko-ko"
| "zh-zh"
| "de-de";
export type LocaleCode = `${string}-${Language}`;
export const LANGUAGES: readonly Language[] = [
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
@@ -34,13 +25,11 @@ export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru";
export const DEFAULT_LANGUAGE: Language = "ru";
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
const localeCodeSet: ReadonlySet<string> = new Set(LOCALE_CODES);
const URL_LOCALE_PATTERN = /^([a-z]{2})-([a-z]{2})$/;
// Angular's URL contract is `/{country}-{language}` where the two
// halves repeat the same 2-letter language code (see
// `ClientApp/src/app/shared/services/localization.service.ts` —
// `Country = baseHref[1..3]`, `Language = baseHref[4..6]`). We mirror
// that exact shape so the React MF remote shares Angular's URL surface.
// `languageToLocale` returns the canonical same-language URL used for
// generated links, while `normalizeLocaleParam` below also accepts Angular's
// mixed country-language URLs such as `/ru-en`.
const LANGUAGE_TO_LOCALE_CODE: Record<Language, LocaleCode> = {
ru: "ru-ru",
en: "en-en",
@@ -58,15 +47,16 @@ export function isLanguage(x: string): x is Language {
}
export function isLocaleCode(x: string): x is LocaleCode {
return localeCodeSet.has(x);
const match = URL_LOCALE_PATTERN.exec(x);
return match ? isLanguage(match[2] ?? "") : false;
}
/**
* Extract the language part from a BCP-47 locale code.
* `localeToLanguage("en-en")` → `"en"`.
* Extract the language part from an Angular URL locale code.
* `localeToLanguage("ru-en")` → `"en"`.
*/
export function localeToLanguage(code: LocaleCode): Language {
return code.slice(0, 2) as Language;
return code.split("-")[1] as Language;
}
/**
@@ -79,10 +69,10 @@ export function languageToLocale(lang: Language): LocaleCode {
}
/**
* Read the locale code from `:lang` URL params. Accepts both BCP-47
* (`ru-ru`) and bare short codes (`ru`) — the short form is promoted
* to its canonical BCP-47 cousin so legacy / direct API consumers
* keep working during migration.
* Read the locale code from `:lang` URL params. Accepts Angular
* country-language URLs (`ru-en`) and bare short codes (`en`) — the short
* form is promoted to its canonical same-language URL (`en-en`) so legacy /
* direct API consumers keep working during migration.
*/
export function normalizeLocaleParam(raw: string | undefined): LocaleCode | null {
if (!raw) return null;
+51 -1
View File
@@ -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");
});
});
+43
View File
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More