diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index 3aced034..34a2d0d8 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -385,7 +385,7 @@ export const OnlineBoardDetailsPage: FC = ({ 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 = ({ // Live updates via SignalR const { flight: liveFlight, connectionStatus } = useLiveFlightDetails( - flightId, + flight?.flightId ?? flightId, flight, + refresh, ); const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight; diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index dbd63ba4..9d4164e8 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -375,6 +375,7 @@ export const OnlineBoardSearchPage: FC = ({ const { flights: liveFlights, connectionStatus } = useLiveBoardSearch( liveBoardParams, flights, + refresh, ); // Calendar days diff --git a/src/features/online-board/hooks/useFlightDetails.ts b/src/features/online-board/hooks/useFlightDetails.ts index a7f65687..bfdbb757 100644 --- a/src/features/online-board/hooks/useFlightDetails.ts +++ b/src/features/online-board/hooks/useFlightDetails.ts @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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, }; } diff --git a/src/features/online-board/hooks/useLiveBoardSearch.test.ts b/src/features/online-board/hooks/useLiveBoardSearch.test.ts index fd25affa..49b7e324 100644 --- a/src/features/online-board/hooks/useLiveBoardSearch.test.ts +++ b/src/features/online-board/hooks/useLiveBoardSearch.test.ts @@ -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 diff --git a/src/features/online-board/hooks/useLiveBoardSearch.ts b/src/features/online-board/hooks/useLiveBoardSearch.ts index d9e2d162..3be085e9 100644 --- a/src/features/online-board/hooks/useLiveBoardSearch.ts +++ b/src/features/online-board/hooks/useLiveBoardSearch.ts @@ -49,13 +49,24 @@ export function buildBoardChannelKey(params: LiveBoardSearchParams): string { export function useLiveBoardSearch( params: LiveBoardSearchParams, initialFlights: ISimpleFlight[], + onRefresh?: () => void, ): UseLiveBoardSearchResult { const config = useMemo>( () => ({ 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< diff --git a/src/features/online-board/hooks/useLiveFlightDetails.test.ts b/src/features/online-board/hooks/useLiveFlightDetails.test.ts index bb6ecd29..739709ba 100644 --- a/src/features/online-board/hooks/useLiveFlightDetails.test.ts +++ b/src/features/online-board/hooks/useLiveFlightDetails.test.ts @@ -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 diff --git a/src/features/online-board/hooks/useLiveFlightDetails.ts b/src/features/online-board/hooks/useLiveFlightDetails.ts index 03dc548e..b74b7369 100644 --- a/src/features/online-board/hooks/useLiveFlightDetails.ts +++ b/src/features/online-board/hooks/useLiveFlightDetails.ts @@ -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>( + const config = useMemo>( () => ({ 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); diff --git a/src/shared/hooks/useLiveFlights.test.ts b/src/shared/hooks/useLiveFlights.test.ts index 48750f53..6b41dad8 100644 --- a/src/shared/hooks/useLiveFlights.test.ts +++ b/src/shared/hooks/useLiveFlights.test.ts @@ -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); diff --git a/src/shared/hooks/useLiveFlights.ts b/src/shared/hooks/useLiveFlights.ts index 5f47467b..189a09e8 100644 --- a/src/shared/hooks/useLiveFlights.ts +++ b/src/shared/hooks/useLiveFlights.ts @@ -4,9 +4,17 @@ import { type ConnectionStatus, } from "../signalr/connection.js"; +export interface LiveSubscription { + eventName: string; + invokeMethodName: string; + args: (params: TParams) => unknown[]; +} + export interface UseLiveFlightsConfig { hubUrl: string; channelKey: (params: TParams) => string; + subscription?: LiveSubscription; + onMessage?: (message: unknown) => void | unknown[]; } export interface UseLiveFlightsResult { @@ -26,15 +34,18 @@ export function useLiveFlights( initialData: TData[], config: UseLiveFlightsConfig, ): UseLiveFlightsResult { - const [data, setData] = useState(initialData); + const [data, setData] = useState(null); const [connectionStatus, setConnectionStatus] = useState("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( // 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( }); 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 }; } diff --git a/src/shared/signalr/connection.test.ts b/src/shared/signalr/connection.test.ts index fbec0843..9a08502f 100644 --- a/src/shared/signalr/connection.test.ts +++ b/src/shared/signalr/connection.test.ts @@ -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", () => { diff --git a/src/shared/signalr/connection.ts b/src/shared/signalr/connection.ts index 3d464407..5e4bf3e3 100644 --- a/src/shared/signalr/connection.ts +++ b/src/shared/signalr/connection.ts @@ -26,6 +26,7 @@ type MessageHandler = (message: unknown) => void; interface HubConnectionLike { start(): Promise; stop(): Promise; + invoke(methodName: string, ...args: unknown[]): Promise; 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 { + 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);