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)
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())

View File

@ -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,

View File

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

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 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<HTMLDivElement>(null);
@ -178,6 +181,9 @@ export default function DashboardPage() {
/>
</div>
{/* Alert Banner */}
<AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} />
{/* Main Content — 2 columns */}
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
{/* Left: Upcoming feed (wider) */}
@ -208,8 +214,12 @@ export default function DashboardPage() {
</div>
</div>
{/* Active Reminders */}
{data.active_reminders.length > 0 && (
{/* 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 (
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@ -221,7 +231,7 @@ export default function DashboardPage() {
</CardHeader>
<CardContent>
<div className="space-y-0.5">
{data.active_reminders.map((reminder) => (
{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"
@ -236,7 +246,8 @@ export default function DashboardPage() {
</div>
</CardContent>
</Card>
)}
);
})()}
{/* Tracked Projects */}
<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 { 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);

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;
is_active: boolean;
is_dismissed: boolean;
snoozed_until?: string | null;
recurrence_rule?: string;
created_at: string;
updated_at: string;