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;