Swap LockProvider to outer wrapper so AlertsProvider can read isLocked. When locked, dismiss all visible reminder toasts and skip firing new ones. Toasts re-fire normally on unlock via the firedRef.clear() reset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
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<AlertsContextValue>({
|
|
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<Set<number>>(new Set());
|
|
const prevPathnameRef = useRef(location.pathname);
|
|
const isDashboard = location.pathname === '/' || location.pathname === '/dashboard';
|
|
|
|
const { data: alerts = [] } = useQuery<Reminder[]>({
|
|
queryKey: ['reminders', 'due'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Reminder[]>('/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 (
|
|
<div className="flex items-center gap-3 bg-card border border-border rounded-lg p-3 shadow-lg w-[356px]">
|
|
<div className="p-1.5 rounded-md bg-orange-500/10 shrink-0">
|
|
<Bell className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-foreground truncate">{reminder.title}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{timeAgo}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<SnoozeDropdown
|
|
onSnooze={(m) => snoozeRef.current(reminder.id, m)}
|
|
label={reminder.title}
|
|
direction="down"
|
|
/>
|
|
<button
|
|
onClick={() => dismissRef.current(reminder.id)}
|
|
aria-label={`Dismiss "${reminder.title}"`}
|
|
className="flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
<span className="text-[11px] font-medium">Dismiss</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderSummaryToast(_t: string | number, count: number) {
|
|
return (
|
|
<div className="flex items-center gap-3 bg-card border border-border rounded-lg p-3 shadow-lg w-[356px]">
|
|
<div className="p-1.5 rounded-md bg-orange-500/10 shrink-0">
|
|
<Bell className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
+{count} more reminder{count > 1 ? 's' : ''} due
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>
|
|
{children}
|
|
</AlertsContext.Provider>
|
|
);
|
|
}
|