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:
parent
e3ecc11a21
commit
5080e23256
24
backend/alembic/versions/017_add_reminder_snoozed_until.py
Normal file
24
backend/alembic/versions/017_add_reminder_snoozed_until.py
Normal 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")
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
71
frontend/src/components/dashboard/AlertBanner.tsx
Normal file
71
frontend/src/components/dashboard/AlertBanner.tsx
Normal 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`;
|
||||
}
|
||||
@ -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,35 +214,40 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Reminders */}
|
||||
{data.active_reminders.length > 0 && (
|
||||
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Bell className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
Active Reminders
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
{/* 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">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Bell className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
Active Reminders
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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 */}
|
||||
<div className="animate-slide-up" style={{ animationDelay: '200ms', animationFillMode: 'backwards' }}>
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
188
frontend/src/hooks/useAlerts.tsx
Normal file
188
frontend/src/hooks/useAlerts.tsx
Normal 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`;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user