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]; }