import { createContext, useContext, useRef, useEffect, useCallback, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useLocation } from 'react-router-dom'; import { toast } from 'sonner'; import { Bell, X } from 'lucide-react'; import api from '@/lib/api'; import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils'; import { useLock } from '@/hooks/useLock'; import SnoozeDropdown from '@/components/reminders/SnoozeDropdown'; import type { Reminder } from '@/types'; const MAX_TOASTS = 3; interface AlertsContextValue { alerts: Reminder[]; dismiss: (id: number) => void; snooze: (id: number, minutes: 5 | 10 | 15) => void; } const AlertsContext = createContext({ alerts: [], dismiss: () => {}, snooze: () => {}, }); export function useAlerts() { return useContext(AlertsContext); } export function AlertsProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const location = useLocation(); const { isLocked } = useLock(); const firedRef = useRef>(new Set()); const prevPathnameRef = useRef(location.pathname); const isDashboard = location.pathname === '/' || location.pathname === '/dashboard'; const { data: alerts = [] } = useQuery({ queryKey: ['reminders', 'due'], queryFn: async () => { const { data } = await api.get('/reminders/due', { params: { client_now: toLocalDatetime() }, }); return data; }, refetchInterval: 30_000, staleTime: 0, refetchIntervalInBackground: false, refetchOnWindowFocus: true, }); // Prune firedRef — remove IDs no longer in the response useEffect(() => { const currentIds = new Set(alerts.map((a) => a.id)); firedRef.current.forEach((id) => { if (!currentIds.has(id)) { firedRef.current.delete(id); toast.dismiss(`reminder-${id}`); } }); }, [alerts]); const dismissMutation = useMutation({ mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reminders'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); }, onError: () => { queryClient.invalidateQueries({ queryKey: ['reminders', 'due'] }); toast.error('Failed to dismiss reminder'); }, }); const snoozeMutation = useMutation({ mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) => api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reminders'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); }, onError: () => { queryClient.invalidateQueries({ queryKey: ['reminders', 'due'] }); toast.error('Failed to snooze reminder'); }, }); const updateSummaryToast = useCallback(() => { // Count alerts that are still fired but don't have individual toasts (overflow) const totalFired = firedRef.current.size; const overflow = totalFired - MAX_TOASTS; if (overflow > 0) { toast.custom( (t) => renderSummaryToast(t, overflow), { id: 'reminder-summary', duration: Infinity } ); } else { toast.dismiss('reminder-summary'); } }, []); const handleDismiss = useCallback((id: number) => { toast.dismiss(`reminder-${id}`); firedRef.current.delete(id); updateSummaryToast(); dismissMutation.mutate(id); }, [dismissMutation, updateSummaryToast]); const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => { toast.dismiss(`reminder-${id}`); firedRef.current.delete(id); updateSummaryToast(); snoozeMutation.mutate({ id, minutes }); }, [snoozeMutation, updateSummaryToast]); // Store latest callbacks in refs so toast closures always call current versions const dismissRef = useRef(handleDismiss); const snoozeRef = useRef(handleSnooze); useEffect(() => { dismissRef.current = handleDismiss; }, [handleDismiss]); useEffect(() => { snoozeRef.current = handleSnooze; }, [handleSnooze]); function renderToast(_t: string | number, reminder: Reminder) { const timeAgo = reminder.remind_at ? getRelativeTime(reminder.remind_at) : ''; return (

{reminder.title}

{timeAgo}

snoozeRef.current(reminder.id, m)} label={reminder.title} direction="down" />
); } function renderSummaryToast(_t: string | number, count: number) { return (

+{count} more reminder{count > 1 ? 's' : ''} due

); } function fireToasts(reminders: Reminder[]) { const newAlerts = reminders.filter((a) => !firedRef.current.has(a.id)); if (newAlerts.length === 0) return; const toShow = newAlerts.slice(0, MAX_TOASTS); const overflow = newAlerts.length - MAX_TOASTS; toShow.forEach((reminder) => { firedRef.current.add(reminder.id); toast.custom( (t) => renderToast(t, reminder), { id: `reminder-${reminder.id}`, duration: Infinity } ); }); // Mark remaining as fired to prevent re-firing newAlerts.slice(MAX_TOASTS).forEach((a) => firedRef.current.add(a.id)); if (overflow > 0) { toast.custom( (t) => renderSummaryToast(t, overflow), { id: 'reminder-summary', duration: Infinity } ); } } // Unified toast management — single effect handles route changes, lock state, and new alerts useEffect(() => { const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; const nowOnDashboard = isDashboard; prevPathnameRef.current = location.pathname; // Suppress toasts while locked — dismiss any visible ones if (isLocked) { alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); toast.dismiss('reminder-summary'); firedRef.current.clear(); return; } if (nowOnDashboard) { // On dashboard — dismiss all toasts, banner takes over alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); toast.dismiss('reminder-summary'); firedRef.current.clear(); return; } // Transitioning away from dashboard — reset fired set so all current alerts get toasts if (wasOnDashboard && !nowOnDashboard) { firedRef.current.clear(); } // On non-dashboard page — fire toasts for any unfired alerts fireToasts(alerts); }, [location.pathname, isDashboard, alerts, isLocked]); return ( {children} ); }