import { useState, useEffect, useMemo, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { useToast } from '../../contexts/ToastContext';
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
function normalizeHexTag(value: string | null | undefined): string {
if (!value) return '';
return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
}
function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
const aNorm = normalizeHexTag(a);
const bNorm = normalizeHexTag(b);
if (!aNorm || !bNorm) return false;
if (aNorm === bNorm) return true;
// Some readers report shortened UID forms.
return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
}
// Color palette for the cycling spool animation
const SPOOL_COLORS = [
'#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
'#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
];
// --- Idle state with slow color-cycling spool ---
function IdleSpool() {
const { t } = useTranslation();
const [colorIndex, setColorIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length);
}, 5000);
return () => clearInterval(interval);
}, []);
const currentColor = SPOOL_COLORS[colorIndex];
return (
{/* Animated spool with optimized NFC waves */}
{/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
{/* Spool icon with lightweight radial glow */}
{/* Text content */}
{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}
{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}
{/* Subtle hint */}
{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}
);
}
// --- Offline state ---
function DeviceOfflineState() {
const { t } = useTranslation();
return (
{/* Offline icon */}
{t('spoolbuddy.status.deviceOffline', 'Device Offline')}
{t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}
{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
);
}
// --- Main Dashboard ---
export function SpoolBuddyDashboard() {
const { sbState, selectedPrinterId } = useOutletContext();
const { t } = useTranslation();
const { showToast } = useToast();
const { data: spoolmanSettings } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
staleTime: 5 * 60 * 1000,
});
const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
// Fetch spools for stats, tag lookup, and untagged list
const { data: spools = [], refetch: refetchSpools } = useQuery({
queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'],
queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(false) : api.getSpools(false),
enabled: spoolmanSettings !== undefined,
});
// Kiosk caveat: the SpoolBuddy display is a long-running browser window with
// no focus/remount events, so a staleTime alone leaves this cache effectively
// permanent. When slot assignments change from another client (Bambuddy main
// UI, direct Spoolman edit, AssignToAmsModal on a separate browser), the
// kiosk keeps showing the spool as still-assigned forever and isSpoolAssigned
// reports stale, disabling the Assign button. Polling every 3 s is cheap
// (slot-assignments/all is a tiny DB query) and bounds staleness to a window
// operators don't notice.
const { data: spoolmanSlotAssignments = [] } = useQuery({
queryKey: ['spoolman-slot-assignments'],
queryFn: () => api.getSpoolmanSlotAssignments(),
enabled: spoolmanMode,
staleTime: 3 * 1000,
refetchInterval: 3 * 1000,
refetchIntervalInBackground: false,
});
// Fetch printers and their statuses for the status badges
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
const statusQueries = useQueries({
queries: printers.map((printer: Printer) => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
refetchInterval: 10000,
select: (data: PrinterStatus) => ({
connected: data?.connected,
awaiting_plate_clear: data?.awaiting_plate_clear === true,
}),
})),
});
// Plate-clear: collect printers that are waiting for the operator to confirm.
// The kiosk's API key passes the printers:clear_plate gate (not in the
// _APIKEY_DENIED_PERMISSIONS set), so no extra perm wiring is needed here.
const platesPending = printers
.map((printer: Printer, i: number) => ({
printer,
pending: statusQueries[i]?.data?.awaiting_plate_clear === true,
}))
.filter((row: { pending: boolean }) => row.pending);
const queryClient = useQueryClient();
const clearPlateMutation = useMutation({
mutationFn: (printerId: number) => api.clearPlate(printerId),
onSuccess: (_data, printerId) => {
// Optimistically clear the flag so the row vanishes immediately; the
// backend already broadcasts a printer_status WS event after clearing,
// but we don't want the user to see the row linger while that round-trips.
queryClient.setQueryData(['printerStatus', printerId], (old: PrinterStatus | undefined) =>
old ? { ...old, awaiting_plate_clear: false } : old
);
showToast(t('spoolbuddy.dashboard.plateClearedToast', 'Plate marked as cleared'), 'success');
},
onError: () => {
showToast(t('spoolbuddy.dashboard.plateClearFailed', 'Could not mark plate as cleared'), 'error');
},
});
const unassignSpoolMutation = useMutation({
mutationFn: (spoolId: number) => api.unassignSpoolmanSlot(spoolId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
void queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
},
onError: () => showToast(t('inventory.unassignFailed', 'Failed to unassign spool'), 'error'),
});
// Current Spool card state - persists until user closes or new tag detected
const [displayedTagId, setDisplayedTagId] = useState(null);
const [displayedWeight, setDisplayedWeight] = useState(null);
const [hiddenTagId, setHiddenTagId] = useState(null);
const [showLinkModal, setShowLinkModal] = useState(false);
const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
const [showQuickAddModal, setShowQuickAddModal] = useState(false);
const [quickAddBusy, setQuickAddBusy] = useState(false);
const [justLinkedSpool, setJustLinkedSpool] = useState | null>(null);
// Track current tag from state
const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
const currentWeight = sbState.weight;
const weightStable = sbState.weightStable;
// Stabilized scale display: only update when change exceeds threshold to prevent bouncing
const stableDisplayWeight = useRef(null);
const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this
if (currentWeight === null) {
stableDisplayWeight.current = null;
} else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {
stableDisplayWeight.current = currentWeight;
}
const scaleDisplayValue = stableDisplayWeight.current;
// Find spool by tag_id in the loaded spools list
const displayedSpool = useMemo((): InventorySpool | null => {
if (sbState.matchedSpool?.id) {
const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
if (byId) return byId;
}
if (!displayedTagId) return null;
const byTag = spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId));
if (byTag) return byTag;
// When a Bambu tray UUID (32-char) is linked, Spoolman stores it in extra.tag and
// _map_spoolman_spool routes it to tray_uuid, not tag_uid. tagsEquivalent only
// compares tag_uid, so it misses this spool until the device re-scans and
// sbState.matchedSpool is populated. Hold the spool returned by the link call
// as a temporary bridge. Cast is safe: SpoolInfoCard only reads the 9 fields
// present in MatchedSpool; AssignToAmsModal is guarded by !justLinkedSpool below.
if (justLinkedSpool) return justLinkedSpool as unknown as InventorySpool;
return null;
}, [displayedTagId, sbState.matchedSpool, spools, justLinkedSpool]);
// Effective spool for the Assign-to-AMS modal: prefer the fully-typed
// InventorySpool from the local query cache, fall back to the
// WebSocket-delivered MatchedSpool when the cached query hasn't caught up
// (Spoolman spool added or unarchived after the dashboard loaded — the
// initial fetch with includeArchived=false misses it). Without this
// fallback the SpoolInfoCard renders via its own
// `displayedSpool ?? sbState.matchedSpool` path while the modal's stricter
// guard silently fails to mount, so the "Assign to AMS" button looks
// clickable but does nothing on click. MatchedSpool is a 9-field subset
// of InventorySpool — slicer_filament* are absent, which is acceptable:
// the modal's mismatch check yields 'none' for profile (same as a manual
// inventory spool without a preset), and the assign API only needs the
// spool id to route to the correct row.
const effectiveModalSpool: InventorySpool | null = useMemo(() => {
if (displayedSpool && !justLinkedSpool) return displayedSpool;
const m = sbState.matchedSpool;
if (!m) return null;
return {
id: m.id,
tag_uid: m.tag_uid,
material: m.material,
subtype: m.subtype,
color_name: m.color_name,
rgba: m.rgba,
brand: m.brand,
label_weight: m.label_weight,
core_weight: m.core_weight,
weight_used: m.weight_used,
} as unknown as InventorySpool;
}, [displayedSpool, justLinkedSpool, sbState.matchedSpool]);
const isSpoolAssigned = spoolmanMode && effectiveModalSpool != null
? spoolmanSlotAssignments.some(a => a.spoolman_spool_id === effectiveModalSpool.id)
: false;
// Untagged spools for the Link feature
const untaggedSpools = useMemo(() => {
return spoolmanMode
? spools.filter((s) => !s.tag_uid && !s.tray_uuid && !s.archived_at)
: spools.filter((s) => !s.tag_uid && !s.archived_at);
}, [spools, spoolmanMode]);
// Handle tag detection - show card when tag detected, keep until user closes or new tag
useEffect(() => {
if (currentTagId) {
const isHidden = hiddenTagId === currentTagId;
const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;
if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
setDisplayedTagId(currentTagId);
setDisplayedWeight(null);
setHiddenTagId(null);
setJustLinkedSpool(null);
}
// Update weight when stable and card is visible
if (!isHidden && currentWeight !== null && weightStable) {
setDisplayedWeight(Math.round(Math.max(0, currentWeight)));
}
} else {
// Tag removed - clear hidden state so same tag can show when re-placed
if (hiddenTagId) {
setDisplayedTagId(null);
setHiddenTagId(null);
setDisplayedWeight(null);
setJustLinkedSpool(null);
}
}
}, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
// Auto-sync weight once when known spool first detected
const handleCloseSpoolCard = () => {
setHiddenTagId(displayedTagId);
};
const handleLinkTagToSpool = async (spool: InventorySpool) => {
if (!displayedTagId) return;
try {
if (spoolmanMode) {
const tag_uid = sbState.unknownTagUid || undefined;
const tray_uuid = (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined;
if (!tag_uid && !tray_uuid) {
showToast(t('spoolman.linkFailed'), 'error');
return;
}
const raw = await api.linkTagToSpoolmanSpool(spool.id, { tray_uuid, tag_uid });
const updated = raw as InventorySpool | undefined;
if (!updated) {
showToast(t('spoolman.linkFailed'), 'error');
return;
}
const { id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used } = updated;
setJustLinkedSpool({ id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used });
showToast(t('spoolman.linkSuccess'), 'success');
} else {
await api.linkTagToSpool(spool.id, {
tag_uid: displayedTagId,
tag_type: 'generic',
data_origin: 'nfc_link',
});
}
refetchSpools();
} catch (e) {
console.error('Failed to link tag:', e);
showToast(t('spoolman.linkFailed'), 'error');
} finally {
setShowLinkModal(false);
}
};
const handleQuickAddToInventory = async () => {
if (!displayedTagId) return;
setQuickAddBusy(true);
try {
const weight = liveWeight ?? displayedWeight;
if (spoolmanMode) {
const created = await api.createSpoolmanInventorySpool({
material: 'PLA',
subtype: null,
color_name: null,
rgba: null,
extra_colors: null,
effect_type: null,
brand: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: null,
tag_type: null,
cost_per_kg: null,
last_scale_weight: weight !== null ? Math.round(weight) : null,
last_weighed_at: weight !== null ? new Date().toISOString() : null,
category: null,
low_stock_threshold_pct: null,
});
await api.linkTagToSpoolmanSpool(created.id, {
tag_uid: sbState.unknownTagUid || undefined,
tray_uuid: (!sbState.unknownTagUid && sbState.unknownTrayUuid) ? sbState.unknownTrayUuid : undefined,
});
} else {
await api.createSpool({
material: 'PLA',
subtype: null,
color_name: null,
rgba: null,
extra_colors: null,
effect_type: null,
brand: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: displayedTagId,
tray_uuid: null,
data_origin: 'spoolbuddy',
tag_type: 'generic',
cost_per_kg: null,
last_scale_weight: weight !== null ? Math.round(weight) : null,
last_weighed_at: weight !== null ? new Date().toISOString() : null,
category: null,
low_stock_threshold_pct: null,
});
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Failed to quick-add spool:', msg);
showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');
} finally {
setShowQuickAddModal(false);
setQuickAddBusy(false);
refetchSpools();
}
};
// For unknown tags, use live weight or stored displayed weight
const useScaleWeight = currentWeight !== null &&
(currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
const liveWeight = useScaleWeight ? currentWeight : null;
// Stats
const totalSpools = spools.length;
const materials = new Set(spools.map((s) => s.material)).size;
const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
return (
{/* Compact stats bar */}
{totalSpools}
{t('spoolbuddy.inventory.spools', 'Spools')}
{materials}
{t('spoolbuddy.spool.material', 'Materials')}
{brands}
{t('spoolbuddy.spool.brand', 'Brands')}
{/* Main content: Device (left) + Current Spool (right) */}
{/* Left column */}
{/* Device card */}
{t('spoolbuddy.dashboard.device', 'Device')}
{/* Connection status */}
{sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
{/* Scale weight */}
{t('spoolbuddy.spool.scaleWeight', 'Scale')}
{scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
{/* NFC status */}
{currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
{/* Printer status badges */}
{printers.length > 0 && (
{t('spoolbuddy.dashboard.printers', 'Printers')}
{printers.map((printer: Printer, i: number) => {
const isOnline = statusQueries[i]?.data?.connected ?? false;
return (
);
})}
{/* Plate-ready pills — same compact size as the printer badges above so
the row stays scannable when multiple printers finish at once.
Wraps via flex-wrap. Each pill is independently tappable. */}
{platesPending.length > 0 && (
{platesPending.map(({ printer }: { printer: Printer }) => (
clearPlateMutation.mutate(printer.id)}
disabled={clearPlateMutation.isPending}
data-testid={`plate-clear-button-${printer.id}`}
title={t('spoolbuddy.dashboard.plateReady', 'Plate ready: {{name}}', { name: printer.name })}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 active:bg-amber-500/30 border border-amber-500/30 text-amber-200 transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{printer.name}
·
{t('spoolbuddy.dashboard.plateClearAction', 'Clear')}
))}
)}
)}
{/* Right column: Current Spool */}
{t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
{!sbState.deviceOnline ? (
) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
{
const s = displayedSpool ?? sbState.matchedSpool!;
return {
id: s.id,
tag_uid: displayedTagId,
material: s.material,
subtype: s.subtype,
color_name: s.color_name,
rgba: s.rgba,
brand: s.brand,
label_weight: s.label_weight,
core_weight: s.core_weight,
weight_used: s.weight_used,
};
})()}
scaleWeight={liveWeight ?? displayedWeight}
onSyncWeight={() => refetchSpools()}
onAssignToAms={() => setShowAssignAmsModal(true)}
isAssigned={isSpoolAssigned}
onUnassignFromAms={
(isSpoolAssigned && displayedSpool?.id != null)
? () => unassignSpoolMutation.mutate(displayedSpool!.id)
: undefined
}
onClose={handleCloseSpoolCard}
/>
) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
0 ? () => setShowLinkModal(true) : undefined}
onAddToInventory={() => setShowQuickAddModal(true)}
onClose={handleCloseSpoolCard}
/>
) : (
)}
{/* Assign to AMS Modal — uses effectiveModalSpool which falls back to
sbState.matchedSpool when the cached inventory query hasn't caught up
to the matched spool (newly-added or unarchived in Spoolman). The
!justLinkedSpool guard still excludes the freshly-linked synthetic
spool because that path goes through a different flow. */}
{effectiveModalSpool && !justLinkedSpool && displayedTagId && (
setShowAssignAmsModal(false)}
spool={effectiveModalSpool}
printerId={selectedPrinterId}
spoolmanMode={spoolmanMode}
/>
)}
{/* Link Tag to Spool Modal */}
{displayedTagId && (
setShowLinkModal(false)}
tagId={displayedTagId}
untaggedSpools={untaggedSpools}
onLink={handleLinkTagToSpool}
/>
)}
{/* Quick-add to Inventory Modal */}
{showQuickAddModal && displayedTagId && (
{t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
{/* Hint */}
{t('spoolbuddy.modal.quickAddHint', 'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use "Assign Spool" here to assign the NFC tag.')}
{t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}
{displayedTagId}
setShowQuickAddModal(false)}
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
>
{t('common.cancel', 'Cancel')}
{quickAddBusy ? t('common.saving', 'Saving...') : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}
)}
);
}