From 3dc10aa06024e9dd1dfd800d1743c651669caabc Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 15:23:55 -0500 Subject: [PATCH] feat(dashboard): add toast notification system with error tracking Add lightweight toast notification infrastructure that surfaces repeated errors to users while avoiding alert fatigue. Components: - ToastContainer: Renders toast notifications with auto-dismiss - showToast(): Singleton function to display messages from anywhere - trackError(): Counts errors by key, surfaces toast after threshold Error tracking strategy: - Errors logged immediately (console.error) - Toast shown only after 3+ occurrences within 30-second window - Prevents spam from transient network issues - Reset counter on success (clearErrorCount) Toast styling: - Fixed position bottom-right with z-index 100 - Type-aware colors (error=red, success=green, info=yellow) - Manual dismiss button with auto-dismiss after 5 seconds - Backdrop blur and slide-up animation This prepares the dashboard to gracefully handle API failures without overwhelming users with error popups for transient issues. Co-Authored-By: Claude Opus 4.5 --- dashboard/components/Toast.js | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 dashboard/components/Toast.js diff --git a/dashboard/components/Toast.js b/dashboard/components/Toast.js new file mode 100644 index 0000000..566afeb --- /dev/null +++ b/dashboard/components/Toast.js @@ -0,0 +1,125 @@ +import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js'; + +/** + * Lightweight toast notification system. + * Tracks error counts and surfaces persistent issues. + */ + +// Singleton state for toast management (shared across components) +let toastListeners = []; +let toastIdCounter = 0; + +export function showToast(message, type = 'error', duration = 5000) { + const id = ++toastIdCounter; + const toast = { id, message, type, duration }; + toastListeners.forEach(listener => listener(toast)); + return id; +} + +export function ToastContainer() { + const [toasts, setToasts] = useState([]); + const timeoutIds = useRef(new Map()); + + useEffect(() => { + const listener = (toast) => { + setToasts(prev => [...prev, toast]); + if (toast.duration > 0) { + const timeoutId = setTimeout(() => { + timeoutIds.current.delete(toast.id); + setToasts(prev => prev.filter(t => t.id !== toast.id)); + }, toast.duration); + timeoutIds.current.set(toast.id, timeoutId); + } + }; + toastListeners.push(listener); + return () => { + toastListeners = toastListeners.filter(l => l !== listener); + // Clear all pending timeouts on unmount + timeoutIds.current.forEach(id => clearTimeout(id)); + timeoutIds.current.clear(); + }; + }, []); + + const dismiss = useCallback((id) => { + // Clear auto-dismiss timeout if exists + const timeoutId = timeoutIds.current.get(id); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutIds.current.delete(id); + } + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + if (toasts.length === 0) return null; + + return html` +
+ ${toasts.map(toast => html` +
+
${toast.message}
+ +
+ `)} +
+ `; +} + +/** + * Error tracker for surfacing repeated failures. + * Tracks errors by key and shows toast after threshold. + */ +const errorCounts = {}; +const ERROR_THRESHOLD = 3; +const ERROR_WINDOW_MS = 30000; // 30 second window + +export function trackError(key, message, { log = true, threshold = ERROR_THRESHOLD } = {}) { + const now = Date.now(); + + // Always log + if (log) { + console.error(`[${key}]`, message); + } + + // Track error count within window + if (!errorCounts[key]) { + errorCounts[key] = { count: 0, firstAt: now, lastToastAt: 0 }; + } + + const tracker = errorCounts[key]; + + // Reset if outside window + if (now - tracker.firstAt > ERROR_WINDOW_MS) { + tracker.count = 0; + tracker.firstAt = now; + } + + tracker.count++; + + // Surface toast after threshold, but not too frequently + if (tracker.count >= threshold && now - tracker.lastToastAt > ERROR_WINDOW_MS) { + showToast(`${message} (repeated ${tracker.count}x)`, 'error'); + tracker.lastToastAt = now; + tracker.count = 0; // Reset after showing toast + } +} + +export function clearErrorCount(key) { + delete errorCounts[key]; +}