From 84ba56f7d0106bd4f96c7cc94342c6c8c47088a8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 12 Jul 2026 12:01:05 +0000 Subject: [PATCH] implement batch board notification system for schedule changes and updates --- .../train-scheduling/booking-batch.service.ts | 83 ++++++++++++++++++- .../booking-window.gateway.ts | 15 ++++ .../booking-window.service.ts | 10 +++ .../trainScheduling/PriorityTrackingTab.tsx | 12 ++- .../bookingWindows/useBookingWindowSocket.ts | 30 ++++++- .../pages/trainScheduling/BatchBoardPage.tsx | 8 +- .../BatchScheduleDetailPage.tsx | 36 ++++---- .../pages/trainScheduling/BookingsManager.tsx | 9 +- .../TrainScheduleV2ListPage.tsx | 11 ++- .../types/src/freight/booking-window-ws.ts | 13 +++ 10 files changed, 195 insertions(+), 32 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 719bce1ac..c25a30f03 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -532,6 +532,7 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, ); } + this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated"); } /** Customer paid — delegate to ensurePaidBookingAllocated. */ @@ -766,6 +767,7 @@ export class BookingBatchService implements OnModuleInit { if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(scheduleId, 'FULL'); } + this.notifyBoardChanged(scheduleId, 'export_booking_accepted'); } /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ @@ -778,6 +780,9 @@ export class BookingBatchService implements OnModuleInit { `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); } + if (unlinked.length > 0) { + this.notifyBoardChanged(scheduleId, "paid_reconciled"); + } } // ---- legacy fill entry point ---------------------------------------------- @@ -1159,7 +1164,12 @@ export class BookingBatchService implements OnModuleInit { /** Run wagon-level allocation for all eligible linked bookings on a schedule. */ async runWagonAllocation(scheduleId: string) { - return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + const result = + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + if (result.assignedBookingIds.length > 0) { + this.notifyBoardChanged(scheduleId, "wagon_allocation_run"); + } + return result; } /** @@ -1329,6 +1339,7 @@ export class BookingBatchService implements OnModuleInit { this.resortPoolByPriority(pool); const units = this.groupConsolidatedPool(pool); let armed = false; + let preempted = false; let reservedThisPass = 0; let commercialReserved = 0; @@ -1367,6 +1378,7 @@ export class BookingBatchService implements OnModuleInit { budget, wagonDims, ); + preempted = true; if (!freed) continue; // still doesn't fit even after preempt } else { // Doesn't fit whole. A split-eligible import booking is offered the part @@ -1415,6 +1427,10 @@ export class BookingBatchService implements OnModuleInit { ); if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); + // One push per fill pass (never per booking) — only when rows changed. + if (reservedThisPass > 0 || armed || preempted) { + this.notifyBoardChanged(scheduleId, "batch_fill"); + } void this.triggerWagonAllocation(scheduleId); return commercialReserved; } @@ -1515,8 +1531,13 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); - // Live per-schedule corridor budget + arm flag, in departure order. - const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; + // Live per-schedule corridor budget + arm/changed flags, in departure order. + const trains: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + changed: boolean; + }> = []; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1530,7 +1551,7 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false }); + trains.push({ id, budget, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1603,6 +1624,9 @@ export class BookingBatchService implements OnModuleInit { t.budget, wagonDims, ); + // Preempt may have displaced (expired) victims even when the need + // still doesn't fit — the board must refresh either way. + t.changed = true; if (freed) { target = t; break; @@ -1647,6 +1671,7 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + target.changed = true; reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1664,6 +1689,10 @@ export class BookingBatchService implements OnModuleInit { for (const t of trains) { if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); + // One push per touched train per pass (never per booking). `armed` covers + // commercial reserves + partial offers; `changed` covers gov allocations + // and preemption. + if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill"); void this.triggerWagonAllocation(t.id); } @@ -1910,6 +1939,10 @@ export class BookingBatchService implements OnModuleInit { `— payment phase extended for them`, ); } + // Emitted here (not in settleDueReservations/settleBatch, which both wrap + // this) so one settle produces one push, after every allocation/expiry/ + // top-up extension for this schedule has been persisted. + this.notifyBoardChanged(scheduleId, "reservations_settled"); return true; } @@ -1962,6 +1995,21 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Announce that a schedule's batch-board data changed so open boards refetch. + * Called AFTER the state change is persisted; a push failure only logs — it + * must never break the business transaction that triggered it. + */ + private notifyBoardChanged(scheduleId: string, reason: string): void { + try { + this.bookingWindowGateway.emitBatchChanged(scheduleId, reason); + } catch (err) { + this.logger.warn( + `Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + // ---- staff override actions ---------------------------------------------- /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */ @@ -1987,6 +2035,7 @@ export class BookingBatchService implements OnModuleInit { await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); + this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); } /** @@ -2021,6 +2070,7 @@ export class BookingBatchService implements OnModuleInit { ); } + const sourceScheduleId = booking.trainScheduleId ?? null; await this.dataSource.transaction(async (manager) => { if (booking.trainScheduleId) { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( @@ -2043,6 +2093,11 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); }); + // Both boards changed: the booking left the source train and joined the target. + if (sourceScheduleId && sourceScheduleId !== newScheduleId) { + this.notifyBoardChanged(sourceScheduleId, "booking_moved"); + } + this.notifyBoardChanged(newScheduleId, "booking_moved"); } /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ @@ -2060,6 +2115,8 @@ export class BookingBatchService implements OnModuleInit { if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(freedScheduleId); } + // After the top-up + phase extension so one push carries the final state. + this.notifyBoardChanged(freedScheduleId, "reservation_expired"); } } @@ -2099,10 +2156,12 @@ export class BookingBatchService implements OnModuleInit { .update(booking.id, { trainScheduleId: scheduleId }); booking.trainScheduleId = scheduleId; await this.allocate(scheduleId, booking, 'gov'); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } // ---- mutations ------------------------------------------------------------ @@ -2365,7 +2424,12 @@ export class BookingBatchService implements OnModuleInit { day, ); const leftovers = pool.filter((b) => !b.isGovernment); + // Capture pinned schedules BEFORE expire() clears trainScheduleId, so each + // touched board gets exactly one push at the end of the sweep. + const touchedScheduleIds = new Set(); + if (leftovers.length) touchedScheduleIds.add(scheduleId); for (const booking of leftovers) { + if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.expire(booking, "no-capacity"); } if (leftovers.length) { @@ -2374,6 +2438,9 @@ export class BookingBatchService implements OnModuleInit { `expired ${leftovers.length} waiting booking(s)`, ); } + for (const id of touchedScheduleIds) { + this.notifyBoardChanged(id, "day_pool_expired"); + } return leftovers.length; } @@ -2436,7 +2503,12 @@ export class BookingBatchService implements OnModuleInit { `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, ); } + // Only bookings pinned to a train show on a board — collect their schedules + // and push once per schedule after the sweep (most unaccepted rows are + // unpinned under day-level pooling, so this usually emits nothing). + const touchedScheduleIds = new Set(); for (const booking of unaccepted) { + if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", @@ -2453,6 +2525,9 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, ); } + for (const id of touchedScheduleIds) { + this.notifyBoardChanged(id, "unaccepted_expired"); + } } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index 699471d90..69e60dd3a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -1,6 +1,7 @@ import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE, + type BatchBoardChangedEvent, type BookingWindowPhaseEvent, } from '@edr/types'; import { Logger } from '@nestjs/common'; @@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection { this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); } + /** + * Announce that a schedule's batch-board data changed (payment, allocation, + * fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries + * no board data, only the scheduleId; clients holding that board refetch. + */ + emitBatchChanged(scheduleId: string, reason: string): void { + const payload: BatchBoardChangedEvent = { + scheduleId, + reason, + at: new Date().toISOString(), + }; + this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload); + } + private extractToken(socket: Socket): string | undefined { const authToken = socket.handshake.auth?.token as string | undefined; if (authToken) return authToken; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 25a7b1aed..d3405c951 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit { .update(s.id, { docReviewCompletedAt: now }); s.docReviewCompletedAt = now; await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now); + // The batch fill emits only for trains it actually reserved onto — this + // covers the empty-pool case so every open board still refetches. Push + // failures only log; the doc-review completion itself already persisted. + try { + this.gateway.emitBatchChanged(s.id, 'doc_review_completed'); + } catch (err) { + this.logger.warn( + `Batch-board push failed for ${s.id}: ${(err as Error).message}`, + ); + } } const fresh = await this.trainSchedulesRepository.findById(scheduleId); return fresh ?? schedule; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index b02ebbc8f..23b654a55 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { Box, Group, @@ -267,7 +267,13 @@ function CapacityDivider({ used, max }: { used: number; max: number | null }) { ); } -export function PriorityTrackingTab({ data, bookings }: Props) { +// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every +// page render; both props keep their identity across unrelated page state +// (React Query structural sharing + the page's useMemo'd bookings). +export const PriorityTrackingTab = memo(function PriorityTrackingTab({ + data, + bookings, +}: Props) { const phase = data.windowPhase; const isPayPhase = phase === "PAYMENT"; @@ -558,7 +564,7 @@ export function PriorityTrackingTab({ data, bookings }: Props) { ) : null} ); -} +}); /** Contextual banner describing the current window phase in plain language. */ function PhaseBanner({ phase }: { phase: string | null }) { diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index e6075f06f..d5409584f 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -1,6 +1,7 @@ import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE, + type BatchBoardChangedEvent, type BookingWindowPhaseEvent, } from "@edr/types"; import { useQueryClient } from "@tanstack/react-query"; @@ -82,9 +83,19 @@ export function useBookingWindowSocket(enabled: boolean = 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), - ); + let hadConnected = false; + socket.on("connect", () => { + console.debug("[booking-windows] socket connected", socket.id); + // A RE-connect means pushes may have been missed while offline — refetch + // every board view (list + any open detail share the "batch-board" key + // prefix) once so the gap self-heals immediately. + if (hadConnected) { + void qc.invalidateQueries({ + queryKey: ["train-scheduling", "batch-board"], + }); + } + hadConnected = true; + }); socket.on("connect_error", (err) => console.warn("[booking-windows] socket connect failed:", err.message), ); @@ -144,6 +155,19 @@ export function useBookingWindowSocket(enabled: boolean = true) { }, ); + // Board-data pushes (payment settled, allocation, fill, expiry, …): refetch + // the schedule's detail immediately, refresh the list debounced. The event + // carries no data — the board views are rich, differently-shaped queries. + socket.on( + BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, + (event: BatchBoardChangedEvent) => { + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId), + }); + scheduleRefetch(false); + }, + ); + return () => { if (refetchTimer) clearTimeout(refetchTimer); socket.off(); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index db6a87a54..6520c208e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -55,6 +55,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import type { BatchBoardFilters, BatchBoardSchedule, @@ -424,6 +425,8 @@ function CardSkeleton() { export default function BatchBoardPage() { const navigate = useNavigate(); + // Live board: phase + batch-changed pushes invalidate the list query below. + useBookingWindowSocket(); const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { pagination, setPagination } = usePagination({ pageSize: 12 }); const [search, setSearch] = useState(""); @@ -484,7 +487,10 @@ export default function BatchBoardPage() { const { data, isLoading, isError, isFetching, refetch } = useQuery({ ...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }), - refetchInterval: 30_000, + // Real-time updates come from the booking-window socket (batch-board:changed + // + PHASE pushes invalidate this query); 60s is only a self-heal safety net + // for a missed emit. + refetchInterval: 60_000, placeholderData: keepPreviousData, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 114b2e742..ae75debb1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, @@ -377,7 +377,15 @@ const BOOKING_COLUMNS: ColumnDef[] = [ }, ]; -function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { +// Memoized: the page re-renders on unrelated state (tab switch, composition +// booking selection, background-fetch flags) while the bookings arrays keep +// their identity (useMemo + React Query structural sharing) — skip re-rendering +// the whole table in those cases. +const BookingTable = memo(function BookingTable({ + bookings, +}: { + bookings: BatchBoardBookingDetail[]; +}) { return ( ); -} +}); function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { const chips: Array<{ value: number; color: string; label: string }> = [ @@ -581,18 +589,10 @@ export default function BatchScheduleDetailPage() { api.trainScheduling.batchBoardDetail.queryOptions({ input: { scheduleId: scheduleId ?? "" }, enabled: Boolean(scheduleId), - // Poll fast while a window cycle is actively moving (open / doc-review / - // payment) so the priority ranking + pay countdowns stay live; back off to - // 30s once the cycle is idle (pre-window / closed / done). - refetchInterval: (query) => { - const phase = (query.state.data as BatchBoardScheduleDetail | undefined) - ?.windowPhase; - return phase === "OPEN" || - phase === "DOC_REVIEW" || - phase === "PAYMENT" - ? 5_000 - : 30_000; - }, + // Real-time updates come from the booking-window socket (batch-board:changed + // + PHASE pushes invalidate this query); 60s is only a self-heal safety net + // for a missed emit. + refetchInterval: 60_000, }), ); // Keep the board in sync with server-pushed window-phase transitions too @@ -688,6 +688,10 @@ export default function BatchScheduleDetailPage() { null, ); + // Stable identity so the memoized BookingsManager isn't re-rendered by + // unrelated page state (tab switches, composition selection, fetch flags). + const handleBookingsChanged = useCallback(() => void refetch(), [refetch]); + const handleCompleteDocReview = () => { completeDocReview .mutateAsync(scheduleId ?? "") @@ -1002,7 +1006,7 @@ export default function BatchScheduleDetailPage() { void refetch()} + onChanged={handleBookingsChanged} readOnly={bookingsReadOnly} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BookingsManager.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BookingsManager.tsx index f1eb91ba6..05502a691 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BookingsManager.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BookingsManager.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { ActionIcon, Badge, @@ -118,8 +118,11 @@ export interface BookingsManagerProps { * allocation status, and remove or re-assign bookings individually or in bulk. * Wraps the shared DataTable; selection + actions are handled locally so the * surrounding accordion / tab layout stays untouched. + * + * Memoized: the detail page passes stable props (memoized bookings array + + * useCallback onChanged), so its unrelated re-renders skip this subtree. */ -export function BookingsManager({ +export const BookingsManager = memo(function BookingsManager({ scheduleId, bookings, onChanged, @@ -620,6 +623,6 @@ export function BookingsManager({ ); -} +}); export default BookingsManager; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 059036b1a..e42fc1a0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -49,7 +49,7 @@ import { RouteCorridor, StatusPill, } from "@/components/trainScheduling/scheduleVisuals"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; @@ -169,7 +169,14 @@ export default function TrainScheduleV2ListPage() { ); const schedulesQuery = useQuery( - api.trainScheduling.scheduleList.queryOptions({ input: { filters } }), + api.trainScheduling.scheduleList.queryOptions({ + input: { filters }, + // Keep the previous page on screen while the next page/filter result + // loads instead of flashing the empty state; 30s staleTime spares + // back-and-forth navigation from refetching an unchanged list. + placeholderData: keepPreviousData, + staleTime: 30_000, + }), ); // Yard options for the origin/destination filters (shared routes reference // list, so the choices don't shrink to whatever the current page shows). diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts index 6c48f2c4c..3fd3c4d7b 100644 --- a/packages/types/src/freight/booking-window-ws.ts +++ b/packages/types/src/freight/booking-window-ws.ts @@ -32,9 +32,22 @@ export interface BookingWindowPhaseEvent { scheduledDepartureDate: string | null; } +/** + * Payload pushed whenever server-side state behind a schedule's batch board + * changes outside a phase transition (payment settled, allocation run, batch + * fill, reservation expiry, doc review, …). Carries no board data — clients + * refetch the views they hold; `reason` exists for debugging/telemetry only. + */ +export interface BatchBoardChangedEvent { + scheduleId: string; + reason: string; + at: string; +} + /** Socket.io event names pushed server → client on the booking-windows namespace. */ export const BOOKING_WINDOW_WS_EVENTS = { PHASE: "booking-window:phase", + BATCH_CHANGED: "batch-board:changed", } as const; /** Socket.io namespace the booking-window gateway listens on. */