From 5080e23256564832406f5592ac924fc9332927bf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 23 Feb 2026 23:15:56 +0800 Subject: [PATCH] Add real-time reminder alerts with snooze/dismiss - Backend: GET /api/reminders/due endpoint, PATCH snooze endpoint, snoozed_until column + migration - Frontend: useAlerts hook polls every 30s, fires Sonner toasts on non-dashboard pages (max 3 + summary), renders AlertBanner on dashboard below stats row - Dashboard Active Reminders card filters out items shown in banner Co-Authored-By: Claude Opus 4.6 --- .../017_add_reminder_snoozed_until.py | 24 +++ backend/app/models/reminder.py | 1 + backend/app/routers/reminders.py | 52 ++++- backend/app/schemas/reminder.py | 7 +- .../src/components/dashboard/AlertBanner.tsx | 71 +++++++ .../components/dashboard/DashboardPage.tsx | 67 ++++--- frontend/src/components/layout/AppLayout.tsx | 2 + frontend/src/hooks/useAlerts.tsx | 188 ++++++++++++++++++ frontend/src/types/index.ts | 1 + 9 files changed, 382 insertions(+), 31 deletions(-) create mode 100644 backend/alembic/versions/017_add_reminder_snoozed_until.py create mode 100644 frontend/src/components/dashboard/AlertBanner.tsx create mode 100644 frontend/src/hooks/useAlerts.tsx diff --git a/backend/alembic/versions/017_add_reminder_snoozed_until.py b/backend/alembic/versions/017_add_reminder_snoozed_until.py new file mode 100644 index 0000000..c04aad8 --- /dev/null +++ b/backend/alembic/versions/017_add_reminder_snoozed_until.py @@ -0,0 +1,24 @@ +"""Add snoozed_until column to reminders + +Revision ID: 017 +Revises: 016 +Create Date: 2026-02-23 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "017" +down_revision = "016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("reminders", sa.Column("snoozed_until", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("reminders", "snoozed_until") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index b1fbd71..8259d21 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -14,6 +14,7 @@ class Reminder(Base): remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) + snoozed_until: Mapped[Optional[datetime]] = mapped_column(nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index 47950f7..a23d97b 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -1,11 +1,13 @@ +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, and_, or_ from typing import Optional, List from app.database import get_db from app.models.reminder import Reminder -from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse +from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse, ReminderSnooze from app.routers.auth import get_current_session from app.models.settings import Settings @@ -36,6 +38,52 @@ async def get_reminders( return reminders +@router.get("/due", response_model=List[ReminderResponse]) +async def get_due_reminders( + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get reminders that are currently due for alerting.""" + now = datetime.now() + query = select(Reminder).where( + and_( + Reminder.remind_at <= now, + Reminder.is_dismissed == False, + Reminder.is_active == True, + Reminder.recurrence_rule.is_(None), + or_( + Reminder.snoozed_until.is_(None), + Reminder.snoozed_until <= now, + ), + ) + ).order_by(Reminder.remind_at.asc()) + + result = await db.execute(query) + return result.scalars().all() + + +@router.patch("/{reminder_id}/snooze", response_model=ReminderResponse) +async def snooze_reminder( + reminder_id: int, + body: ReminderSnooze, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Snooze a reminder for N minutes from now.""" + result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + reminder = result.scalar_one_or_none() + + if not reminder: + raise HTTPException(status_code=404, detail="Reminder not found") + + reminder.snoozed_until = datetime.now() + timedelta(minutes=body.minutes) + + await db.commit() + await db.refresh(reminder) + + return reminder + + @router.post("/", response_model=ReminderResponse, status_code=201) async def create_reminder( reminder: ReminderCreate, diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py index b9be26d..c2bbc67 100644 --- a/backend/app/schemas/reminder.py +++ b/backend/app/schemas/reminder.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime -from typing import Optional +from typing import Literal, Optional class ReminderCreate(BaseModel): @@ -20,6 +20,10 @@ class ReminderUpdate(BaseModel): recurrence_rule: Optional[str] = None +class ReminderSnooze(BaseModel): + minutes: Literal[5, 10, 15] + + class ReminderResponse(BaseModel): id: int title: str @@ -27,6 +31,7 @@ class ReminderResponse(BaseModel): remind_at: Optional[datetime] is_active: bool is_dismissed: bool + snoozed_until: Optional[datetime] = None recurrence_rule: Optional[str] created_at: datetime updated_at: datetime diff --git a/frontend/src/components/dashboard/AlertBanner.tsx b/frontend/src/components/dashboard/AlertBanner.tsx new file mode 100644 index 0000000..1ebcad3 --- /dev/null +++ b/frontend/src/components/dashboard/AlertBanner.tsx @@ -0,0 +1,71 @@ +import { Bell, X } from 'lucide-react'; +import type { Reminder } from '@/types'; + +interface AlertBannerProps { + alerts: Reminder[]; + onDismiss: (id: number) => void; + onSnooze: (id: number, minutes: 5 | 10 | 15) => void; +} + +export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBannerProps) { + if (alerts.length === 0) return null; + + return ( +
+
+
+ +
+ Alerts + + {alerts.length} + +
+
+ {alerts.map((alert) => ( +
+
+ {alert.title} + + {alert.remind_at ? getRelativeTime(alert.remind_at) : ''} + +
+ {([5, 10, 15] as const).map((m) => ( + + ))} +
+ +
+ ))} +
+
+ ); +} + +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 ee10080..5a05ff8 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -5,6 +5,7 @@ import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react'; import api from '@/lib/api'; import type { DashboardData, UpcomingResponse, WeatherData } from '@/types'; import { useSettings } from '@/hooks/useSettings'; +import { useAlerts } from '@/hooks/useAlerts'; import StatsWidget from './StatsWidget'; import TodoWidget from './TodoWidget'; import CalendarWidget from './CalendarWidget'; @@ -13,6 +14,7 @@ import WeekTimeline from './WeekTimeline'; import DayBriefing from './DayBriefing'; import CountdownWidget from './CountdownWidget'; import TrackedProjectsWidget from './TrackedProjectsWidget'; +import AlertBanner from './AlertBanner'; import EventForm from '../calendar/EventForm'; import TodoForm from '../todos/TodoForm'; import ReminderForm from '../reminders/ReminderForm'; @@ -32,6 +34,7 @@ function getGreeting(name?: string): string { export default function DashboardPage() { const { settings } = useSettings(); + const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts(); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -178,6 +181,9 @@ export default function DashboardPage() { />
+ {/* Alert Banner */} + + {/* Main Content — 2 columns */}
{/* Left: Upcoming feed (wider) */} @@ -208,35 +214,40 @@ export default function DashboardPage() {
- {/* Active Reminders */} - {data.active_reminders.length > 0 && ( - - - -
- -
- Active Reminders -
-
- -
- {data.active_reminders.map((reminder) => ( -
-
- {reminder.title} - - {format(new Date(reminder.remind_at), 'MMM d, h:mm a')} - + {/* Active Reminders — exclude those already shown in alert banner */} + {(() => { + const alertIds = new Set(alerts.map((a) => a.id)); + const futureReminders = data.active_reminders.filter((r) => !alertIds.has(r.id)); + if (futureReminders.length === 0) return null; + return ( + + + +
+
- ))} -
- - - )} + Active Reminders + + + +
+ {futureReminders.map((reminder) => ( +
+
+ {reminder.title} + + {format(new Date(reminder.remind_at), 'MMM d, h:mm a')} + +
+ ))} +
+ + + ); + })()} {/* Tracked Projects */}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index bd00f90..2c9f924 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -2,11 +2,13 @@ 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 { 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); diff --git a/frontend/src/hooks/useAlerts.tsx b/frontend/src/hooks/useAlerts.tsx new file mode 100644 index 0000000..b824b5f --- /dev/null +++ b/frontend/src/hooks/useAlerts.tsx @@ -0,0 +1,188 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; +import { toast } from 'sonner'; +import api from '@/lib/api'; +import type { Reminder } from '@/types'; + +const MAX_TOASTS = 3; + +export function useAlerts() { + const queryClient = useQueryClient(); + const location = useLocation(); + 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'); + 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]); + + // Handle route changes + useEffect(() => { + const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; + const nowOnDashboard = isDashboard; + prevPathnameRef.current = location.pathname; + + 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 + + // Fire toasts for new alerts on non-dashboard pages + useEffect(() => { + if (isDashboard) return; + fireToasts(alerts); + }, [alerts, isDashboard]); // eslint-disable-line react-hooks/exhaustive-deps + + 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 } + ); + } + } + + 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 +

+
+ ); + } + + const dismissMutation = useMutation({ + mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reminders'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }, + }); + + 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`; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ee4b2e..f39565f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -85,6 +85,7 @@ export interface Reminder { remind_at?: string; is_active: boolean; is_dismissed: boolean; + snoozed_until?: string | null; recurrence_rule?: string; created_at: string; updated_at: string;