import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE, type BookingWindowPhaseEvent, } from "@edr/types"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { io } from "socket.io-client"; import { API_BASE_URL } from "@/constants/apiConfig"; import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; // The socket namespace lives at the server root, not under the `/api` REST // prefix — strip a trailing `/api` if the base URL carries one. const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); // The two carousel window lists share the MyBookingWindow-shaped row and can be // patched in place. The batch board is a richer, differently-shaped view, so it // stays on a (debounced) invalidate. const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]); /** Shape shared by both carousel window lists (all-lanes + contract-scoped). */ interface WindowRow { scheduleId: string; windowPhase: string | null; isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; } function isWindowKey(key: readonly unknown[]): boolean { return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1])); } /** * Fold a server phase push onto a cached window row, recomputing isOpenNow the * same way the server does (phase OPEN + status OPEN) so live-patched state can * never disagree with a fresh REST fetch on refresh. */ function applyEvent(row: T, event: BookingWindowPhaseEvent): T { return { ...row, windowPhase: event.phase, bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus, bookingCycleNo: event.bookingCycleNo, isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN", windowOpensAt: event.windowOpensAt, windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } /** * Subscribes to live booking-window pushes for staff. A phase transition carries * the schedule's full new state; we fold it straight into the carousel window * lists with setQueriesData rather than invalidating — same rationale as the * portal hook (no per-push refetch storm; live + refreshed state agree, killing * the refresh-jump). The batch board is a different-shaped view, so it keeps a * debounced invalidate, as do pushes for schedules not present in any list. */ export function useBookingWindowSocket(enabled: boolean = true) { const qc = useQueryClient(); useEffect(() => { if (!enabled) return; const token = getCookie(AUTH_TOKEN_COOKIE); if (!token) return; const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { auth: { token }, transports: ["websocket"], withCredentials: true, }); // Deliberate console breadcrumbs: "live updates not arriving" is only // diagnosable from the browser when connect/reject outcomes are visible. socket.on("connect", () => console.debug("[booking-windows] socket connected", socket.id), ); socket.on("connect_error", (err) => console.warn("[booking-windows] socket connect failed:", err.message), ); socket.on("disconnect", (reason) => console.debug("[booking-windows] socket disconnected:", reason), ); // Coalesce the batch-board refresh (and the unknown-schedule fallback) so a // burst of pushes triggers at most one invalidation per window. let refetchTimer: ReturnType | null = null; const scheduleRefetch = (includeWindowLists: boolean) => { if (refetchTimer) return; refetchTimer = setTimeout(() => { refetchTimer = null; void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), }); if (includeWindowLists) { void qc.invalidateQueries({ predicate: (q) => isWindowKey(q.queryKey), }); } }, 800); }; socket.on( BOOKING_WINDOW_WS_EVENTS.PHASE, (event: BookingWindowPhaseEvent) => { let patchedSomewhere = false; qc.setQueriesData( { predicate: (q) => isWindowKey(q.queryKey) }, (rows) => { if (!rows) return rows; let changed = false; const next = rows.map((row) => { if (row.scheduleId !== event.scheduleId) return row; changed = true; patchedSomewhere = true; return applyEvent(row, event); }); return changed ? next : rows; }, ); // Refresh the batch-board DETAIL for the schedule that transitioned so the // Priority Tracking tab reranks + updates its countdowns immediately (the // detail is a different shape from the list — invalidate, don't patch). void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId), }); // Always refresh the batch board (different shape, not patched). When the // schedule wasn't in any window list either, refresh those too so a newly // announced window surfaces. Both debounced — no per-push stampede. scheduleRefetch(!patchedSomewhere); }, ); return () => { if (refetchTimer) clearTimeout(refetchTimer); socket.off(); socket.disconnect(); }; }, [enabled, qc]); }