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 type { MyBookingWindow } from "@/services/bookings.service"; function getAuthToken(): string | undefined { return document.cookie .split("; ") .find((row) => row.startsWith("auth-token=")) ?.split("=")[1]; } // 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\/?$/, ""); // Both window lists live under this key prefix (myBookingWindows + // contractBookingWindows/*), so one predicate patches every cached list. const WINDOW_KEY_PREFIX = ["train-scheduling"] as const; const WINDOW_ACTIONS = new Set(["myBookingWindows", "contractBookingWindows"]); /** * Fold a server phase push onto a cached window row. `isOpenNow` is recomputed * exactly as the server's mapBookingWindowRow does (phase OPEN + status OPEN) so * the live-patched state can never disagree with what a fresh REST fetch returns * on refresh — both come from the same server timestamps, not the client clock. */ function applyEvent( row: MyBookingWindow, event: BookingWindowPhaseEvent, ): MyBookingWindow { 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. Every phase transition the window * engine applies (open, doc review, payment, reopen, done) carries the schedule's * full new state; we fold it straight into the cached window lists with * setQueriesData rather than invalidating. * * Why not invalidate: at ~200 concurrent users a namespace-wide broadcast made * every client refetch two heavy window queries on every schedule transition — * an O(users × schedules) stampede that lagged the whole population. Patching the * cache in place means a push costs each client one array map, no network. It * also fixes the refresh-jump: the live state and a post-refresh REST fetch now * derive isOpenNow/phase from the same server fields, so they agree. * * A push for a schedule not present in any cached list (a brand-new window) can't * be patched in — those fall back to a debounced invalidate so the new row still * appears, without the storm. */ export function useBookingWindowSocket(enabled: boolean) { const qc = useQueryClient(); useEffect(() => { if (!enabled) return; const token = getAuthToken(); 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 "unknown schedule → refetch" fallback so a burst of pushes // for new schedules triggers at most one invalidation per window. let refetchTimer: ReturnType | null = null; const scheduleRefetch = () => { if (refetchTimer) return; refetchTimer = setTimeout(() => { refetchTimer = null; void qc.invalidateQueries({ predicate: (q) => { const [prefix, action] = q.queryKey as unknown[]; return prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action)); }, }); }, 800); }; socket.on( BOOKING_WINDOW_WS_EVENTS.PHASE, (event: BookingWindowPhaseEvent) => { let patchedSomewhere = false; qc.setQueriesData( { predicate: (q) => { const [prefix, action] = q.queryKey as unknown[]; return ( prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action)) ); }, }, (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; }, ); // The schedule wasn't in any cached list — a newly announced window (or a // lane the client hasn't fetched). Fall back to a debounced refetch so it // surfaces, without the per-push stampede that patching avoids. if (!patchedSomewhere) scheduleRefetch(); }, ); return () => { if (refetchTimer) clearTimeout(refetchTimer); socket.off(); socket.disconnect(); }; }, [enabled, qc]); }