diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index 9ff6d1b..26d3fc6 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -134,6 +134,10 @@ async def update_reminder( update_data = reminder_update.model_dump(exclude_unset=True) + # Clear stale snooze if remind_at is being changed + if 'remind_at' in update_data: + reminder.snoozed_until = None + for key, value in update_data.items(): setattr(reminder, key, value) @@ -176,6 +180,7 @@ async def dismiss_reminder( raise HTTPException(status_code=404, detail="Reminder not found") reminder.is_dismissed = True + reminder.snoozed_until = None await db.commit() await db.refresh(reminder) diff --git a/frontend/src/components/dashboard/AlertBanner.tsx b/frontend/src/components/dashboard/AlertBanner.tsx index 1ebcad3..2ea3f06 100644 --- a/frontend/src/components/dashboard/AlertBanner.tsx +++ b/frontend/src/components/dashboard/AlertBanner.tsx @@ -1,4 +1,5 @@ import { Bell, X } from 'lucide-react'; +import { getRelativeTime } from '@/lib/date-utils'; import type { Reminder } from '@/types'; interface AlertBannerProps { @@ -55,17 +56,3 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner ); } - -function getRelativeTime(dateStr: string): string { - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; -} diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 5a05ff8..fa3c202 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -239,7 +239,7 @@ export default function DashboardPage() {
{reminder.title} - {format(new Date(reminder.remind_at), 'MMM d, h:mm a')} + {reminder.remind_at ? format(new Date(reminder.remind_at), 'MMM d, h:mm a') : ''}
))} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 2c9f924..4b9df6a 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -2,36 +2,37 @@ import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import { Menu } from 'lucide-react'; import { useTheme } from '@/hooks/useTheme'; -import { useAlerts } from '@/hooks/useAlerts'; +import { AlertsProvider } from '@/hooks/useAlerts'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; export default function AppLayout() { useTheme(); - useAlerts(); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); return ( -
- setCollapsed(!collapsed)} - mobileOpen={mobileOpen} - onMobileClose={() => setMobileOpen(false)} - /> -
- {/* Mobile header */} -
- -

UMBRA

+ +
+ setCollapsed(!collapsed)} + mobileOpen={mobileOpen} + onMobileClose={() => setMobileOpen(false)} + /> +
+ {/* Mobile header */} +
+ +

UMBRA

+
+
+ +
-
- -
-
+ ); } diff --git a/frontend/src/hooks/useAlerts.tsx b/frontend/src/hooks/useAlerts.tsx index b824b5f..62f6881 100644 --- a/frontend/src/hooks/useAlerts.tsx +++ b/frontend/src/hooks/useAlerts.tsx @@ -1,13 +1,31 @@ -import { useRef, useEffect, useCallback } from 'react'; +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 } from 'lucide-react'; import api from '@/lib/api'; +import { getRelativeTime } from '@/lib/date-utils'; 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 firedRef = useRef>(new Set()); @@ -37,29 +55,89 @@ export function useAlerts() { }); }, [alerts]); - // Handle route changes - useEffect(() => { - const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; - const nowOnDashboard = isDashboard; - prevPathnameRef.current = location.pathname; + const dismissMutation = useMutation({ + mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }, + }); - if (nowOnDashboard) { - // Moving TO dashboard — dismiss all toasts, banner takes over - alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); - toast.dismiss('reminder-summary'); - firedRef.current.clear(); - } else if (wasOnDashboard && !nowOnDashboard) { - // Moving AWAY from dashboard — fire toasts for current alerts - firedRef.current.clear(); - fireToasts(alerts); - } - }, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps + const snoozeMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) => + api.patch(`/reminders/${id}/snooze`, { minutes }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }, + }); - // Fire toasts for new alerts on non-dashboard pages - useEffect(() => { - if (isDashboard) return; - fireToasts(alerts); - }, [alerts, isDashboard]); // eslint-disable-line react-hooks/exhaustive-deps + const handleDismiss = useCallback((id: number) => { + toast.dismiss(`reminder-${id}`); + toast.dismiss('reminder-summary'); + firedRef.current.delete(id); + dismissMutation.mutate(id); + }, [dismissMutation]); + + const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => { + toast.dismiss(`reminder-${id}`); + toast.dismiss('reminder-summary'); + firedRef.current.delete(id); + snoozeMutation.mutate({ id, minutes }); + }, [snoozeMutation]); + + // 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}

+
+
+ {[5, 10, 15].map((m) => ( + + ))} +
+ +
+
+
+ ); + } + + 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)); @@ -87,102 +165,33 @@ export function useAlerts() { } } - function renderToast(_t: string | number, reminder: Reminder) { - const timeAgo = reminder.remind_at ? getRelativeTime(reminder.remind_at) : ''; - return ( -
-
- - - -
-
-

{reminder.title}

-

{timeAgo}

-
-
- {[5, 10, 15].map((m) => ( - - ))} -
- -
-
-
- ); - } + // Handle route changes + useEffect(() => { + const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; + const nowOnDashboard = isDashboard; + prevPathnameRef.current = location.pathname; - function renderSummaryToast(_t: string | number, count: number) { - return ( -
-
- - - -
-

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

-
- ); - } + if (nowOnDashboard) { + // Moving TO dashboard — dismiss all toasts, banner takes over + alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); + toast.dismiss('reminder-summary'); + firedRef.current.clear(); + } else if (wasOnDashboard && !nowOnDashboard) { + // Moving AWAY from dashboard — fire toasts for current alerts + firedRef.current.clear(); + fireToasts(alerts); + } + }, [location.pathname, isDashboard, alerts]); - const dismissMutation = useMutation({ - mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reminders'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - }, - }); + // Fire toasts for new alerts on non-dashboard pages + useEffect(() => { + if (isDashboard) return; + fireToasts(alerts); + }, [alerts, isDashboard]); - const snoozeMutation = useMutation({ - mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) => - api.patch(`/reminders/${id}/snooze`, { minutes }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reminders'] }); - queryClient.invalidateQueries({ queryKey: ['dashboard'] }); - }, - }); - - const handleDismiss = useCallback((id: number) => { - toast.dismiss(`reminder-${id}`); - firedRef.current.delete(id); - dismissMutation.mutate(id); - // If summary toast exists and we're reducing count, dismiss it too — next poll will re-evaluate - toast.dismiss('reminder-summary'); - }, [dismissMutation]); - - const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => { - toast.dismiss(`reminder-${id}`); - firedRef.current.delete(id); - snoozeMutation.mutate({ id, minutes }); - toast.dismiss('reminder-summary'); - }, [snoozeMutation]); - - return { alerts, dismiss: handleDismiss, snooze: handleSnooze }; -} - -function getRelativeTime(dateStr: string): string { - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; + return ( + + {children} + + ); } diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts new file mode 100644 index 0000000..48fcea8 --- /dev/null +++ b/frontend/src/lib/date-utils.ts @@ -0,0 +1,13 @@ +export function getRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +}