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:
parent
250cbd0239
commit
e3ecc11a21
@ -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).
|
||||||
|
|||||||
166
frontend/src/components/reminders/ReminderItem.tsx
Normal file
166
frontend/src/components/reminders/ReminderItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
</div>
|
||||||
key={reminder.id}
|
);
|
||||||
className={cn(
|
}
|
||||||
'cursor-pointer transition-colors hover:bg-accent/5',
|
|
||||||
isOverdue && 'border-destructive'
|
return (
|
||||||
)}
|
<div className="space-y-5">
|
||||||
onClick={() => onEdit(reminder)}
|
{groups.map((group) => (
|
||||||
>
|
<div key={group.key}>
|
||||||
<CardHeader>
|
<h3 className="text-xs uppercase tracking-wider text-muted-foreground mb-2 px-1">
|
||||||
<div className="flex items-start justify-between">
|
{group.label}
|
||||||
<div className="flex items-center gap-2">
|
<span className="ml-1.5 tabular-nums">({group.reminders.length})</span>
|
||||||
{reminder.is_dismissed ? (
|
</h3>
|
||||||
<BellOff className="h-5 w-5 text-muted-foreground" />
|
<div className="space-y-0.5">
|
||||||
) : (
|
{group.reminders.map((reminder) => (
|
||||||
<Bell className={cn('h-5 w-5', isOverdue && 'text-destructive')} />
|
<ReminderItem key={reminder.id} reminder={reminder} onEdit={onEdit} />
|
||||||
)}
|
))}
|
||||||
<CardTitle className="text-lg">{reminder.title}</CardTitle>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user