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
+2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
/**
* Tests for useDispatchedPrinterIds — the hook that exposes printer IDs with
* a queued/active background-dispatch job so PrinterSelector can grey them
* out between dispatch-accepted and the printer's PRINT_START report.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useDispatchedPrinterIds,
__resetDispatchedPrinterIdsForTests,
} from '../../hooks/useDispatchedPrinterIds';
function fire(detail: Record<string, unknown>) {
act(() => {
window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
});
}
describe('useDispatchedPrinterIds', () => {
beforeEach(() => {
__resetDispatchedPrinterIdsForTests();
});
afterEach(() => {
__resetDispatchedPrinterIdsForTests();
});
it('returns an empty set initially', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
expect(result.current.size).toBe(0);
});
it('picks up printer IDs from dispatched_jobs', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({
dispatched_jobs: [
{ job_id: 1, printer_id: 42, printer_name: 'Farm-A' },
],
active_jobs: [],
});
expect(result.current.has(42)).toBe(true);
expect(result.current.size).toBe(1);
});
it('picks up printer IDs from active_jobs', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({
dispatched_jobs: [],
active_jobs: [
{ job_id: 1, printer_id: 7, printer_name: 'Farm-B' },
],
});
expect(result.current.has(7)).toBe(true);
});
it('unions both lists', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({
dispatched_jobs: [{ job_id: 1, printer_id: 1 }],
active_jobs: [{ job_id: 2, printer_id: 2 }],
});
expect(result.current.size).toBe(2);
expect(result.current.has(1)).toBe(true);
expect(result.current.has(2)).toBe(true);
});
it('clears printers when subsequent event reports no jobs', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({ dispatched_jobs: [{ job_id: 1, printer_id: 9 }], active_jobs: [] });
expect(result.current.has(9)).toBe(true);
fire({ dispatched_jobs: [], active_jobs: [] });
expect(result.current.size).toBe(0);
});
it('ignores jobs without a numeric printer_id', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({
dispatched_jobs: [
{ job_id: 1, printer_id: 'not-a-number' },
{ job_id: 2 },
{ job_id: 3, printer_id: 5 },
],
active_jobs: [],
});
expect(result.current.size).toBe(1);
expect(result.current.has(5)).toBe(true);
});
it('keeps snapshot reference stable when content is unchanged', () => {
const { result } = renderHook(() => useDispatchedPrinterIds());
fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
const first = result.current;
fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
expect(result.current).toBe(first);
});
it('shares state across hook instances', () => {
const a = renderHook(() => useDispatchedPrinterIds());
const b = renderHook(() => useDispatchedPrinterIds());
fire({ dispatched_jobs: [{ printer_id: 11 }], active_jobs: [] });
expect(a.result.current.has(11)).toBe(true);
expect(b.result.current.has(11)).toBe(true);
});
});
@@ -12,6 +12,7 @@ import {
Users,
} from 'lucide-react';
import { api, type PrinterStatus } from '../../api/client';
import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds';
import { getColorName } from '../../utils/colors';
import {
normalizeColorForCompare,
@@ -255,7 +256,14 @@ export function PrinterSelector({
return map;
}, [activePrinters, statusQueries]);
// Printers with a queued/active background dispatch — accepted by Bambuddy
// but not yet reflected in PrinterStatus.state (which only flips on
// PRINT_START from the printer itself). Backend rejects double-sends with
// 409 anyway; this just stops the operator from picking them in the modal.
const dispatchedPrinterIds = useDispatchedPrinterIds();
const isPrinterBusy = (printerId: number): boolean => {
if (dispatchedPrinterIds.has(printerId)) return true;
const status = printerStatusMap.get(printerId);
if (!status) return false; // Unknown state — don't block
if (!status.connected) return true;
@@ -263,6 +271,7 @@ export function PrinterSelector({
};
const getPrinterStateLabel = (printerId: number): string | null => {
if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...';
const status = printerStatusMap.get(printerId);
if (!status) return null;
if (!status.connected) return 'Offline';
@@ -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;
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BUDdM_BW.js"></script>
<script type="module" crossorigin src="/assets/index-BdzWqWAS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7mua31Zk.css">
</head>
<body>