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)
|
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())
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 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,8 +214,12 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Reminders */}
|
{/* Active Reminders — exclude those already shown in alert banner */}
|
||||||
{data.active_reminders.length > 0 && (
|
{(() => {
|
||||||
|
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' }}>
|
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@ -221,7 +231,7 @@ export default function DashboardPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{data.active_reminders.map((reminder) => (
|
{futureReminders.map((reminder) => (
|
||||||
<div
|
<div
|
||||||
key={reminder.id}
|
key={reminder.id}
|
||||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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' }}>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user