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>
213 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|