Kyle Pope 250cbd0239 Address remaining QA items: indexes, validation, UX improvements
Backend:
- [W1] Add server_default=func.now() on created_at/updated_at
- [W2] Add index on reset_at column (migration 016)
- [W7] Document weekly reset edge case in code comment

Frontend:
- [W4] Extract shared isTodoOverdue() utility in lib/utils.ts,
  used consistently across TodosPage, TodoItem, TodoList
- [W5] Delete requires double-click confirmation (button turns red
  for 2s, second click confirms) with optimistic removal
- [W6] Stat cards now reflect filtered counts, not global
- [S3] Optimistic delete with rollback on error
- [S4] Add "None" to priority segmented filter
- [S7] Sort todos within groups by due date ascending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:24:59 +08:00

213 lines
7.8 KiB
TypeScript

import { useState, useMemo } from 'react';
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ListSkeleton } from '@/components/ui/skeleton';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
const priorityFilters = [
{ value: '', label: 'All' },
{ value: 'none', label: 'None' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
] as const;
export default function TodosPage() {
const [showForm, setShowForm] = useState(false);
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [filters, setFilters] = useState({
priority: '',
category: '',
showCompleted: true,
search: '',
});
const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const { data } = await api.get<Todo[]>('/todos');
return data;
},
});
const categories = useMemo(() => {
const cats = new Set<string>();
todos.forEach((t) => {
if (t.category) cats.add(t.category);
});
return Array.from(cats).sort();
}, [todos]);
const filteredTodos = useMemo(
() =>
todos.filter((todo) => {
if (filters.priority && todo.priority !== filters.priority) return false;
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase())
return false;
if (!filters.showCompleted && todo.completed) return false;
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
return false;
return true;
}),
[todos, filters]
);
const totalCount = filteredTodos.filter((t) => !t.completed).length;
const completedCount = filteredTodos.filter((t) => t.completed).length;
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
const handleEdit = (todo: Todo) => {
setEditingTodo(todo);
setShowForm(true);
};
const handleCloseForm = () => {
setShowForm(false);
setEditingTodo(null);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => (
<button
key={pf.value}
onClick={() => setFilters({ ...filters, priority: pf.value })}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.priority === pf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor:
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{pf.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={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<div className="relative">
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="h-8 rounded-md border border-input bg-background px-3 pr-8 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="show-completed"
checked={filters.showCompleted}
onChange={(e) =>
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
}
/>
<Label htmlFor="show-completed" className="text-xs text-muted-foreground cursor-pointer">
Completed
</Label>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Todo
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Summary stats */}
{!isLoading && todos.length > 0 && (
<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-blue-500/10">
<CheckSquare className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Open
</p>
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</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-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Completed
</p>
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</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-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>
</div>
)}
{isLoading ? (
<ListSkeleton rows={6} />
) : (
<TodoList
todos={filteredTodos}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
/>
)}
</div>
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
</div>
);
}