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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-23 23:15:56 +08:00
parent e3ecc11a21
commit 5080e23256
9 changed files with 382 additions and 31 deletions

View File

@ -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")

View File

@ -14,6 +14,7 @@ class Reminder(Base):
remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) 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) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,11 +1,13 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, and_, or_
from typing import Optional, List from typing import Optional, List
from app.database import get_db from app.database import get_db
from app.models.reminder import Reminder 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.routers.auth import get_current_session
from app.models.settings import Settings from app.models.settings import Settings
@ -36,6 +38,52 @@ async def get_reminders(
return 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) @router.post("/", response_model=ReminderResponse, status_code=201)
async def create_reminder( async def create_reminder(
reminder: ReminderCreate, reminder: ReminderCreate,

View File

@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Literal, Optional
class ReminderCreate(BaseModel): class ReminderCreate(BaseModel):
@ -20,6 +20,10 @@ class ReminderUpdate(BaseModel):
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = None
class ReminderSnooze(BaseModel):
minutes: Literal[5, 10, 15]
class ReminderResponse(BaseModel): class ReminderResponse(BaseModel):
id: int id: int
title: str title: str
@ -27,6 +31,7 @@ class ReminderResponse(BaseModel):
remind_at: Optional[datetime] remind_at: Optional[datetime]
is_active: bool is_active: bool
is_dismissed: bool is_dismissed: bool
snoozed_until: Optional[datetime] = None
recurrence_rule: Optional[str] recurrence_rule: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -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 (
<div className="rounded-lg border border-border border-l-4 border-l-orange-500 bg-card animate-slide-up">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Bell className="h-4 w-4 text-orange-400" />
</div>
<span className="font-heading text-sm font-semibold">Alerts</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/15 text-orange-400 font-medium">
{alerts.length}
</span>
</div>
<div className="divide-y divide-border">
{alerts.map((alert) => (
<div
key={alert.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-card-elevated transition-colors duration-150"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="text-sm font-medium truncate flex-1 min-w-0">{alert.title}</span>
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
{alert.remind_at ? getRelativeTime(alert.remind_at) : ''}
</span>
<div className="flex items-center gap-0.5 text-[11px] shrink-0">
{([5, 10, 15] as const).map((m) => (
<button
key={m}
onClick={() => onSnooze(alert.id, m)}
className="px-1.5 py-0.5 rounded bg-secondary hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors"
>
{m}m
</button>
))}
</div>
<button
onClick={() => onDismiss(alert.id)}
className="p-1 rounded hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors shrink-0"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
);
}
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`;
}

View File

@ -5,6 +5,7 @@ import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types'; import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { useAlerts } from '@/hooks/useAlerts';
import StatsWidget from './StatsWidget'; import StatsWidget from './StatsWidget';
import TodoWidget from './TodoWidget'; import TodoWidget from './TodoWidget';
import CalendarWidget from './CalendarWidget'; import CalendarWidget from './CalendarWidget';
@ -13,6 +14,7 @@ import WeekTimeline from './WeekTimeline';
import DayBriefing from './DayBriefing'; import DayBriefing from './DayBriefing';
import CountdownWidget from './CountdownWidget'; import CountdownWidget from './CountdownWidget';
import TrackedProjectsWidget from './TrackedProjectsWidget'; import TrackedProjectsWidget from './TrackedProjectsWidget';
import AlertBanner from './AlertBanner';
import EventForm from '../calendar/EventForm'; import EventForm from '../calendar/EventForm';
import TodoForm from '../todos/TodoForm'; import TodoForm from '../todos/TodoForm';
import ReminderForm from '../reminders/ReminderForm'; import ReminderForm from '../reminders/ReminderForm';
@ -32,6 +34,7 @@ function getGreeting(name?: string): string {
export default function DashboardPage() { export default function DashboardPage() {
const { settings } = useSettings(); const { settings } = useSettings();
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -178,6 +181,9 @@ export default function DashboardPage() {
/> />
</div> </div>
{/* Alert Banner */}
<AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} />
{/* Main Content — 2 columns */} {/* Main Content — 2 columns */}
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}> <div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
{/* Left: Upcoming feed (wider) */} {/* Left: Upcoming feed (wider) */}
@ -208,35 +214,40 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Active Reminders */} {/* Active Reminders — exclude those already shown in alert banner */}
{data.active_reminders.length > 0 && ( {(() => {
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}> const alertIds = new Set(alerts.map((a) => a.id));
<CardHeader> const futureReminders = data.active_reminders.filter((r) => !alertIds.has(r.id));
<CardTitle className="flex items-center gap-2"> if (futureReminders.length === 0) return null;
<div className="p-1.5 rounded-md bg-orange-500/10"> return (
<Bell className="h-4 w-4 text-orange-400" /> <Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}>
</div> <CardHeader>
Active Reminders <CardTitle className="flex items-center gap-2">
</CardTitle> <div className="p-1.5 rounded-md bg-orange-500/10">
</CardHeader> <Bell className="h-4 w-4 text-orange-400" />
<CardContent>
<div className="space-y-0.5">
{data.active_reminders.map((reminder) => (
<div
key={reminder.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(reminder.remind_at), 'MMM d, h:mm a')}
</span>
</div> </div>
))} Active Reminders
</div> </CardTitle>
</CardContent> </CardHeader>
</Card> <CardContent>
)} <div className="space-y-0.5">
{futureReminders.map((reminder) => (
<div
key={reminder.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
{format(new Date(reminder.remind_at), 'MMM d, h:mm a')}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
})()}
{/* Tracked Projects */} {/* Tracked Projects */}
<div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}> <div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}>

View File

@ -2,11 +2,13 @@ import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useAlerts } from '@/hooks/useAlerts';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
export default function AppLayout() { export default function AppLayout() {
useTheme(); useTheme();
useAlerts();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);

View File

@ -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<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');
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 (
<div className="flex items-start 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 mt-0.5">
<svg className="h-4 w-4 text-orange-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
</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 className="flex items-center gap-1.5 mt-2">
<div className="flex items-center gap-0.5 text-[11px]">
{[5, 10, 15].map((m) => (
<button
key={m}
onClick={() => handleSnooze(reminder.id, m as 5 | 10 | 15)}
className="px-1.5 py-0.5 rounded bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors"
>
{m}m
</button>
))}
</div>
<button
onClick={() => handleDismiss(reminder.id)}
className="ml-auto px-2 py-0.5 rounded text-[11px] bg-secondary hover:bg-card-elevated text-muted-foreground hover:text-foreground transition-colors"
>
Dismiss
</button>
</div>
</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">
<svg className="h-4 w-4 text-orange-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
</div>
<p className="text-sm text-muted-foreground">
+{count} more reminder{count > 1 ? 's' : ''} due
</p>
</div>
);
}
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`;
}

View File

@ -85,6 +85,7 @@ export interface Reminder {
remind_at?: string; remind_at?: string;
is_active: boolean; is_active: boolean;
is_dismissed: boolean; is_dismissed: boolean;
snoozed_until?: string | null;
recurrence_rule?: string; recurrence_rule?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;