Browse Source

fix(spoolbuddy): tare banner now resolves to complete or timed-out (#1536)

  The TARE button on Settings → Scale set a "Tare command sent. Waiting
  for device..." banner with no mechanism to clear it. The daemon writes
  back through /calibration/set-tare which stamps last_calibrated_at on
  the device row, but handleTare was set-and-forget — the banner stayed
  forever. The "Calibration complete!" success banner had the same shape.

  Snapshot last_calibrated_at when TARE is pressed, set an awaiting state,
  invalidate the device-list query every 1s while waiting (so detection
  responds within ~1s, not the 10s background poll), and when the snapshot
  advances flip the banner to "Tare complete!" with a 3s auto-dismiss. A
  15s timeout falls open to "Tare timed out — is the SpoolBuddy daemon
  running?" so a dead daemon doesn't trap the user on the spinner. The
  calibration-complete and calibration-failed banners now share the same
  auto-dismiss helper.
maziggy 2 days ago
parent
commit
d0ff6f7dc1

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -5430,6 +5430,8 @@ export default {
       calibrateNow: 'Kalibrieren',
       calibrated: 'Kalibriert',
       tareSet: 'Tara-Befehl gesendet. Warte auf Gerät...',
+      tareComplete: 'Tara abgeschlossen!',
+      tareTimedOut: 'Tara-Zeitüberschreitung — läuft der SpoolBuddy-Daemon?',
       tareFailed: 'Tara-Befehl fehlgeschlagen',
       zeroSet: 'Nullpunkt gesetzt. Bekanntes Gewicht auf die Waage legen.',
       calibrationDone: 'Kalibrierung abgeschlossen!',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -5443,6 +5443,8 @@ export default {
       calibrateNow: 'Calibrate',
       calibrated: 'Calibrated',
       tareSet: 'Tare command sent. Waiting for device...',
+      tareComplete: 'Tare complete!',
+      tareTimedOut: 'Tare timed out — is the SpoolBuddy daemon running?',
       tareFailed: 'Failed to send tare command',
       zeroSet: 'Zero point set. Place known weight on scale.',
       calibrationDone: 'Calibration complete!',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -5439,6 +5439,8 @@ export default {
       calibrateNow: 'Calibrar',
       calibrated: 'Calibrada',
       tareSet: 'Comando de tara enviado. Esperando al dispositivo...',
+      tareComplete: '¡Tara completada!',
+      tareTimedOut: 'Tara agotó el tiempo de espera — ¿está activo el daemon de SpoolBuddy?',
       tareFailed: 'Error al enviar el comando de tara',
       zeroSet: 'Punto cero establecido. Coloque el peso conocido en la báscula.',
       calibrationDone: '¡Calibración completada!',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -5407,6 +5407,8 @@ export default {
       calibrateNow: 'Calibrer',
       calibrated: 'Calibré',
       tareSet: 'Commande de tare envoyée. En attente de l\'appareil...',
+      tareComplete: 'Tare terminée !',
+      tareTimedOut: 'Délai de tare dépassé — le daemon SpoolBuddy fonctionne-t-il ?',
       tareFailed: 'Échec de l\'envoi de la commande de tare',
       zeroSet: 'Point zéro défini. Placez le poids connu sur la balance.',
       calibrationDone: 'Calibration terminée !',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -5406,6 +5406,8 @@ export default {
       calibrateNow: 'Calibra',
       calibrated: 'Calibrato',
       tareSet: 'Comando tara inviato. In attesa del dispositivo...',
+      tareComplete: 'Tara completata!',
+      tareTimedOut: 'Tempo scaduto per la tara — il demone SpoolBuddy è in esecuzione?',
       tareFailed: 'Invio comando tara fallito',
       zeroSet: 'Punto zero impostato. Posizionare il peso noto sulla bilancia.',
       calibrationDone: 'Calibrazione completata!',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -5418,6 +5418,8 @@ export default {
       calibrateNow: 'キャリブレーション',
       calibrated: 'キャリブレーション済み',
       tareSet: '風袋コマンドを送信しました。デバイスを待っています...',
+      tareComplete: '風袋が完了しました!',
+      tareTimedOut: '風袋がタイムアウトしました — SpoolBuddy デーモンは起動していますか?',
       tareFailed: '風袋コマンドの送信に失敗しました',
       zeroSet: 'ゼロ点を設定しました。既知の重量を計量台に置いてください。',
       calibrationDone: 'キャリブレーション完了!',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5406,6 +5406,8 @@ export default {
       calibrateNow: 'Calibrar',
       calibrated: 'Calibrado',
       tareSet: 'Comando de tara enviado. Aguardando dispositivo...',
+      tareComplete: 'Tara concluída!',
+      tareTimedOut: 'Tempo esgotado para a tara — o daemon do SpoolBuddy está em execução?',
       tareFailed: 'Falha ao enviar comando de tara',
       zeroSet: 'Ponto zero definido. Coloque o peso conhecido na balança.',
       calibrationDone: 'Calibração concluída!',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5418,6 +5418,8 @@ export default {
       calibrateNow: '校准',
       calibrated: '已校准',
       tareSet: '去皮命令已发送。等待设备响应...',
+      tareComplete: '去皮完成!',
+      tareTimedOut: '去皮超时 — SpoolBuddy 守护进程是否正在运行?',
       tareFailed: '发送去皮命令失败',
       zeroSet: '零点已设置。将已知重量放在秤上。',
       calibrationDone: '校准完成!',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5418,6 +5418,8 @@ export default {
       calibrateNow: '校準',
       calibrated: '已校準',
       tareSet: '去皮命令已傳送。等待裝置回應...',
+      tareComplete: '去皮完成!',
+      tareTimedOut: '去皮逾時 — SpoolBuddy 守護程序是否正在執行?',
       tareFailed: '傳送去皮命令失敗',
       zeroSet: '零點已設定。將已知重量放在磅秤上。',
       calibrationDone: '校準完成!',

+ 69 - 1
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useOutletContext } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
@@ -383,11 +383,70 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
   rawAdc: number | null;
 }) {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
   const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
   const [knownWeight, setKnownWeight] = useState('500');
   const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);
   const [busy, setBusy] = useState(false);
   const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null);
+  // Snapshot of device.last_calibrated_at taken when a tare is dispatched.
+  // The completion watcher below polls the device list and flips the banner
+  // to "Tare complete!" (or "Tare timed out") once the daemon writes back
+  // a new last_calibrated_at. Without this watcher the banner just sat at
+  // "Waiting for device..." forever (#1536).
+  const [awaitingTareSince, setAwaitingTareSince] = useState<{ snapshot: string | null; startedAtMs: number } | null>(null);
+  const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  // Clear any pending auto-dismiss when status changes manually or on unmount.
+  useEffect(() => {
+    return () => {
+      if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
+    };
+  }, []);
+
+  const scheduleStatusDismiss = useCallback((ms: number) => {
+    if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
+    dismissTimerRef.current = setTimeout(() => setStatus(null), ms);
+  }, []);
+
+  // Tare-completion watcher: aggressively re-fetch device data while waiting,
+  // detect when last_calibrated_at advances past the snapshot, then settle
+  // the banner. Fails open on a 15s timeout so a dead daemon doesn't leave
+  // the user staring at "Waiting for device..." indefinitely.
+  useEffect(() => {
+    if (!awaitingTareSince) return;
+    const TARE_TIMEOUT_MS = 15000;
+    const POLL_INTERVAL_MS = 1000;
+    const elapsed = () => Date.now() - awaitingTareSince.startedAtMs;
+
+    const pollHandle = setInterval(() => {
+      queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
+    }, POLL_INTERVAL_MS);
+    const timeoutHandle = setTimeout(() => {
+      setAwaitingTareSince(null);
+      setStatus({
+        type: 'error',
+        msg: t('spoolbuddy.settings.tareTimedOut', 'Tare timed out — is the SpoolBuddy daemon running?'),
+      });
+      scheduleStatusDismiss(5000);
+    }, Math.max(0, TARE_TIMEOUT_MS - elapsed()));
+
+    return () => {
+      clearInterval(pollHandle);
+      clearTimeout(timeoutHandle);
+    };
+  }, [awaitingTareSince, queryClient, t, scheduleStatusDismiss]);
+
+  // When fresh device data arrives, check whether last_calibrated_at moved.
+  useEffect(() => {
+    if (!awaitingTareSince) return;
+    const current = device?.last_calibrated_at ?? null;
+    if (current !== awaitingTareSince.snapshot) {
+      setAwaitingTareSince(null);
+      setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareComplete', 'Tare complete!') });
+      scheduleStatusDismiss(3000);
+    }
+  }, [device?.last_calibrated_at, awaitingTareSince, t, scheduleStatusDismiss]);
 
   const numpadPress = (key: string) => {
     if (key === 'backspace') {
@@ -402,11 +461,18 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
   const handleTare = async () => {
     setBusy(true);
     setStatus(null);
+    if (dismissTimerRef.current) {
+      clearTimeout(dismissTimerRef.current);
+      dismissTimerRef.current = null;
+    }
+    const snapshot = device?.last_calibrated_at ?? null;
     try {
       await spoolbuddyApi.tare(device.device_id);
       setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') });
+      setAwaitingTareSince({ snapshot, startedAtMs: Date.now() });
     } catch {
       setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
+      scheduleStatusDismiss(5000);
     } finally {
       setBusy(false);
     }
@@ -434,9 +500,11 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
       try {
         await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
         setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') });
+        scheduleStatusDismiss(3000);
         setCalStep('idle');
       } catch {
         setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') });
+        scheduleStatusDismiss(5000);
       } finally {
         setBusy(false);
       }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-anb_3VUN.js


+ 1 - 1
static/index.html

@@ -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-B9TjqVXt.js"></script>
+    <script type="module" crossorigin src="/assets/index-anb_3VUN.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff