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:
parent
79b3097410
commit
250cbd0239
@ -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")
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user