UMBRA/frontend/src/components/reminders/ReminderDetailPanel.tsx
Kyle Pope 013f9ec010 Add custom DatePicker component, replace all native date inputs
Custom date-picker.tsx with date/datetime modes, portal popup with
month/year dropdowns, min/max constraints, and hidden input for form
validation. Replaces all 10 native <input type="date"> and
<input type="datetime-local"> across LockScreen, SettingsPage,
PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel,
ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium
calendar icon invert CSS fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:30:52 +08:00

475 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isPast, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle, AlignLeft,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Reminder } from '@/types';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
// --- Types ---
interface ReminderDetailPanelProps {
reminder: Reminder | null;
isCreating?: boolean;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
remind_at: string;
recurrence_rule: string;
}
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
function buildEditState(reminder: Reminder): EditState {
return {
title: reminder.title,
description: reminder.description || '',
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
recurrence_rule: reminder.recurrence_rule || '',
};
}
function buildCreateState(): EditState {
return {
title: '',
description: '',
remind_at: '',
recurrence_rule: '',
};
}
// --- Component ---
export default function ReminderDetailPanel({
reminder,
isCreating = false,
onClose,
onSaved,
onDeleted,
}: ReminderDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
);
// Reset state when reminder changes
useEffect(() => {
setIsEditing(false);
if (reminder) setEditState(buildEditState(reminder));
}, [reminder?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState());
}
}, [isCreating]);
const invalidateAll = useCallback(() => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const payload = {
title: data.title,
description: data.description || null,
remind_at: data.remind_at || null,
recurrence_rule: data.recurrence_rule || null,
};
if (reminder && !isCreating) {
return api.put(`/reminders/${reminder.id}`, payload);
} else {
return api.post('/reminders', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
},
});
const dismissMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder dismissed');
},
onError: () => {
toast.error('Failed to dismiss reminder');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/reminders/${reminder!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (reminder) setEditState(buildEditState(reminder));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (reminder) {
setEditState(buildEditState(reminder));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!reminder && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Bell className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a reminder to view details</p>
</div>
);
}
// View data
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;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing && !isCreating ? (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold flex-1"
placeholder="Reminder title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<Bell
className={`h-4 w-4 shrink-0 ${
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
}`}
/>
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
{reminder!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{(isEditing || isCreating) ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!reminder!.is_dismissed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
title="Dismiss reminder"
>
<BellOff className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit reminder"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete reminder"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{(isEditing || isCreating) ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="reminder-title" required>Title</Label>
<Input
id="reminder-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Reminder title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="reminder-desc">Description</Label>
<Textarea
id="reminder-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label>
<DatePicker
id="reminder-at"
mode="datetime"
value={editState.remind_at}
onChange={(v) => updateField('remind_at', v)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="reminder-recurrence">Recurrence</Label>
<Select
id="reminder-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* 2-column grid: Status, Recurrence, Remind At, Snoozed Until */}
<div className="grid grid-cols-2 gap-3">
{/* Status */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{reminder!.is_dismissed ? (
<BellOff className="h-3 w-3" />
) : isOverdue ? (
<AlertCircle className="h-3 w-3" />
) : (
<Bell className="h-3 w-3" />
)}
Status
</div>
{reminder!.is_dismissed ? (
<p className="text-sm text-muted-foreground">Dismissed</p>
) : isOverdue ? (
<p className="text-sm text-red-400">Overdue</p>
) : isDueToday ? (
<p className="text-sm text-yellow-400">Due today</p>
) : (
<p className="text-sm text-orange-400">Active</p>
)}
</div>
{/* Recurrence */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Recurrence
</div>
{reminder!.recurrence_rule ? (
<p className="text-sm">{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Remind At */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Remind At
</div>
{remindDate ? (
<CopyableField
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
icon={Clock}
label="Remind at"
/>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Snoozed Until */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Snoozed Until
</div>
{reminder!.snoozed_until ? (
<p className="text-sm">{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Description — full width */}
{reminder!.description && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" />
Description
</div>
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
{reminder!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(reminder!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}