diff --git a/.claude/projects/ui_refresh.md b/.claude/projects/ui_refresh.md index 9682578..00b538b 100644 --- a/.claude/projects/ui_refresh.md +++ b/.claude/projects/ui_refresh.md @@ -244,9 +244,27 @@ Several components feel like unstyled defaults: - [x] Single-line compact row design (ReminderItem) matching TodoItem pattern - [x] Grouped sections (Overdue → Today → Upcoming → No Date → Dismissed) - [x] Recurrence badge inline, date coloring (red overdue, yellow today, muted future) -- [x] Dismiss button, edit button, 2-second confirm delete pattern +- [x] Dismiss button, edit button, 4-second confirm delete with "Sure?" label - [x] Optimistic delete with rollback - [x] Empty state with "Add Reminder" action button +- [x] ReminderForm uses single datetime-local input (matches EventForm) with recurrence alongside + +#### Reminder Alerts — COMPLETED +- [x] Backend: `snoozed_until` column + Alembic migration 017 with composite index +- [x] Backend: `GET /api/reminders/due` endpoint (overdue, non-dismissed, non-recurring, snooze-aware) +- [x] Backend: `PATCH /api/reminders/{id}/snooze` with `Literal[5, 10, 15]` validation + state guards +- [x] Backend: Snooze/due endpoints accept `client_now` from frontend (fixes Docker UTC vs local time) +- [x] Backend: Dismiss clears `snoozed_until`; updating `remind_at` reactivates dismissed reminders +- [x] Frontend: `AlertsProvider` context in AppLayout — single polling instance, no duplicate toasts +- [x] Frontend: `useAlerts` hook polls `/reminders/due` every 30s with client_now +- [x] Frontend: Sonner custom toasts on non-dashboard pages (max 3 + overflow summary, `duration: Infinity`) +- [x] Frontend: `AlertBanner` on dashboard below stats row (orange left accent, compact rows) +- [x] Frontend: `SnoozeDropdown` component — clock icon + "Snooze" label, opens dropdown with 5/10/15 min options +- [x] Frontend: Toast/banner dismiss button with X icon + "Dismiss" label +- [x] Frontend: Route-aware display — toasts dismissed on dashboard entry, fired on exit +- [x] Frontend: Dashboard Active Reminders card filters out items already in AlertBanner +- [x] Frontend: Shared `getRelativeTime` + `toLocalDatetime` utilities in `lib/date-utils.ts` +- [x] Accessibility: aria-labels on all snooze/dismiss buttons ### Stage 5: Entity Pages (People, Locations) - Avatar placeholders for People cards @@ -275,4 +293,4 @@ Several components feel like unstyled defaults: ## Refresh Scope -**Current status:** Stages 1-4 complete. Next up: Stage 5 Entity Pages (People, Locations). +**Current status:** Stages 1-4 complete, plus Reminder Alerts feature. Next up: Stage 5 Entity Pages (People, Locations). diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index 924163d..f64e28d 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -144,6 +144,10 @@ async def update_reminder( reminder.snoozed_until = None reminder.is_dismissed = False + # Clear snoozed_until when dismissing via update (match dedicated endpoint) + if update_data.get('is_dismissed') is True: + reminder.snoozed_until = None + for key, value in update_data.items(): setattr(reminder, key, value) diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py index f37a3bc..a4a8d39 100644 --- a/backend/app/schemas/reminder.py +++ b/backend/app/schemas/reminder.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict from datetime import datetime from typing import Literal, Optional @@ -8,7 +8,7 @@ class ReminderCreate(BaseModel): description: Optional[str] = None remind_at: Optional[datetime] = None is_active: bool = True - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None class ReminderUpdate(BaseModel): @@ -17,20 +17,13 @@ class ReminderUpdate(BaseModel): remind_at: Optional[datetime] = None is_active: Optional[bool] = None is_dismissed: Optional[bool] = None - recurrence_rule: Optional[str] = None + recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None class ReminderSnooze(BaseModel): minutes: Literal[5, 10, 15] client_now: Optional[datetime] = None - @field_validator('minutes', mode='before') - @classmethod - def validate_minutes(cls, v: int) -> int: - if v not in (5, 10, 15): - raise ValueError('Snooze duration must be 5, 10, or 15 minutes') - return v - class ReminderResponse(BaseModel): id: int diff --git a/frontend/src/components/dashboard/AlertBanner.tsx b/frontend/src/components/dashboard/AlertBanner.tsx index 8172b04..f098851 100644 --- a/frontend/src/components/dashboard/AlertBanner.tsx +++ b/frontend/src/components/dashboard/AlertBanner.tsx @@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner {alerts.length} -
+
{alerts.map((alert) => (
>(); + useEffect(() => () => clearTimeout(deleteTimerRef.current), []); const remindDate = reminder.remind_at ? parseISO(reminder.remind_at) : null; const isOverdue = !reminder.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate); @@ -70,9 +72,10 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) { const handleDelete = () => { if (!confirmingDelete) { setConfirmingDelete(true); - setTimeout(() => setConfirmingDelete(false), 4000); + deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000); return; } + clearTimeout(deleteTimerRef.current); deleteMutation.mutate(); setConfirmingDelete(false); }; diff --git a/frontend/src/components/reminders/SnoozeDropdown.tsx b/frontend/src/components/reminders/SnoozeDropdown.tsx index 00036a9..97d1ebc 100644 --- a/frontend/src/components/reminders/SnoozeDropdown.tsx +++ b/frontend/src/components/reminders/SnoozeDropdown.tsx @@ -19,13 +19,20 @@ export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: Sn useEffect(() => { if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } + document.addEventListener('keydown', handleKey); document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); + return () => { + document.removeEventListener('keydown', handleKey); + document.removeEventListener('mousedown', handleClick); + }; }, [open]); return ( @@ -33,18 +40,21 @@ export default function SnoozeDropdown({ onSnooze, label, direction = 'up' }: Sn {open && ( -
{OPTIONS.map((opt) => (