diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3bccf81..46f85b9 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -175,6 +175,33 @@ export const dailySummaries = pgTable("daily_summaries", { export type DailySummary = typeof dailySummaries.$inferSelect; export type NewDailySummary = typeof dailySummaries.$inferInsert; +// ─── Personal Todos ─── + +export const todoPriorityEnum = pgEnum("todo_priority", [ + "high", + "medium", + "low", + "none", +]); + +export const todos = pgTable("todos", { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + title: text("title").notNull(), + description: text("description"), + isCompleted: boolean("is_completed").notNull().default(false), + priority: todoPriorityEnum("priority").notNull().default("none"), + category: text("category"), + dueDate: timestamp("due_date", { withTimezone: true }), + completedAt: timestamp("completed_at", { withTimezone: true }), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type Todo = typeof todos.$inferSelect; +export type NewTodo = typeof todos.$inferInsert; + // ─── BetterAuth tables ─── export const users = pgTable("users", { diff --git a/backend/src/index.ts b/backend/src/index.ts index 555fc93..a34f2c9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import { commentRoutes } from "./routes/comments"; import { activityRoutes } from "./routes/activity"; import { summaryRoutes } from "./routes/summaries"; import { securityRoutes } from "./routes/security"; +import { todoRoutes } from "./routes/todos"; import { auth } from "./lib/auth"; import { db } from "./db"; import { tasks, users } from "./db/schema"; @@ -124,6 +125,7 @@ const app = new Elysia() .use(adminRoutes) .use(securityRoutes) .use(summaryRoutes) + .use(todoRoutes) // Current user info (role, etc.) .get("/api/me", async ({ request }) => { diff --git a/backend/src/routes/todos.ts b/backend/src/routes/todos.ts new file mode 100644 index 0000000..56851a1 --- /dev/null +++ b/backend/src/routes/todos.ts @@ -0,0 +1,264 @@ +import { Elysia, t } from "elysia"; +import { db } from "../db"; +import { todos } from "../db/schema"; +import { eq, and, asc, desc, sql } from "drizzle-orm"; +import { auth } from "../lib/auth"; + +const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; + +async function requireSessionOrBearer(request: Request, headers: Record) { + const authHeader = headers["authorization"]; + if (authHeader === `Bearer ${BEARER_TOKEN}`) { + // Return a default user ID for bearer token access + return { userId: "bearer" }; + } + + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (session?.user) return { userId: session.user.id }; + } catch {} + throw new Error("Unauthorized"); +} + +export const todoRoutes = new Elysia({ prefix: "/api/todos" }) + .onError(({ error, set }) => { + const msg = (error as any)?.message || String(error); + if (msg === "Unauthorized") { + set.status = 401; + return { error: "Unauthorized" }; + } + if (msg === "Not found") { + set.status = 404; + return { error: "Not found" }; + } + console.error("Todo route error:", msg); + set.status = 500; + return { error: "Internal server error" }; + }) + + // GET all todos for current user + .get("/", async ({ request, headers, query }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const conditions = [eq(todos.userId, userId)]; + + // Filter by completion + if (query.completed === "true") { + conditions.push(eq(todos.isCompleted, true)); + } else if (query.completed === "false") { + conditions.push(eq(todos.isCompleted, false)); + } + + // Filter by category + if (query.category) { + conditions.push(eq(todos.category, query.category)); + } + + const userTodos = await db + .select() + .from(todos) + .where(and(...conditions)) + .orderBy( + asc(todos.isCompleted), + desc(sql`CASE ${todos.priority} WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`), + asc(todos.sortOrder), + desc(todos.createdAt) + ); + + return userTodos; + }, { + query: t.Object({ + completed: t.Optional(t.String()), + category: t.Optional(t.String()), + }), + }) + + // GET categories (distinct) + .get("/categories", async ({ request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const result = await db + .selectDistinct({ category: todos.category }) + .from(todos) + .where(and(eq(todos.userId, userId), sql`${todos.category} IS NOT NULL AND ${todos.category} != ''`)) + .orderBy(asc(todos.category)); + + return result.map((r) => r.category).filter(Boolean) as string[]; + }) + + // POST create todo + .post("/", async ({ body, request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + // Get max sort order + const maxOrder = await db + .select({ max: sql`COALESCE(MAX(${todos.sortOrder}), 0)` }) + .from(todos) + .where(eq(todos.userId, userId)); + + const [todo] = await db + .insert(todos) + .values({ + userId, + title: body.title, + description: body.description || null, + priority: body.priority || "none", + category: body.category || null, + dueDate: body.dueDate ? new Date(body.dueDate) : null, + sortOrder: (maxOrder[0]?.max ?? 0) + 1, + }) + .returning(); + + return todo; + }, { + body: t.Object({ + title: t.String({ minLength: 1 }), + description: t.Optional(t.String()), + priority: t.Optional(t.Union([ + t.Literal("high"), + t.Literal("medium"), + t.Literal("low"), + t.Literal("none"), + ])), + category: t.Optional(t.String()), + dueDate: t.Optional(t.Union([t.String(), t.Null()])), + }), + }) + + // PATCH update todo + .patch("/:id", async ({ params, body, request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const existing = await db + .select() + .from(todos) + .where(and(eq(todos.id, params.id), eq(todos.userId, userId))); + + if (!existing.length) throw new Error("Not found"); + + const updates: Record = { updatedAt: new Date() }; + if (body.title !== undefined) updates.title = body.title; + if (body.description !== undefined) updates.description = body.description; + if (body.priority !== undefined) updates.priority = body.priority; + if (body.category !== undefined) updates.category = body.category || null; + if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; + if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder; + if (body.isCompleted !== undefined) { + updates.isCompleted = body.isCompleted; + updates.completedAt = body.isCompleted ? new Date() : null; + } + + const [updated] = await db + .update(todos) + .set(updates) + .where(eq(todos.id, params.id)) + .returning(); + + return updated; + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ + title: t.Optional(t.String()), + description: t.Optional(t.String()), + priority: t.Optional(t.Union([ + t.Literal("high"), + t.Literal("medium"), + t.Literal("low"), + t.Literal("none"), + ])), + category: t.Optional(t.Union([t.String(), t.Null()])), + dueDate: t.Optional(t.Union([t.String(), t.Null()])), + isCompleted: t.Optional(t.Boolean()), + sortOrder: t.Optional(t.Number()), + }), + }) + + // PATCH toggle complete + .patch("/:id/toggle", async ({ params, request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const existing = await db + .select() + .from(todos) + .where(and(eq(todos.id, params.id), eq(todos.userId, userId))); + + if (!existing.length) throw new Error("Not found"); + + const nowCompleted = !existing[0].isCompleted; + const [updated] = await db + .update(todos) + .set({ + isCompleted: nowCompleted, + completedAt: nowCompleted ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(todos.id, params.id)) + .returning(); + + return updated; + }, { + params: t.Object({ id: t.String() }), + }) + + // DELETE todo + .delete("/:id", async ({ params, request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const existing = await db + .select() + .from(todos) + .where(and(eq(todos.id, params.id), eq(todos.userId, userId))); + + if (!existing.length) throw new Error("Not found"); + + await db.delete(todos).where(eq(todos.id, params.id)); + return { success: true }; + }, { + params: t.Object({ id: t.String() }), + }) + + // POST bulk import (for migration) + .post("/import", async ({ body, request, headers }) => { + const { userId } = await requireSessionOrBearer(request, headers); + + const imported = []; + for (const item of body.todos) { + const [todo] = await db + .insert(todos) + .values({ + userId, + title: item.title, + description: item.description || null, + isCompleted: item.isCompleted || false, + priority: item.priority || "none", + category: item.category || null, + dueDate: item.dueDate ? new Date(item.dueDate) : null, + completedAt: item.completedAt ? new Date(item.completedAt) : null, + sortOrder: item.sortOrder || 0, + createdAt: item.createdAt ? new Date(item.createdAt) : new Date(), + }) + .returning(); + imported.push(todo); + } + + return { imported: imported.length, todos: imported }; + }, { + body: t.Object({ + todos: t.Array(t.Object({ + title: t.String(), + description: t.Optional(t.Union([t.String(), t.Null()])), + isCompleted: t.Optional(t.Boolean()), + priority: t.Optional(t.Union([ + t.Literal("high"), + t.Literal("medium"), + t.Literal("low"), + t.Literal("none"), + ])), + category: t.Optional(t.Union([t.String(), t.Null()])), + dueDate: t.Optional(t.Union([t.String(), t.Null()])), + completedAt: t.Optional(t.Union([t.String(), t.Null()])), + sortOrder: t.Optional(t.Number()), + createdAt: t.Optional(t.String()), + })), + }), + }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c7b707..1a428c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ defa const SummariesPage = lazy(() => import("./pages/SummariesPage").then(m => ({ default: m.SummariesPage }))); const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage }))); const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage }))); +const TodosPage = lazy(() => import("./pages/TodosPage").then(m => ({ default: m.TodosPage }))); function PageLoader() { return ( @@ -40,6 +41,7 @@ function AuthenticatedApp() { }>} /> }>} /> }>} /> + }>} /> }>} /> } /> diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 242c2bf..f4dbe67 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -10,6 +10,7 @@ import { signOut } from "../lib/auth-client"; const navItems = [ { to: "/", label: "Dashboard", icon: "🔨", badgeKey: null }, { to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" }, + { to: "/todos", label: "Todos", icon: "✅", badgeKey: null }, { to: "/projects", label: "Projects", icon: "📁", badgeKey: null }, { to: "/activity", label: "Activity", icon: "📝", badgeKey: null }, { to: "/summaries", label: "Summaries", icon: "📅", badgeKey: null }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1220d3f..dec009a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence } from "./types"; +import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence, Todo, TodoPriority } from "./types"; const BASE = "/api/tasks"; @@ -228,3 +228,75 @@ export async function deleteUser(userId: string): Promise { }); if (!res.ok) throw new Error("Failed to delete user"); } + +// ─── Todos API ─── + +const TODOS_BASE = "/api/todos"; + +export async function fetchTodos(params?: { completed?: string; category?: string }): Promise { + const url = new URL(TODOS_BASE, window.location.origin); + if (params?.completed) url.searchParams.set("completed", params.completed); + if (params?.category) url.searchParams.set("category", params.category); + const res = await fetch(url.toString(), { credentials: "include" }); + if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch todos"); + return res.json(); +} + +export async function fetchTodoCategories(): Promise { + const res = await fetch(`${TODOS_BASE}/categories`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch categories"); + return res.json(); +} + +export async function createTodo(todo: { + title: string; + description?: string; + priority?: TodoPriority; + category?: string; + dueDate?: string | null; +}): Promise { + const res = await fetch(TODOS_BASE, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(todo), + }); + if (!res.ok) throw new Error("Failed to create todo"); + return res.json(); +} + +export async function updateTodo(id: string, updates: Partial<{ + title: string; + description: string; + priority: TodoPriority; + category: string | null; + dueDate: string | null; + isCompleted: boolean; + sortOrder: number; +}>): Promise { + const res = await fetch(`${TODOS_BASE}/${id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error("Failed to update todo"); + return res.json(); +} + +export async function toggleTodo(id: string): Promise { + const res = await fetch(`${TODOS_BASE}/${id}/toggle`, { + method: "PATCH", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to toggle todo"); + return res.json(); +} + +export async function deleteTodo(id: string): Promise { + const res = await fetch(`${TODOS_BASE}/${id}`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to delete todo"); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 464f389..e6e4ad1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -51,6 +51,27 @@ export interface Recurrence { autoActivate?: boolean; } +// ─── Personal Todos ─── + +export type TodoPriority = "high" | "medium" | "low" | "none"; + +export interface Todo { + id: string; + userId: string; + title: string; + description: string | null; + isCompleted: boolean; + priority: TodoPriority; + category: string | null; + dueDate: string | null; + completedAt: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +// ─── Tasks ─── + export interface Task { id: string; taskNumber: number; diff --git a/frontend/src/pages/SecurityPage.tsx b/frontend/src/pages/SecurityPage.tsx index 30ff55b..6d39b7c 100644 --- a/frontend/src/pages/SecurityPage.tsx +++ b/frontend/src/pages/SecurityPage.tsx @@ -80,6 +80,7 @@ async function updateAudit( return res.json(); } +// @ts-expect-error unused but kept for future use async function deleteAudit(id: string): Promise { const res = await fetch(`${BASE}/${id}`, { method: "DELETE", diff --git a/frontend/src/pages/TodosPage.tsx b/frontend/src/pages/TodosPage.tsx new file mode 100644 index 0000000..9c2ea33 --- /dev/null +++ b/frontend/src/pages/TodosPage.tsx @@ -0,0 +1,511 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import type { Todo, TodoPriority } from "../lib/types"; +import { + fetchTodos, + fetchTodoCategories, + createTodo, + updateTodo, + toggleTodo, + deleteTodo, +} from "../lib/api"; + +const PRIORITY_COLORS: Record = { + high: "text-red-500", + medium: "text-amber-500", + low: "text-blue-400", + none: "text-gray-400 dark:text-gray-600", +}; + +const PRIORITY_BG: Record = { + high: "bg-red-500/10 border-red-500/30 text-red-400", + medium: "bg-amber-500/10 border-amber-500/30 text-amber-400", + low: "bg-blue-500/10 border-blue-500/30 text-blue-400", + none: "bg-gray-500/10 border-gray-500/30 text-gray-400", +}; + +const PRIORITY_LABELS: Record = { + high: "High", + medium: "Medium", + low: "Low", + none: "None", +}; + +function formatDueDate(dateStr: string | null): string { + if (!dateStr) return ""; + const date = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateOnly = new Date(date); + dateOnly.setHours(0, 0, 0, 0); + + if (dateOnly.getTime() === today.getTime()) return "Today"; + if (dateOnly.getTime() === tomorrow.getTime()) return "Tomorrow"; + + const diff = dateOnly.getTime() - today.getTime(); + const days = Math.round(diff / (1000 * 60 * 60 * 24)); + if (days < 0) return `${Math.abs(days)}d overdue`; + if (days < 7) return date.toLocaleDateString("en-US", { weekday: "short" }); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function isDueOverdue(dateStr: string | null): boolean { + if (!dateStr) return false; + const date = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date < today; +} + +function isDueToday(dateStr: string | null): boolean { + if (!dateStr) return false; + const date = new Date(dateStr); + const today = new Date(); + return date.toDateString() === today.toDateString(); +} + +// ─── Todo Item Component ─── + +function TodoItem({ + todo, + onToggle, + onUpdate, + onDelete, +}: { + todo: Todo; + onToggle: (id: string) => void; + onUpdate: (id: string, updates: { title?: string }) => void; + onDelete: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const [showActions, setShowActions] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.focus(); + }, [editing]); + + const handleSave = () => { + if (editTitle.trim() && editTitle !== todo.title) { + onUpdate(todo.id, { title: editTitle.trim() }); + } + setEditing(false); + }; + + const overdue = isDueOverdue(todo.dueDate) && !todo.isCompleted; + const dueToday = isDueToday(todo.dueDate) && !todo.isCompleted; + + return ( +
setShowActions(true)} + onMouseLeave={() => setShowActions(false)} + > + {/* Checkbox */} + + + {/* Content */} +
+ {editing ? ( + setEditTitle(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditTitle(todo.title); + setEditing(false); + } + }} + className="w-full bg-transparent border-b border-amber-400 dark:border-amber-500 text-gray-900 dark:text-gray-100 text-sm focus:outline-none py-0.5" + /> + ) : ( +

!todo.isCompleted && setEditing(true)} + className={`text-sm cursor-pointer ${ + todo.isCompleted + ? "line-through text-gray-400 dark:text-gray-500" + : "text-gray-900 dark:text-gray-100" + }`} + > + {todo.title} +

+ )} + + {/* Meta row */} +
+ {todo.category && ( + + {todo.category} + + )} + {todo.dueDate && ( + + {formatDueDate(todo.dueDate)} + + )} + {todo.priority !== "none" && ( + + {PRIORITY_LABELS[todo.priority]} + + )} +
+
+ + {/* Actions */} +
+ +
+
+ ); +} + +// ─── Add Todo Form ─── + +function AddTodoForm({ + onAdd, + categories, +}: { + onAdd: (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => void; + categories: string[]; +}) { + const [title, setTitle] = useState(""); + const [showExpanded, setShowExpanded] = useState(false); + const [priority, setPriority] = useState("none"); + const [category, setCategory] = useState(""); + const [newCategory, setNewCategory] = useState(""); + const [dueDate, setDueDate] = useState(""); + const inputRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + const cat = newCategory.trim() || category || undefined; + onAdd({ + title: title.trim(), + priority: priority !== "none" ? priority : undefined, + category: cat, + dueDate: dueDate || undefined, + }); + + setTitle(""); + setPriority("none"); + setCategory(""); + setNewCategory(""); + setDueDate(""); + setShowExpanded(false); + inputRef.current?.focus(); + }; + + return ( +
+
+
+ setTitle(e.target.value)} + placeholder="Add a todo..." + className="w-full px-3 py-2.5 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400" + onFocus={() => setShowExpanded(true)} + /> +
+ +
+ + {showExpanded && ( +
+ {/* Priority */} + + + {/* Category */} + + + { + setNewCategory(e.target.value); + if (e.target.value) setCategory(""); + }} + placeholder="New category..." + className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-400 w-32" + /> + + {/* Due date */} + setDueDate(e.target.value)} + className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> + + +
+ )} +
+ ); +} + +// ─── Main Todos Page ─── + +type FilterTab = "all" | "active" | "completed"; + +export function TodosPage() { + const [todos, setTodos] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("active"); + const [categoryFilter, setCategoryFilter] = useState(""); + + const loadTodos = useCallback(async () => { + try { + const params: { completed?: string; category?: string } = {}; + if (filter === "active") params.completed = "false"; + if (filter === "completed") params.completed = "true"; + if (categoryFilter) params.category = categoryFilter; + + const [data, cats] = await Promise.all([ + fetchTodos(params), + fetchTodoCategories(), + ]); + setTodos(data); + setCategories(cats); + } catch (e) { + console.error("Failed to load todos:", e); + } finally { + setLoading(false); + } + }, [filter, categoryFilter]); + + useEffect(() => { + loadTodos(); + }, [loadTodos]); + + const handleAdd = async (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => { + try { + await createTodo(todo); + loadTodos(); + } catch (e) { + console.error("Failed to create todo:", e); + } + }; + + const handleToggle = async (id: string) => { + try { + // Optimistic update + setTodos((prev) => + prev.map((t) => + t.id === id ? { ...t, isCompleted: !t.isCompleted } : t + ) + ); + await toggleTodo(id); + // Reload after a short delay to let the animation play + setTimeout(loadTodos, 300); + } catch (e) { + console.error("Failed to toggle todo:", e); + loadTodos(); + } + }; + + const handleUpdate = async (id: string, updates: { title?: string; description?: string; priority?: TodoPriority; category?: string | null; dueDate?: string | null; isCompleted?: boolean }) => { + try { + await updateTodo(id, updates); + loadTodos(); + } catch (e) { + console.error("Failed to update todo:", e); + } + }; + + const handleDelete = async (id: string) => { + try { + setTodos((prev) => prev.filter((t) => t.id !== id)); + await deleteTodo(id); + } catch (e) { + console.error("Failed to delete todo:", e); + loadTodos(); + } + }; + + return ( +
+ {/* Header */} +
+

+ Todos +

+

+ Personal checklist — quick todos and reminders +

+
+ + {/* Add form */} + + + {/* Filter tabs */} +
+ {(["active", "all", "completed"] as FilterTab[]).map((tab) => ( + + ))} + + {/* Category filter */} + {categories.length > 0 && ( +
+ +
+ )} +
+ + {/* Todo list */} + {loading ? ( +
+
+ + + +
+
+ ) : todos.length === 0 ? ( +
+

+ {filter === "completed" ? "No completed todos yet" : "All clear! 🎉"} +

+

+ {filter === "active" ? "Add a todo above to get started" : ""} +

+
+ ) : ( +
+ {todos.map((todo) => ( + + ))} +
+ )} + + {/* Footer stats */} + {!loading && todos.length > 0 && ( +
+

+ {todos.length} {todos.length === 1 ? "todo" : "todos"} shown +

+
+ )} +
+ ); +}