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)
category: Mapped[Optional[str]] = mapped_column(String(100), 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)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=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(), server_default=func.now())
# Relationships
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
days_ahead = (target_weekday - today.weekday()) % 7
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)
if current_due_date:

View File

@ -1,10 +1,11 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
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 type { Todo } from '@/types';
import { cn } from '@/lib/utils';
import { cn, isTodoOverdue } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
@ -26,8 +27,11 @@ const recurrenceLabels: Record<string, string> = {
monthly: 'Monthly',
};
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const queryClient = useQueryClient();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const toggleMutation = useMutation({
mutationFn: async () => {
@ -35,9 +39,7 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
@ -49,20 +51,42 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
mutationFn: async () => {
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: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
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');
},
});
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 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 nextDueDate = todo.next_due_date ? parseISO(todo.next_due_date) : null;
@ -155,10 +179,15 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
<Button
variant="ghost"
size="icon"
aria-label="Delete todo"
onClick={() => deleteMutation.mutate()}
aria-label={confirmingDelete ? 'Confirm delete' : 'Delete todo'}
onClick={handleDelete}
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" />
</Button>

View File

@ -1,7 +1,8 @@
import { useMemo } from '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 { isTodoOverdue } from '@/lib/utils';
import { EmptyState } from '@/components/ui/empty-state';
import TodoItem from './TodoItem';
@ -17,6 +18,14 @@ interface TodoGroup {
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) {
const groups = useMemo(() => {
const overdue: Todo[] = [];
@ -36,16 +45,20 @@ export default function TodoList({ todos, onEdit, onAdd }: TodoListProps) {
continue;
}
const dueDate = parseISO(todo.due_date);
if (isToday(dueDate)) {
if (isToday(parseISO(todo.due_date))) {
today.push(todo);
} else if (isPast(startOfDay(dueDate))) {
} else if (isTodoOverdue(todo.due_date, false)) {
overdue.push(todo);
} else {
upcoming.push(todo);
}
}
// Sort date-bearing groups by due date ascending
overdue.sort(sortByDueDate);
today.sort(sortByDueDate);
upcoming.sort(sortByDueDate);
const result: TodoGroup[] = [];
if (overdue.length > 0) result.push({ key: 'overdue', label: 'Overdue', todos: overdue });
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 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';
@ -14,6 +15,7 @@ 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' },
@ -59,14 +61,9 @@ export default function TodosPage() {
[todos, filters]
);
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
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 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);

View File

@ -1,6 +1,17 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { parseISO, isToday, isPast, startOfDay } from 'date-fns';
export function cn(...inputs: ClassValue[]) {
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);
}