UMBRA/frontend/src/components/todos/TodoDetailPanel.tsx
Kyle Pope b04854a488 Default date/time fields to today/now on create forms
Todo, reminder, project, and task forms now pre-fill date/time
fields with today's date and current time when creating new items.
Edit mode still uses stored values. DOB fields excluded.

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

555 lines
19 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle, AlignLeft,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
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 { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
// --- Types ---
export interface TodoCreateDefaults {
category?: string;
}
interface TodoDetailPanelProps {
todo: Todo | null;
isCreating?: boolean;
createDefaults?: TodoCreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
priority: string;
due_date: string;
due_time: string;
category: string;
recurrence_rule: string;
}
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
function todayLocal(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function nowTimeLocal(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
function buildEditState(todo: Todo): EditState {
return {
title: todo.title,
description: todo.description || '',
priority: todo.priority,
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
category: todo.category || '',
recurrence_rule: todo.recurrence_rule || '',
};
}
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
return {
title: '',
description: '',
priority: 'medium',
due_date: todayLocal(),
due_time: nowTimeLocal(),
category: defaults?.category || '',
recurrence_rule: '',
};
}
// --- Component ---
export default function TodoDetailPanel({
todo,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
}: TodoDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
);
// Reset state when todo changes
useEffect(() => {
setIsEditing(false);
if (todo) setEditState(buildEditState(todo));
}, [todo?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults));
}
}, [isCreating, createDefaults]);
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,
priority: data.priority,
due_date: data.due_date || null,
due_time: data.due_time || null,
category: data.category || null,
recurrence_rule: data.recurrence_rule || null,
};
if (todo && !isCreating) {
return api.put(`/todos/${todo.id}`, payload);
} else {
return api.post('/todos', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Todo created' : 'Todo updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
},
});
const toggleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success(todo!.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
toast.error('Failed to update todo');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/todos/${todo!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Todo deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete todo'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (todo) setEditState(buildEditState(todo));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (todo) {
setEditState(buildEditState(todo));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!todo && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a todo to view details</p>
</div>
);
}
// View data
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : 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="Todo title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={todo!.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
</span>
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo!.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>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit todo"
>
<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 todo"
>
<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="todo-title" required>Title</Label>
<Input
id="todo-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Todo title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="todo-desc">Description</Label>
<Textarea
id="todo-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="todo-priority">Priority</Label>
<Select
id="todo-priority"
value={editState.priority}
onChange={(e) => updateField('priority', e.target.value)}
className="text-xs"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="todo-category">Category</Label>
<Input
id="todo-category"
value={editState.category}
onChange={(e) => updateField('category', e.target.value)}
placeholder="e.g., Work"
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<DatePicker
variant="input"
id="todo-due-date"
mode="datetime"
value={editState.due_date ? (editState.due_date + 'T' + (editState.due_time || '00:00')) : ''}
onChange={(v) => {
updateField('due_date', v ? v.slice(0, 10) : '');
updateField('due_time', v ? v.slice(11, 16) : '');
}}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-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: Priority, Category, Due Date, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Priority */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Flag className="h-3 w-3" />
Priority
</div>
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
{todo!.priority}
</Badge>
</div>
{/* Category */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Tag className="h-3 w-3" />
Category
</div>
{todo!.category ? (
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
{todo!.category}
</Badge>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Due Date */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
Due Date
</div>
{dueDate ? (
<CopyableField
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
icon={isOverdue ? AlertCircle : Calendar}
label="Due date"
/>
) : todo!.due_time ? (
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
) : (
<p className="text-sm text-muted-foreground"></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>
{todo!.recurrence_rule ? (
<p className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Completion status — full width */}
{todo!.completed && todo!.completed_at && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<CheckSquare className="h-3 w-3" />
Completed
</div>
<p className="text-sm text-green-400">
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
</p>
</div>
)}
{/* Reset info — full width */}
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
<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" />
Resets
</div>
<p className="text-sm text-purple-400">
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
</p>
</div>
)}
{/* Description — full width */}
{todo!.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">
{todo!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(todo!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}