// components/Toast.tsx // Tiny in-app toast system. Each toast is a row in a vertical stack // anchored to the bottom-right. Auto-dismiss after 5s (configurable). // Includes a 3-second coalesce window — bursty events (e.g. 20 tasks // flipping to done at once) only show one toast. "use client"; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode, } from "react"; export type ToastKind = "info" | "success" | "error" | "warn"; export interface Toast { id: string; kind: ToastKind; title: string; body?: string; /** Optional click target — e.g. open a task detail panel. */ onClick?: () => void; /** Auto-dismiss in ms. 0 = sticky. Default 5000. */ duration?: number; } interface ToastContextValue { toast: (t: Omit) => void; dismiss: (id: string) => void; } const ToastContext = createContext(null); const COALESCE_MS = 3000; const MAX_VISIBLE = 4; export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const lastFiredRef = useRef<{ key: string; at: number } | null>(null); const dismiss = useCallback((id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); const toast = useCallback((t: Omit) => { const key = `${t.kind}|${t.title}|${t.body ?? ""}`; const now = Date.now(); if (lastFiredRef.current && lastFiredRef.current.key === key && now - lastFiredRef.current.at < COALESCE_MS) { return; } lastFiredRef.current = { key, at: now }; const id = `${now}-${Math.random().toString(36).slice(2, 8)}`; setToasts((prev) => { const next = [...prev, { ...t, id }]; return next.slice(-MAX_VISIBLE); }); }, []); return ( {children} ); } export function useToast(): ToastContextValue { const ctx = useContext(ToastContext); if (!ctx) { // Safe no-op so non-provider contexts (e.g. tests) don't crash. return { toast: () => {}, dismiss: () => {} }; } return ctx; } function ToastViewport({ toasts, onDismiss, }: { toasts: Toast[]; onDismiss: (id: string) => void; }) { return (
{toasts.map((t) => ( onDismiss(t.id)} /> ))}
); } function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) { const duration = toast.duration ?? 5000; useEffect(() => { if (duration <= 0) return; const t = setTimeout(onDismiss, duration); return () => clearTimeout(t); }, [duration, onDismiss]); const palette = { info: "border-blue-300 bg-blue-50 text-blue-900", success: "border-green-300 bg-green-50 text-green-900", error: "border-red-300 bg-red-50 text-red-900", warn: "border-amber-300 bg-amber-50 text-amber-900", }[toast.kind]; const icon = { info: "ℹ", success: "✅", error: "❌", warn: "⚠", }[toast.kind]; const inner = (
{icon}

{toast.title}

{toast.body ?

{toast.body}

: null}
); return inner; } // ───────────────────────────────────────────────────────────────── // Browser notifications — request permission on first prompt. // ───────────────────────────────────────────────────────────────── export async function maybeRequestNotificationPermission(): Promise { if (typeof window === "undefined") return false; if (!("Notification" in window)) return false; if (Notification.permission === "granted") return true; if (Notification.permission === "denied") return false; const result = await Notification.requestPermission(); return result === "granted"; } export function fireBrowserNotification(title: string, options: NotificationOptions = {}) { if (typeof window === "undefined") return; if (!("Notification" in window)) return; if (Notification.permission !== "granted") return; try { new Notification(title, options); } catch { // Some browsers throw on focus-tab notifications — ignore. } }