- Toast: snooze/dismiss buttons side-by-side on the right, dismiss uses X icon, snooze shows clock icon + 'Snooze' label - SnoozeDropdown trigger now shows text label alongside icon - Delete confirm 'Sure?' lingers 4 seconds instead of 2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.2 KiB
TypeScript
174 lines
5.2 KiB
TypeScript
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), 4000);
|
|
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>
|
|
|
|
{confirmingDelete ? (
|
|
<Button
|
|
variant="ghost"
|
|
aria-label="Confirm delete"
|
|
onClick={handleDelete}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
>
|
|
Sure?
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Delete reminder"
|
|
onClick={handleDelete}
|
|
disabled={deleteMutation.isPending}
|
|
className="h-7 w-7 shrink-0 hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|