Files
amc/dashboard/components/Toast.js
teernisse 3dc10aa060 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>
2026-02-26 15:24:06 -05:00

126 lines
3.8 KiB
JavaScript

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