From e3ecc11a21ee25f73433d01569d1ca2581500992 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Mon, 23 Feb 2026 21:40:29 +0800 Subject: [PATCH] Redesign Reminders page to match Todos compact list pattern - Compact h-16 header with segmented filter, search input - Stat cards (Active/Overdue/Dismissed) with semantic colors - New ReminderItem component: single-line rows with grouped sections - Optimistic delete, 2-second confirm pattern, dismiss action - Mark Stage 4 Reminders as completed in ui_refresh.md Co-Authored-By: Claude Opus 4.6 --- .claude/projects/ui_refresh.md | 27 ++- .../src/components/reminders/ReminderItem.tsx | 166 +++++++++++++++++ .../src/components/reminders/ReminderList.tsx | 176 +++++++++--------- .../components/reminders/RemindersPage.tsx | 160 ++++++++++++---- 4 files changed, 393 insertions(+), 136 deletions(-) create mode 100644 frontend/src/components/reminders/ReminderItem.tsx diff --git a/.claude/projects/ui_refresh.md b/.claude/projects/ui_refresh.md index 23c1d0a..9682578 100644 --- a/.claude/projects/ui_refresh.md +++ b/.claude/projects/ui_refresh.md @@ -227,11 +227,26 @@ Several components feel like unstyled defaults: - [x] Better empty states with contextual illustrations or suggestions - [x] Consistent hover/click affordances -#### Todos & Reminders — pending -- Refined filter bar components -- Improved card designs -- Better empty states with contextual illustrations or suggestions -- Consistent hover/click affordances +#### Todos — COMPLETED +- [x] Compact h-16 header with segmented priority filter, search, category dropdown, show-completed toggle +- [x] Summary stat cards (Open, Completed, Overdue) +- [x] Single-line compact row design (list-view convention: no card borders, hover:bg-card-elevated) +- [x] Priority pills, category badges, recurrence badges inline +- [x] Overdue/today date coloring, due time display +- [x] Grouped sections (Overdue → Today → Upcoming → No Due Date → Completed) +- [x] Empty state with "Add Todo" action button +- [x] Recurrence logic: auto-reset scheduling (daily/weekly/monthly), reset info display +- [x] Optional due time field, fixed date not being truly optional + +#### Reminders — COMPLETED +- [x] Compact h-16 header with segmented status filter (Active/Dismissed/All), search input +- [x] Summary stat cards (Active, Overdue, Dismissed) with semantic icon colors +- [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] Optimistic delete with rollback +- [x] Empty state with "Add Reminder" action button ### Stage 5: Entity Pages (People, Locations) - Avatar placeholders for People cards @@ -260,4 +275,4 @@ Several components feel like unstyled defaults: ## Refresh Scope -**Current status:** Stages 1-3 complete (incl. event template UX fixes). Stage 4 Projects done. Next up: Stage 4 Todos & Reminders. +**Current status:** Stages 1-4 complete. Next up: Stage 5 Entity Pages (People, Locations). diff --git a/frontend/src/components/reminders/ReminderItem.tsx b/frontend/src/components/reminders/ReminderItem.tsx new file mode 100644 index 0000000..93b5473 --- /dev/null +++ b/frontend/src/components/reminders/ReminderItem.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Bell, BellOff, Trash2, Pencil } from 'lucide-react'; +import { format, isPast, isToday, parseISO } from 'date-fns'; +import api from '@/lib/api'; +import type { Reminder } from '@/types'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface ReminderItemProps { + reminder: Reminder; + onEdit: (reminder: Reminder) => void; +} + +const recurrenceLabels: Record = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', +}; + +const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const; + +export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) { + const queryClient = useQueryClient(); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + const remindDate = reminder.remind_at ? parseISO(reminder.remind_at) : null; + const isOverdue = !reminder.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate); + const isDueToday = remindDate ? isToday(remindDate) : false; + + const dismissMutation = useMutation({ + mutationFn: async () => { + const { data } = await api.patch(`/reminders/${reminder.id}/dismiss`); + return data; + }, + onSuccess: () => { + QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); + toast.success('Reminder dismissed'); + }, + onError: () => { + toast.error('Failed to dismiss reminder'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + await api.delete(`/reminders/${reminder.id}`); + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['reminders'] }); + const previous = queryClient.getQueryData(['reminders']); + queryClient.setQueryData(['reminders'], (old) => + old ? old.filter((r) => r.id !== reminder.id) : [] + ); + return { previous }; + }, + onSuccess: () => { + QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); + toast.success('Reminder deleted'); + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(['reminders'], context.previous); + } + toast.error('Failed to delete reminder'); + }, + }); + + const handleDelete = () => { + if (!confirmingDelete) { + setConfirmingDelete(true); + setTimeout(() => setConfirmingDelete(false), 2000); + return; + } + deleteMutation.mutate(); + setConfirmingDelete(false); + }; + + return ( +
+ + + onEdit(reminder)} + > + {reminder.title} + + + {reminder.recurrence_rule && ( + + {recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule} + + )} + + {remindDate && ( + + {format(remindDate, 'MMM d, h:mm a')} + + )} + + {!reminder.is_dismissed && ( + + )} + + + + +
+ ); +} diff --git a/frontend/src/components/reminders/ReminderList.tsx b/frontend/src/components/reminders/ReminderList.tsx index 9583f2b..4dae446 100644 --- a/frontend/src/components/reminders/ReminderList.tsx +++ b/frontend/src/components/reminders/ReminderList.tsx @@ -1,48 +1,71 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { format, isPast } from 'date-fns'; -import { Bell, BellOff, Trash2, Calendar } from 'lucide-react'; -import api from '@/lib/api'; +import { useMemo } from 'react'; +import { Bell } from 'lucide-react'; +import { parseISO, isPast, isToday, compareAsc } from 'date-fns'; import type { Reminder } from '@/types'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { EmptyState } from '@/components/ui/empty-state'; +import ReminderItem from './ReminderItem'; interface ReminderListProps { reminders: Reminder[]; onEdit: (reminder: Reminder) => void; + onAdd: () => void; } -export default function ReminderList({ reminders, onEdit }: ReminderListProps) { - const queryClient = useQueryClient(); +interface ReminderGroup { + key: string; + label: string; + reminders: Reminder[]; +} - const dismissMutation = useMutation({ - mutationFn: async (id: number) => { - const { data } = await api.patch(`/reminders/${id}/dismiss`); - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reminders'] }); - toast.success('Reminder dismissed'); - }, - onError: () => { - toast.error('Failed to dismiss reminder'); - }, - }); +function sortByRemindAt(a: Reminder, b: Reminder): number { + if (!a.remind_at && !b.remind_at) return 0; + if (!a.remind_at) return 1; + if (!b.remind_at) return -1; + return compareAsc(parseISO(a.remind_at), parseISO(b.remind_at)); +} - const deleteMutation = useMutation({ - mutationFn: async (id: number) => { - await api.delete(`/reminders/${id}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['reminders'] }); - toast.success('Reminder deleted'); - }, - onError: () => { - toast.error('Failed to delete reminder'); - }, - }); +export default function ReminderList({ reminders, onEdit, onAdd }: ReminderListProps) { + const groups = useMemo(() => { + const overdue: Reminder[] = []; + const today: Reminder[] = []; + const upcoming: Reminder[] = []; + const noDate: Reminder[] = []; + const dismissed: Reminder[] = []; + + for (const reminder of reminders) { + if (reminder.is_dismissed) { + dismissed.push(reminder); + continue; + } + + if (!reminder.remind_at) { + noDate.push(reminder); + continue; + } + + const date = parseISO(reminder.remind_at); + if (isToday(date)) { + today.push(reminder); + } else if (isPast(date)) { + overdue.push(reminder); + } else { + upcoming.push(reminder); + } + } + + overdue.sort(sortByRemindAt); + today.sort(sortByRemindAt); + upcoming.sort(sortByRemindAt); + + const result: ReminderGroup[] = []; + if (overdue.length > 0) result.push({ key: 'overdue', label: 'Overdue', reminders: overdue }); + if (today.length > 0) result.push({ key: 'today', label: 'Today', reminders: today }); + if (upcoming.length > 0) result.push({ key: 'upcoming', label: 'Upcoming', reminders: upcoming }); + if (noDate.length > 0) result.push({ key: 'no-date', label: 'No Date', reminders: noDate }); + if (dismissed.length > 0) result.push({ key: 'dismissed', label: 'Dismissed', reminders: dismissed }); + + return result; + }, [reminders]); if (reminders.length === 0) { return ( @@ -50,68 +73,37 @@ export default function ReminderList({ reminders, onEdit }: ReminderListProps) { icon={Bell} title="No reminders" description="Create a reminder so you never miss an important date or event." + actionLabel="Add Reminder" + onAction={onAdd} /> ); } + if (groups.length === 1) { + return ( +
+ {groups[0].reminders.map((reminder) => ( + + ))} +
+ ); + } + return ( -
- {reminders.map((reminder) => { - const isOverdue = !reminder.is_dismissed && !!reminder.remind_at && isPast(new Date(reminder.remind_at)); - return ( - onEdit(reminder)} - > - -
-
- {reminder.is_dismissed ? ( - - ) : ( - - )} - {reminder.title} -
-
-
- - {reminder.description && ( -

{reminder.description}

- )} -
- - {reminder.remind_at ? format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a') : 'No date set'} - {isOverdue && (Overdue)} -
-
e.stopPropagation()}> - {!reminder.is_dismissed && ( - - )} - -
-
-
- ); - })} +
+ {groups.map((group) => ( +
+

+ {group.label} + ({group.reminders.length}) +

+
+ {group.reminders.map((reminder) => ( + + ))} +
+
+ ))}
); } diff --git a/frontend/src/components/reminders/RemindersPage.tsx b/frontend/src/components/reminders/RemindersPage.tsx index 57f2aaf..7e7e314 100644 --- a/frontend/src/components/reminders/RemindersPage.tsx +++ b/frontend/src/components/reminders/RemindersPage.tsx @@ -1,17 +1,29 @@ -import { useState } from 'react'; -import { Plus } from 'lucide-react'; +import { useState, useMemo } from 'react'; +import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; +import { isPast, isToday, parseISO } from 'date-fns'; import api from '@/lib/api'; import type { Reminder } from '@/types'; import { Button } from '@/components/ui/button'; -import { GridSkeleton } from '@/components/ui/skeleton'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent } from '@/components/ui/card'; +import { ListSkeleton } from '@/components/ui/skeleton'; import ReminderList from './ReminderList'; import ReminderForm from './ReminderForm'; +const statusFilters = [ + { value: 'active', label: 'Active' }, + { value: 'dismissed', label: 'Dismissed' }, + { value: 'all', label: 'All' }, +] as const; + +type StatusFilter = (typeof statusFilters)[number]['value']; + export default function RemindersPage() { const [showForm, setShowForm] = useState(false); const [editingReminder, setEditingReminder] = useState(null); - const [filter, setFilter] = useState<'all' | 'active' | 'dismissed'>('active'); + const [filter, setFilter] = useState('active'); + const [search, setSearch] = useState(''); const { data: reminders = [], isLoading } = useQuery({ queryKey: ['reminders'], @@ -21,11 +33,22 @@ export default function RemindersPage() { }, }); - const filteredReminders = reminders.filter((reminder) => { - if (filter === 'active') return !reminder.is_dismissed; - if (filter === 'dismissed') return reminder.is_dismissed; - return true; - }); + const filteredReminders = useMemo( + () => + reminders.filter((r) => { + if (filter === 'active' && r.is_dismissed) return false; + if (filter === 'dismissed' && !r.is_dismissed) return false; + if (search && !r.title.toLowerCase().includes(search.toLowerCase())) return false; + return true; + }), + [reminders, filter, search] + ); + + const activeCount = reminders.filter((r) => !r.is_dismissed).length; + const overdueCount = reminders.filter( + (r) => !r.is_dismissed && r.remind_at && isPast(parseISO(r.remind_at)) && !isToday(parseISO(r.remind_at)) + ).length; + const dismissedCount = reminders.filter((r) => r.is_dismissed).length; const handleEdit = (reminder: Reminder) => { setEditingReminder(reminder); @@ -39,42 +62,103 @@ export default function RemindersPage() { return (
-
-
-

Reminders

- + {/* Header */} +
+

Reminders

+ +
+ {statusFilters.map((sf) => ( + + ))}
-
- - - +
+ + setSearch(e.target.value)} + className="w-52 h-8 pl-8 text-sm" + />
+ +
+ +
-
+
+ {/* Summary stats */} + {!isLoading && reminders.length > 0 && ( +
+ + +
+ +
+
+

+ Active +

+

{activeCount}

+
+
+
+ + +
+ +
+
+

+ Overdue +

+

{overdueCount}

+
+
+
+ + +
+ +
+
+

+ Dismissed +

+

{dismissedCount}

+
+
+
+
+ )} + {isLoading ? ( - + ) : ( - + setShowForm(true)} + /> )}