implement batch board notification system for schedule changes and updates

This commit is contained in:
Marshal
2026-07-12 12:01:05 +00:00
parent 4b7f6d2548
commit 84ba56f7d0
10 changed files with 195 additions and 32 deletions

View File

@@ -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}
</Stack>
);
}
});
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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<BatchBoardBookingDetail>[] = [
},
];
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 (
<DataTable
columns={BOOKING_COLUMNS}
@@ -387,7 +395,7 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
);
}
});
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() {
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
onChanged={handleBookingsChanged}
readOnly={bookingsReadOnly}
/>
</Paper>

View File

@@ -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({
</Modal>
</Stack>
);
}
});
export default BookingsManager;

View File

@@ -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).