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>
This commit is contained in:
Kyle 2026-02-23 21:24:59 +08:00
parent 79b3097410
commit 250cbd0239
7 changed files with 107 additions and 29 deletions

View File

@ -0,0 +1,28 @@
"""Add index on reset_at and server defaults on timestamps
Revision ID: 016
Revises: 015
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "016"
down_revision = "015"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index("ix_todos_reset_at", "todos", ["reset_at"])
op.alter_column("todos", "created_at", server_default=sa.func.now())
op.alter_column("todos", "updated_at", server_default=sa.func.now())
def downgrade() -> None:
op.alter_column("todos", "updated_at", server_default=None)
op.alter_column("todos", "created_at", server_default=None)
op.drop_index("ix_todos_reset_at", table_name="todos")

View File

@ -18,11 +18,11 @@ class Todo(Base):
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
reset_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) reset_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, index=True)
next_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) next_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True) project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now(), server_default=func.now())
# Relationships # Relationships
project: Mapped[Optional["Project"]] = relationship(back_populates="todos") project: Mapped[Optional["Project"]] = relationship(back_populates="todos")

View File

@ -41,7 +41,7 @@ def _calculate_recurrence(
target_weekday = 6 if first_day_of_week == 0 else 0 # Python weekday for start target_weekday = 6 if first_day_of_week == 0 else 0 # Python weekday for start
days_ahead = (target_weekday - today.weekday()) % 7 days_ahead = (target_weekday - today.weekday()) % 7
if days_ahead == 0: if days_ahead == 0:
days_ahead = 7 # Always push to *next* week days_ahead = 7 # Always push to *next* week, even if completed on the first day
reset_date = today + timedelta(days=days_ahead) reset_date = today + timedelta(days=days_ahead)
if current_due_date: if current_due_date:

View File

@ -1,10 +1,11 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Trash2, Pencil, Calendar, Clock, AlertCircle, RefreshCw } from 'lucide-react'; import { Trash2, Pencil, Calendar, Clock, AlertCircle, RefreshCw } from 'lucide-react';
import { format, isToday, isPast, parseISO, startOfDay } from 'date-fns'; import { format, isToday, parseISO } from 'date-fns';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { cn } from '@/lib/utils'; import { cn, isTodoOverdue } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -26,8 +27,11 @@ const recurrenceLabels: Record<string, string> = {
monthly: 'Monthly', monthly: 'Monthly',
}; };
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
export default function TodoItem({ todo, onEdit }: TodoItemProps) { export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -35,9 +39,7 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!'); toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!');
}, },
onError: () => { onError: () => {
@ -49,20 +51,42 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
mutationFn: async () => { mutationFn: async () => {
await api.delete(`/todos/${todo.id}`); await api.delete(`/todos/${todo.id}`);
}, },
onMutate: async () => {
// Optimistic removal
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old ? old.filter((t) => t.id !== todo.id) : []
);
return { previous };
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Todo deleted'); toast.success('Todo deleted');
}, },
onError: () => { onError: (_err, _vars, context) => {
// Rollback on failure
if (context?.previous) {
queryClient.setQueryData(['todos'], context.previous);
}
toast.error('Failed to delete todo'); toast.error('Failed to delete todo');
}, },
}); });
const handleDelete = () => {
if (!confirmingDelete) {
setConfirmingDelete(true);
// Auto-reset after 2 seconds if not confirmed
setTimeout(() => setConfirmingDelete(false), 2000);
return;
}
deleteMutation.mutate();
setConfirmingDelete(false);
};
const dueDate = todo.due_date ? parseISO(todo.due_date) : null; const dueDate = todo.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false; const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = dueDate && !todo.completed ? isPast(startOfDay(dueDate)) && !isDueToday : false; const isOverdue = isTodoOverdue(todo.due_date, todo.completed);
const resetDate = todo.reset_at ? parseISO(todo.reset_at) : null; const resetDate = todo.reset_at ? parseISO(todo.reset_at) : null;
const nextDueDate = todo.next_due_date ? parseISO(todo.next_due_date) : null; const nextDueDate = todo.next_due_date ? parseISO(todo.next_due_date) : null;
@ -155,10 +179,15 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Delete todo" aria-label={confirmingDelete ? 'Confirm delete' : 'Delete todo'}
onClick={() => deleteMutation.mutate()} onClick={handleDelete}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="h-7 w-7 shrink-0 hover:bg-destructive/10 hover:text-destructive" className={cn(
'h-7 w-7 shrink-0',
confirmingDelete
? 'bg-destructive/20 text-destructive'
: 'hover:bg-destructive/10 hover:text-destructive'
)}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>

View File

@ -1,7 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CheckSquare } from 'lucide-react'; import { CheckSquare } from 'lucide-react';
import { parseISO, isToday, isPast, startOfDay } from 'date-fns'; import { parseISO, isToday, compareAsc } from 'date-fns';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import TodoItem from './TodoItem'; import TodoItem from './TodoItem';
@ -17,6 +18,14 @@ interface TodoGroup {
todos: Todo[]; todos: Todo[];
} }
/** Sort todos by due_date ascending (earliest first), nulls last. */
function sortByDueDate(a: Todo, b: Todo): number {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return compareAsc(parseISO(a.due_date), parseISO(b.due_date));
}
export default function TodoList({ todos, onEdit, onAdd }: TodoListProps) { export default function TodoList({ todos, onEdit, onAdd }: TodoListProps) {
const groups = useMemo(() => { const groups = useMemo(() => {
const overdue: Todo[] = []; const overdue: Todo[] = [];
@ -36,16 +45,20 @@ export default function TodoList({ todos, onEdit, onAdd }: TodoListProps) {
continue; continue;
} }
const dueDate = parseISO(todo.due_date); if (isToday(parseISO(todo.due_date))) {
if (isToday(dueDate)) {
today.push(todo); today.push(todo);
} else if (isPast(startOfDay(dueDate))) { } else if (isTodoOverdue(todo.due_date, false)) {
overdue.push(todo); overdue.push(todo);
} else { } else {
upcoming.push(todo); upcoming.push(todo);
} }
} }
// Sort date-bearing groups by due date ascending
overdue.sort(sortByDueDate);
today.sort(sortByDueDate);
upcoming.sort(sortByDueDate);
const result: TodoGroup[] = []; const result: TodoGroup[] = [];
if (overdue.length > 0) result.push({ key: 'overdue', label: 'Overdue', todos: overdue }); if (overdue.length > 0) result.push({ key: 'overdue', label: 'Overdue', todos: overdue });
if (today.length > 0) result.push({ key: 'today', label: 'Today', todos: today }); if (today.length > 0) result.push({ key: 'today', label: 'Today', todos: today });

View File

@ -3,6 +3,7 @@ import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } fro
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -14,6 +15,7 @@ import TodoForm from './TodoForm';
const priorityFilters = [ const priorityFilters = [
{ value: '', label: 'All' }, { value: '', label: 'All' },
{ value: 'none', label: 'None' },
{ value: 'low', label: 'Low' }, { value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }, { value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }, { value: 'high', label: 'High' },
@ -59,14 +61,9 @@ export default function TodosPage() {
[todos, filters] [todos, filters]
); );
const now = new Date(); const totalCount = filteredTodos.filter((t) => !t.completed).length;
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const completedCount = filteredTodos.filter((t) => t.completed).length;
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
const totalCount = todos.filter((t) => !t.completed).length;
const completedCount = todos.filter((t) => t.completed).length;
const overdueCount = todos.filter(
(t) => !t.completed && t.due_date && t.due_date < todayStr
).length;
const handleEdit = (todo: Todo) => { const handleEdit = (todo: Todo) => {
setEditingTodo(todo); setEditingTodo(todo);

View File

@ -1,6 +1,17 @@
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { parseISO, isToday, isPast, startOfDay } from 'date-fns';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Check if a todo's due date is overdue (past and not today).
* Returns false if no due_date or if the todo is completed.
*/
export function isTodoOverdue(dueDate: string | undefined, completed: boolean): boolean {
if (!dueDate || completed) return false;
const parsed = parseISO(dueDate);
return isPast(startOfDay(parsed)) && !isToday(parsed);
}