diff --git a/backend/alembic/versions/016_add_todo_indexes_and_defaults.py b/backend/alembic/versions/016_add_todo_indexes_and_defaults.py new file mode 100644 index 0000000..bc5da96 --- /dev/null +++ b/backend/alembic/versions/016_add_todo_indexes_and_defaults.py @@ -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") diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py index 62e4273..cf38b3f 100644 --- a/backend/app/models/todo.py +++ b/backend/app/models/todo.py @@ -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") diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 840669e..b1541ad 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -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: diff --git a/frontend/src/components/todos/TodoItem.tsx b/frontend/src/components/todos/TodoItem.tsx index 511a7e5..e31946a 100644 --- a/frontend/src/components/todos/TodoItem.tsx +++ b/frontend/src/components/todos/TodoItem.tsx @@ -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 = { 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(['todos']); + queryClient.setQueryData(['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) { diff --git a/frontend/src/components/todos/TodoList.tsx b/frontend/src/components/todos/TodoList.tsx index 2c61ea0..0e628dd 100644 --- a/frontend/src/components/todos/TodoList.tsx +++ b/frontend/src/components/todos/TodoList.tsx @@ -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 }); diff --git a/frontend/src/components/todos/TodosPage.tsx b/frontend/src/components/todos/TodosPage.tsx index f67189f..adbf44b 100644 --- a/frontend/src/components/todos/TodosPage.tsx +++ b/frontend/src/components/todos/TodosPage.tsx @@ -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); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9ad0df4..f504a74 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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); +}