mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
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<T extends WindowRow>(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<typeof setTimeout> | 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<WindowRow[]>(
|
|
{ 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]);
|
|
}
|