fix(print-modal): disable printers between dispatch-accept and PRINT_START

Reported off-list by a corporate supporter running a multi-operator
  farm shift. Backend already rejects double-sends with HTTP 409 (see
  background_dispatch._dispatch at lines 283-290), so no double-print
  was possible, but PrinterSelector consulted only PrinterStatus.state
  ({IDLE, FINISH, FAILED}) to decide whether a printer was selectable.
  PRINT_START is the only signal that flips the printer out of IDLE, so
  during the upload + print-command + firmware-ack window the card
  stayed clickable and operators only found out after submitting.

  New useDispatchedPrinterIds hook subscribes to the existing
  background-dispatch WebSocket event (already consumed for the toast
  overlay), collects printer_ids from dispatched_jobs + active_jobs,
  and exposes them via useSyncExternalStore so every PrinterSelector
  instance shares one snapshot. OR'd into isPrinterBusy; badge label
  flips to "Dispatching..." instead of a misleading "Idle".

  No backend change — the reservation already exists, the UI just
  didn't reflect it. No behaviour change for add-to-queue / edit-queue
  modes (disableBusy stays false for those).
This commit is contained in:
maziggy
2026-06-20 20:45:22 +02:00
parent a5fe5cb3d4
commit 37b9bbfaaf
6 changed files with 332 additions and 131 deletions
@@ -0,0 +1,85 @@
/**
* Subscribes to background-dispatch WebSocket events and returns the set of
* printer IDs that currently have a queued or active dispatch job.
*
* Used by PrinterSelector to disable printers between the moment Bambuddy
* accepts a dispatch (FTP upload, print command) and the moment the printer
* itself reports PRINT_START. The backend already rejects double-sends with
* HTTP 409, but the UI gap still let operators pick a printer the server would
* refuse — surfaced by a corporate user running multi-operator farm shifts.
*
* Module-level state + useSyncExternalStore so every PrinterSelector instance
* sees the same snapshot, and component mounts mid-batch pick up the latest
* state without re-fetching.
*/
import { useSyncExternalStore } from 'react';
interface DispatchEventJob {
printer_id?: unknown;
}
interface DispatchEventDetail {
dispatched_jobs?: DispatchEventJob[];
active_jobs?: DispatchEventJob[];
total?: number;
dispatched?: number;
processing?: number;
}
const EMPTY: ReadonlySet<number> = new Set();
let currentSet: ReadonlySet<number> = EMPTY;
const subscribers = new Set<() => void>();
let attached = false;
function recompute(detail: DispatchEventDetail): ReadonlySet<number> {
const next = new Set<number>();
for (const job of detail.dispatched_jobs ?? []) {
if (typeof job.printer_id === 'number') next.add(job.printer_id);
}
for (const job of detail.active_jobs ?? []) {
if (typeof job.printer_id === 'number') next.add(job.printer_id);
}
return next;
}
function handleEvent(event: Event) {
const detail = (event as CustomEvent<DispatchEventDetail>).detail ?? {};
const next = recompute(detail);
// Keep reference stable when content didn't change — useSyncExternalStore
// compares snapshots via Object.is and re-renders on any new reference.
if (next.size === currentSet.size && [...next].every((id) => currentSet.has(id))) {
return;
}
currentSet = next;
subscribers.forEach((cb) => cb());
}
function ensureAttached() {
if (attached || typeof window === 'undefined') return;
window.addEventListener('background-dispatch', handleEvent);
attached = true;
}
const subscribe = (callback: () => void): (() => void) => {
ensureAttached();
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};
const getSnapshot = (): ReadonlySet<number> => currentSet;
export function useDispatchedPrinterIds(): ReadonlySet<number> {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/** Test-only helper — resets the module-level singleton between tests. */
export function __resetDispatchedPrinterIdsForTests(): void {
currentSet = EMPTY;
subscribers.clear();
if (attached && typeof window !== 'undefined') {
window.removeEventListener('background-dispatch', handleEvent);
attached = false;
}
}