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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-23 21:40:29 +08:00
parent 250cbd0239
commit e3ecc11a21
4 changed files with 393 additions and 136 deletions

View File

@ -227,11 +227,26 @@ Several components feel like unstyled defaults:
- [x] Better empty states with contextual illustrations or suggestions - [x] Better empty states with contextual illustrations or suggestions
- [x] Consistent hover/click affordances - [x] Consistent hover/click affordances
#### Todos & Reminders — pending #### Todos — COMPLETED
- Refined filter bar components - [x] Compact h-16 header with segmented priority filter, search, category dropdown, show-completed toggle
- Improved card designs - [x] Summary stat cards (Open, Completed, Overdue)
- Better empty states with contextual illustrations or suggestions - [x] Single-line compact row design (list-view convention: no card borders, hover:bg-card-elevated)
- Consistent hover/click affordances - [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) ### Stage 5: Entity Pages (People, Locations)
- Avatar placeholders for People cards - Avatar placeholders for People cards
@ -260,4 +275,4 @@ Several components feel like unstyled defaults:
## Refresh Scope ## 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).

View File

@ -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<string, string> = {
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<Reminder[]>(['reminders']);
queryClient.setQueryData<Reminder[]>(['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 (
<div
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md transition-colors duration-150',
'hover:bg-card-elevated',
reminder.is_dismissed && 'opacity-50'
)}
>
<Bell
className={cn(
'h-4 w-4 shrink-0',
isOverdue
? 'text-red-400'
: reminder.is_dismissed
? 'text-muted-foreground'
: 'text-orange-400'
)}
/>
<span
className={cn(
'text-sm font-medium truncate flex-1 min-w-0 cursor-pointer',
reminder.is_dismissed && 'line-through text-muted-foreground'
)}
onClick={() => onEdit(reminder)}
>
{reminder.title}
</span>
{reminder.recurrence_rule && (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-purple-500/15 text-purple-400 shrink-0">
{recurrenceLabels[reminder.recurrence_rule] || reminder.recurrence_rule}
</span>
)}
{remindDate && (
<span
className={cn(
'text-[11px] shrink-0',
isOverdue ? 'text-red-400' : isDueToday ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{format(remindDate, 'MMM d, h:mm a')}
</span>
)}
{!reminder.is_dismissed && (
<Button
variant="ghost"
size="icon"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
className="h-7 w-7 shrink-0 hover:bg-orange-500/10 hover:text-orange-400"
aria-label="Dismiss reminder"
>
<BellOff className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(reminder)}
className="h-7 w-7 shrink-0"
aria-label="Edit reminder"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={confirmingDelete ? 'Confirm delete' : 'Delete reminder'}
onClick={handleDelete}
disabled={deleteMutation.isPending}
className={cn(
'h-7 w-7 shrink-0',
confirmingDelete
? 'bg-destructive/20 text-destructive'
: 'hover:bg-destructive/10 hover:text-destructive'
)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -1,48 +1,71 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react';
import { toast } from 'sonner'; import { Bell } from 'lucide-react';
import { format, isPast } from 'date-fns'; import { parseISO, isPast, isToday, compareAsc } from 'date-fns';
import { Bell, BellOff, Trash2, Calendar } from 'lucide-react';
import api from '@/lib/api';
import type { Reminder } from '@/types'; 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 { EmptyState } from '@/components/ui/empty-state';
import ReminderItem from './ReminderItem';
interface ReminderListProps { interface ReminderListProps {
reminders: Reminder[]; reminders: Reminder[];
onEdit: (reminder: Reminder) => void; onEdit: (reminder: Reminder) => void;
onAdd: () => void;
} }
export default function ReminderList({ reminders, onEdit }: ReminderListProps) { interface ReminderGroup {
const queryClient = useQueryClient(); key: string;
label: string;
reminders: Reminder[];
}
const dismissMutation = useMutation({ function sortByRemindAt(a: Reminder, b: Reminder): number {
mutationFn: async (id: number) => { if (!a.remind_at && !b.remind_at) return 0;
const { data } = await api.patch(`/reminders/${id}/dismiss`); if (!a.remind_at) return 1;
return data; if (!b.remind_at) return -1;
}, return compareAsc(parseISO(a.remind_at), parseISO(b.remind_at));
onSuccess: () => { }
queryClient.invalidateQueries({ queryKey: ['reminders'] });
toast.success('Reminder dismissed');
},
onError: () => {
toast.error('Failed to dismiss reminder');
},
});
const deleteMutation = useMutation({ export default function ReminderList({ reminders, onEdit, onAdd }: ReminderListProps) {
mutationFn: async (id: number) => { const groups = useMemo(() => {
await api.delete(`/reminders/${id}`); const overdue: Reminder[] = [];
}, const today: Reminder[] = [];
onSuccess: () => { const upcoming: Reminder[] = [];
queryClient.invalidateQueries({ queryKey: ['reminders'] }); const noDate: Reminder[] = [];
toast.success('Reminder deleted'); const dismissed: Reminder[] = [];
},
onError: () => { for (const reminder of reminders) {
toast.error('Failed to delete reminder'); 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) { if (reminders.length === 0) {
return ( return (
@ -50,68 +73,37 @@ export default function ReminderList({ reminders, onEdit }: ReminderListProps) {
icon={Bell} icon={Bell}
title="No reminders" title="No reminders"
description="Create a reminder so you never miss an important date or event." description="Create a reminder so you never miss an important date or event."
actionLabel="Add Reminder"
onAction={onAdd}
/> />
); );
} }
if (groups.length === 1) {
return ( return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-0.5">
{reminders.map((reminder) => { {groups[0].reminders.map((reminder) => (
const isOverdue = !reminder.is_dismissed && !!reminder.remind_at && isPast(new Date(reminder.remind_at)); <ReminderItem key={reminder.id} reminder={reminder} onEdit={onEdit} />
return ( ))}
<Card
key={reminder.id}
className={cn(
'cursor-pointer transition-colors hover:bg-accent/5',
isOverdue && 'border-destructive'
)}
onClick={() => onEdit(reminder)}
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{reminder.is_dismissed ? (
<BellOff className="h-5 w-5 text-muted-foreground" />
) : (
<Bell className={cn('h-5 w-5', isOverdue && 'text-destructive')} />
)}
<CardTitle className="text-lg">{reminder.title}</CardTitle>
</div> </div>
</div>
</CardHeader>
<CardContent>
{reminder.description && (
<p className="text-sm text-muted-foreground mb-3">{reminder.description}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<Calendar className="h-4 w-4" />
{reminder.remind_at ? format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a') : 'No date set'}
{isOverdue && <span className="text-destructive font-medium">(Overdue)</span>}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
{!reminder.is_dismissed && (
<Button
size="sm"
variant="outline"
onClick={() => dismissMutation.mutate(reminder.id)}
disabled={dismissMutation.isPending}
>
Dismiss
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => deleteMutation.mutate(reminder.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
); );
})} }
return (
<div className="space-y-5">
{groups.map((group) => (
<div key={group.key}>
<h3 className="text-xs uppercase tracking-wider text-muted-foreground mb-2 px-1">
{group.label}
<span className="ml-1.5 tabular-nums">({group.reminders.length})</span>
</h3>
<div className="space-y-0.5">
{group.reminders.map((reminder) => (
<ReminderItem key={reminder.id} reminder={reminder} onEdit={onEdit} />
))}
</div>
</div>
))}
</div> </div>
); );
} }

View File

@ -1,17 +1,29 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus } from 'lucide-react'; import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isPast, isToday, parseISO } from 'date-fns';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
import { Button } from '@/components/ui/button'; 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 ReminderList from './ReminderList';
import ReminderForm from './ReminderForm'; 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() { export default function RemindersPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null); const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'dismissed'>('active'); const [filter, setFilter] = useState<StatusFilter>('active');
const [search, setSearch] = useState('');
const { data: reminders = [], isLoading } = useQuery({ const { data: reminders = [], isLoading } = useQuery({
queryKey: ['reminders'], queryKey: ['reminders'],
@ -21,11 +33,22 @@ export default function RemindersPage() {
}, },
}); });
const filteredReminders = reminders.filter((reminder) => { const filteredReminders = useMemo(
if (filter === 'active') return !reminder.is_dismissed; () =>
if (filter === 'dismissed') return reminder.is_dismissed; 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; 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) => { const handleEdit = (reminder: Reminder) => {
setEditingReminder(reminder); setEditingReminder(reminder);
@ -39,42 +62,103 @@ export default function RemindersPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">Reminders</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
<Button onClick={() => setShowForm(true)}>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
{statusFilters.map((sf) => (
<button
key={sf.value}
onClick={() => setFilter(sf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filter === sf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor:
filter === sf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filter === sf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{sf.label}
</button>
))}
</div>
<div className="relative ml-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Reminder Add Reminder
</Button> </Button>
</div> </div>
<div className="flex gap-2"> <div className="flex-1 overflow-y-auto px-6 py-5">
<Button {/* Summary stats */}
variant={filter === 'active' ? 'default' : 'outline'} {!isLoading && reminders.length > 0 && (
onClick={() => setFilter('active')} <div className="grid gap-2.5 grid-cols-3 mb-5">
> <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-orange-500/10">
<Bell className="h-4 w-4 text-orange-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Active Active
</Button> </p>
<Button <p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p>
variant={filter === 'dismissed' ? 'default' : 'outline'} </div>
onClick={() => setFilter('dismissed')} </CardContent>
> </Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-red-500/10">
<AlertCircle className="h-4 w-4 text-red-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Overdue
</p>
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-gray-500/10">
<BellOff className="h-4 w-4 text-gray-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Dismissed Dismissed
</Button> </p>
<Button <p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p>
variant={filter === 'all' ? 'default' : 'outline'}
onClick={() => setFilter('all')}
>
All
</Button>
</div> </div>
</CardContent>
</Card>
</div> </div>
)}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <ListSkeleton rows={6} />
) : ( ) : (
<ReminderList reminders={filteredReminders} onEdit={handleEdit} /> <ReminderList
reminders={filteredReminders}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
/>
)} )}
</div> </div>