Fix online board live refresh parity

This commit is contained in:
2026-05-06 22:49:15 +03:00
parent 1d32c5d0c6
commit 53b48a62dd
11 changed files with 240 additions and 32 deletions
@@ -385,7 +385,7 @@ 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
@@ -395,8 +395,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
// Live updates via SignalR
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
flightId,
flight?.flightId ?? flightId,
flight,
refresh,
);
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
@@ -375,6 +375,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
liveBoardParams,
flights,
refresh,
);
// Calendar days
@@ -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,13 @@ describe("useLiveFlightDetails", () => {
expect(result.current.flight).toBeNull();
});
it("subscribes to the correct channel", async () => {
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 +255,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 +278,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,42 @@ export interface UseLiveFlightDetailsResult {
// Channel key builder (exported for testing)
// ---------------------------------------------------------------------------
export function buildFlightChannelKey(id: IParsedFlightId): string {
type LiveFlightId = IParsedFlightId | IFlightId;
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,
initialFlight: ISimpleFlight | null,
onRefresh?: () => void,
): UseLiveFlightDetailsResult {
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>(
const config = useMemo<UseLiveFlightsConfig<LiveFlightId>>(
() => ({
hubUrl: getEnv().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;
},
}),
[],
[onRefresh],
);
// useLiveFlights expects an array — wrap/unwrap the single flight
@@ -59,7 +77,7 @@ export function useLiveFlightDetails(
);
const { data, connectionStatus } = useLiveFlights<
IParsedFlightId,
LiveFlightId,
ISimpleFlight
>(id, initialData, config);
+32
View File
@@ -16,6 +16,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;
@@ -110,6 +111,37 @@ describe("useLiveFlights", () => {
expect(result.current.data).toEqual(newData);
});
it("invokes subscription method and lets onMessage trigger a refresh", async () => {
const { hub } = installMockHub(defaultConfig.hubUrl);
const onMessage = vi.fn();
const config = {
...defaultConfig,
subscription: {
eventName: "RefreshDate",
invokeMethodName: "SubscribeDate",
args: (p: { route: string }) => [p.route],
},
onMessage,
};
renderHook(() =>
useLiveFlights({ route: "SVO-LED" }, [], config),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith("RefreshDate", expect.any(Function));
expect(hub.invoke).toHaveBeenCalledWith("SubscribeDate", "SVO-LED");
act(() => {
hub._simulateMessage("RefreshDate", "20260506");
});
expect(onMessage).toHaveBeenCalledWith("20260506");
});
it("cleans up subscription on unmount", async () => {
const { hub } = installMockHub(defaultConfig.hubUrl);
+32 -5
View File
@@ -4,9 +4,17 @@ import {
type ConnectionStatus,
} from "../signalr/connection.js";
export interface LiveSubscription<TParams> {
eventName: string;
invokeMethodName: string;
args: (params: TParams) => unknown[];
}
export interface UseLiveFlightsConfig<TParams> {
hubUrl: string;
channelKey: (params: TParams) => string;
subscription?: LiveSubscription<TParams>;
onMessage?: (message: unknown) => void | unknown[];
}
export interface UseLiveFlightsResult<TData> {
@@ -26,15 +34,18 @@ export function useLiveFlights<TParams, TData>(
initialData: TData[],
config: UseLiveFlightsConfig<TParams>,
): UseLiveFlightsResult<TData> {
const [data, setData] = useState<TData[]>(initialData);
const [data, setData] = useState<TData[] | null>(null);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("idle");
// Keep a stable reference to initialData for the SSR short-circuit
const initialDataRef = useRef(initialData);
initialDataRef.current = initialData;
const onMessageRef = useRef(config.onMessage);
onMessageRef.current = config.onMessage;
const isClient = typeof window !== "undefined";
const paramsKey = config.channelKey(params);
useEffect(() => {
if (!isClient) return;
@@ -43,8 +54,9 @@ export function useLiveFlights<TParams, TData>(
// real SIGNALR_HUB_URL (same-origin or CORS-enabled).
if (!config.hubUrl) return;
const channel = config.channelKey(params);
const channel = config.subscription?.eventName ?? paramsKey;
const connection = getSharedConnection({ hubUrl: config.hubUrl });
setData(null);
// Sync current status
setConnectionStatus(connection.status);
@@ -54,18 +66,33 @@ export function useLiveFlights<TParams, TData>(
});
const unsubChannel = connection.subscribe(channel, (message) => {
setData(message as TData[]);
const nextData = onMessageRef.current?.(message);
if (Array.isArray(nextData)) {
setData(nextData as TData[]);
return;
}
if (!onMessageRef.current) {
setData(message as TData[]);
}
});
const subscription = config.subscription;
if (subscription) {
void connection.invoke(
subscription.invokeMethodName,
...subscription.args(params),
);
}
return () => {
unsubChannel();
unsubStatus();
};
}, [isClient, config.hubUrl, config.channelKey, params]);
}, [isClient, config.hubUrl, config.subscription, paramsKey]);
if (!isClient) {
return { data: initialData, connectionStatus: "idle" };
}
return { data, connectionStatus };
return { data: data ?? initialData, connectionStatus };
}
+17
View File
@@ -17,6 +17,7 @@ function createMockHub() {
const hub = {
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;
@@ -233,6 +234,22 @@ describe("SignalRConnection", () => {
expect(hub.stop).toHaveBeenCalledTimes(1);
expect(conn.status).toBe("idle");
});
it("invokes hub methods after the lazy connection starts", async () => {
const hub = createMockHub();
const conn = createConnection(hub);
await conn.invoke("SubscribeDate", "20260506", "MOW", "LED");
await vi.runAllTimersAsync();
expect(hub.start).toHaveBeenCalledTimes(1);
expect(hub.invoke).toHaveBeenCalledWith(
"SubscribeDate",
"20260506",
"MOW",
"LED",
);
});
});
describe("getSharedConnection", () => {
+25
View File
@@ -26,6 +26,7 @@ type MessageHandler = (message: unknown) => void;
interface HubConnectionLike {
start(): Promise<void>;
stop(): Promise<void>;
invoke(methodName: string, ...args: unknown[]): Promise<unknown>;
on(methodName: string, handler: (...args: unknown[]) => void): void;
off(methodName: string, handler: (...args: unknown[]) => void): void;
onclose(callback: (error?: Error) => void): void;
@@ -93,6 +94,30 @@ export class SignalRConnection {
};
}
/**
* Invoke a hub method after the lazy connection has started.
* Returns false when the hub cannot be reached, so callers can degrade
* quietly while keeping already-fetched data visible.
*/
async invoke(methodName: string, ...args: unknown[]): Promise<boolean> {
if (!this.buildPromise) {
this.buildPromise = this.buildAndStart();
}
await this.buildPromise;
if (this._status !== "live" || !this.connection) {
return false;
}
try {
await this.connection.invoke(methodName, ...args);
return true;
} catch {
this.setStatus("offline");
return false;
}
}
/** Listen for connection status changes. Returns an unsubscribe function. */
onStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler);