diff --git a/frontend/src/components/todos/TodoItem.tsx b/frontend/src/components/todos/TodoItem.tsx index ad87c81..2c06443 100644 --- a/frontend/src/components/todos/TodoItem.tsx +++ b/frontend/src/components/todos/TodoItem.tsx @@ -1,12 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Trash2, Calendar } from 'lucide-react'; -import { format } from 'date-fns'; +import { Trash2, Pencil, Calendar, AlertCircle } from 'lucide-react'; +import { format, isToday, isPast, parseISO, startOfDay } from 'date-fns'; import api from '@/lib/api'; import type { Todo } from '@/types'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; interface TodoItemProps { @@ -14,11 +13,11 @@ interface TodoItemProps { onEdit: (todo: Todo) => void; } -const priorityColors: Record = { - none: 'bg-gray-500/10 text-gray-400 border-gray-500/20', - low: 'bg-green-500/10 text-green-500 border-green-500/20', - medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', - high: 'bg-red-500/10 text-red-500 border-red-500/20', +const priorityStyles: Record = { + none: 'bg-gray-500/20 text-gray-400', + low: 'bg-green-500/20 text-green-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-red-500/20 text-red-400', }; export default function TodoItem({ todo, onEdit }: TodoItemProps) { @@ -31,6 +30,8 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success(todo.completed ? 'Todo marked incomplete' : 'Todo completed!'); }, onError: () => { @@ -44,6 +45,8 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Todo deleted'); }, onError: () => { @@ -51,44 +54,89 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) { }, }); + 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; + return ( -
+
toggleMutation.mutate()} disabled={toggleMutation.isPending} /> +
onEdit(todo)}> -

+

+ {todo.title} +

+ + {todo.priority} + + {todo.category && ( + + {todo.category} + )} - > - {todo.title} - +
+ {todo.description && (

{todo.description}

)} -
- {todo.priority} - {todo.category && {todo.category}} - {todo.due_date && ( -
+ + {dueDate && ( +
+ {isOverdue ? ( + + ) : ( - {format(new Date(todo.due_date), 'MMM d, yyyy')} -
- )} -
+ )} + {isOverdue ? 'Overdue — ' : isDueToday ? 'Today — ' : ''} + {format(dueDate, 'MMM d, yyyy')} +
+ )} +
+ +
+ +
-
); } diff --git a/frontend/src/components/todos/TodoList.tsx b/frontend/src/components/todos/TodoList.tsx index 2bf374d..99bffd3 100644 --- a/frontend/src/components/todos/TodoList.tsx +++ b/frontend/src/components/todos/TodoList.tsx @@ -1,4 +1,6 @@ +import { useMemo } from 'react'; import { CheckSquare } from 'lucide-react'; +import { parseISO, isToday, isPast, startOfDay } from 'date-fns'; import type { Todo } from '@/types'; import { EmptyState } from '@/components/ui/empty-state'; import TodoItem from './TodoItem'; @@ -6,23 +8,93 @@ import TodoItem from './TodoItem'; interface TodoListProps { todos: Todo[]; onEdit: (todo: Todo) => void; + onAdd: () => void; } -export default function TodoList({ todos, onEdit }: TodoListProps) { +interface TodoGroup { + key: string; + label: string; + todos: Todo[]; +} + +export default function TodoList({ todos, onEdit, onAdd }: TodoListProps) { + const groups = useMemo(() => { + const overdue: Todo[] = []; + const today: Todo[] = []; + const upcoming: Todo[] = []; + const noDueDate: Todo[] = []; + const completed: Todo[] = []; + + for (const todo of todos) { + if (todo.completed) { + completed.push(todo); + continue; + } + + if (!todo.due_date) { + noDueDate.push(todo); + continue; + } + + const dueDate = parseISO(todo.due_date); + if (isToday(dueDate)) { + today.push(todo); + } else if (isPast(startOfDay(dueDate))) { + overdue.push(todo); + } else { + upcoming.push(todo); + } + } + + 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 }); + if (upcoming.length > 0) result.push({ key: 'upcoming', label: 'Upcoming', todos: upcoming }); + if (noDueDate.length > 0) + result.push({ key: 'no-date', label: 'No Due Date', todos: noDueDate }); + if (completed.length > 0) + result.push({ key: 'completed', label: 'Completed', todos: completed }); + + return result; + }, [todos]); + if (todos.length === 0) { return ( ); } + // If only one group, skip headers + if (groups.length === 1) { + return ( +
+ {groups[0].todos.map((todo) => ( + + ))} +
+ ); + } + return ( -
- {todos.map((todo) => ( - +
+ {groups.map((group) => ( +
+

+ {group.label} + ({group.todos.length}) +

+
+ {group.todos.map((todo) => ( + + ))} +
+
))}
); diff --git a/frontend/src/components/todos/TodosPage.tsx b/frontend/src/components/todos/TodosPage.tsx index ace606c..1a47388 100644 --- a/frontend/src/components/todos/TodosPage.tsx +++ b/frontend/src/components/todos/TodosPage.tsx @@ -1,17 +1,24 @@ -import { useState } from 'react'; -import { Plus } from 'lucide-react'; +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 { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Select } from '@/components/ui/select'; +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: '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(null); @@ -30,15 +37,33 @@ export default function TodosPage() { }, }); + const categories = useMemo(() => { + const cats = new Set(); + todos.forEach((t) => { + if (t.category) cats.add(t.category); + }); + return Array.from(cats).sort(); + }, [todos]); + const filteredTodos = 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.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; }); + 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 handleEdit = (todo: Todo) => { setEditingTodo(todo); setShowForm(true); @@ -51,56 +76,132 @@ export default function TodosPage() { return (
-
-
-

Todos

- + {/* Header */} +
+

Todos

+ +
+ {priorityFilters.map((pf) => ( + + ))}
-
-
- setFilters({ ...filters, search: e.target.value })} - /> -
- +
+ setFilters({ ...filters, search: e.target.value })} + className="w-52 h-8 pl-8 text-sm" + /> +
+ +
+ +
+ +
+ + setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked }) + } + /> + +
+ +
+ +
-
+
+ {/* Summary stats */} + {!isLoading && todos.length > 0 && ( +
+ + +
+ +
+
+

+ Open +

+

{totalCount}

+
+
+
+ + +
+ +
+
+

+ Completed +

+

{completedCount}

+
+
+
+ + +
+ +
+
+

+ Overdue +

+

{overdueCount}

+
+
+
+
+ )} + {isLoading ? ( ) : ( - + setShowForm(true)} + /> )}