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 <noreply@anthropic.com>
This commit is contained in:
125
dashboard/components/Toast.js
Normal file
125
dashboard/components/Toast.js
Normal file
@@ -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`
|
||||
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
${toasts.map(toast => html`
|
||||
<div
|
||||
key=${toast.id}
|
||||
class="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lg backdrop-blur-sm animate-fade-in-up ${
|
||||
toast.type === 'error'
|
||||
? 'border-attention/50 bg-attention/15 text-attention'
|
||||
: toast.type === 'success'
|
||||
? 'border-active/50 bg-active/15 text-active'
|
||||
: 'border-starting/50 bg-starting/15 text-starting'
|
||||
}"
|
||||
style=${{ maxWidth: '380px' }}
|
||||
>
|
||||
<div class="flex-1 text-sm font-medium">${toast.message}</div>
|
||||
<button
|
||||
onClick=${() => dismiss(toast.id)}
|
||||
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
Reference in New Issue
Block a user