feat: add personal todos feature
- New todos table in DB schema (title, description, priority, category, due date, completion) - Full CRUD + toggle API routes at /api/todos - Categories support with filtering - Bulk import endpoint for migration - New TodosPage with inline editing, priority badges, due date display - Add Todos to sidebar navigation - Dark mode support throughout
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
264
backend/src/routes/todos.ts
Normal file
264
backend/src/routes/todos.ts
Normal file
@@ -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<string, string | undefined>) {
|
||||
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<number>`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<string, any> = { 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()),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
@@ -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() {
|
||||
<Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></Suspense>} />
|
||||
<Route path="/summaries" element={<Suspense fallback={<PageLoader />}><SummariesPage /></Suspense>} />
|
||||
<Route path="/security" element={<Suspense fallback={<PageLoader />}><SecurityPage /></Suspense>} />
|
||||
<Route path="/todos" element={<Suspense fallback={<PageLoader />}><TodosPage /></Suspense>} />
|
||||
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<void> {
|
||||
});
|
||||
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<Todo[]> {
|
||||
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<string[]> {
|
||||
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<Todo> {
|
||||
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<Todo> {
|
||||
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<Todo> {
|
||||
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<void> {
|
||||
const res = await fetch(`${TODOS_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete todo");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
|
||||
511
frontend/src/pages/TodosPage.tsx
Normal file
511
frontend/src/pages/TodosPage.tsx
Normal file
@@ -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<TodoPriority, string> = {
|
||||
high: "text-red-500",
|
||||
medium: "text-amber-500",
|
||||
low: "text-blue-400",
|
||||
none: "text-gray-400 dark:text-gray-600",
|
||||
};
|
||||
|
||||
const PRIORITY_BG: Record<TodoPriority, string> = {
|
||||
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<TodoPriority, string> = {
|
||||
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<HTMLInputElement>(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 (
|
||||
<div
|
||||
className={`group flex items-start gap-3 px-3 py-2.5 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-800/50 ${
|
||||
todo.isCompleted ? "opacity-50" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => onToggle(todo.id)}
|
||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all ${
|
||||
todo.isCompleted
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: `border-gray-300 dark:border-gray-600 hover:border-green-400 ${PRIORITY_COLORS[todo.priority]}`
|
||||
}`}
|
||||
style={
|
||||
!todo.isCompleted && todo.priority !== "none"
|
||||
? {
|
||||
borderColor:
|
||||
todo.priority === "high"
|
||||
? "#ef4444"
|
||||
: todo.priority === "medium"
|
||||
? "#f59e0b"
|
||||
: "#60a5fa",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{todo.isCompleted && (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => !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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{todo.category && (
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||
{todo.category}
|
||||
</span>
|
||||
)}
|
||||
{todo.dueDate && (
|
||||
<span
|
||||
className={`text-[11px] ${
|
||||
overdue
|
||||
? "text-red-500 font-medium"
|
||||
: dueToday
|
||||
? "text-amber-500 font-medium"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{formatDueDate(todo.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{todo.priority !== "none" && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${PRIORITY_BG[todo.priority]}`}
|
||||
>
|
||||
{PRIORITY_LABELS[todo.priority]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={`flex items-center gap-1 transition-opacity ${
|
||||
showActions ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => onDelete(todo.id)}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<TodoPriority>("none");
|
||||
const [category, setCategory] = useState("");
|
||||
const [newCategory, setNewCategory] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<form onSubmit={handleSubmit} className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={title}
|
||||
onChange={(e) => 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)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim()}
|
||||
className="px-4 py-2.5 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showExpanded && (
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap animate-slide-up">
|
||||
{/* Priority */}
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TodoPriority)}
|
||||
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"
|
||||
>
|
||||
<option value="none">No priority</option>
|
||||
<option value="high">🔴 High</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="low">🔵 Low</option>
|
||||
</select>
|
||||
|
||||
{/* Category */}
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
setCategory(e.target.value);
|
||||
if (e.target.value) setNewCategory("");
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={newCategory}
|
||||
onChange={(e) => {
|
||||
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 */}
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExpanded(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Todos Page ───
|
||||
|
||||
type FilterTab = "all" | "active" | "completed";
|
||||
|
||||
export function TodosPage() {
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterTab>("active");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span>✅</span> Todos
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Personal checklist — quick todos and reminders
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<AddTodoForm onAdd={handleAdd} categories={categories} />
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 mb-4 border-b border-gray-200 dark:border-gray-800">
|
||||
{(["active", "all", "completed"] as FilterTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setFilter(tab)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
filter === tab
|
||||
? "border-amber-500 text-amber-600 dark:text-amber-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{tab === "active" ? "Active" : tab === "completed" ? "Completed" : "All"}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<div className="ml-auto">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(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-600 dark:text-gray-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
) : todos.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-400 dark:text-gray-500 text-lg">
|
||||
{filter === "completed" ? "No completed todos yet" : "All clear! 🎉"}
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-600 text-sm mt-1">
|
||||
{filter === "active" ? "Add a todo above to get started" : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{todos.map((todo) => (
|
||||
<TodoItem
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggle={handleToggle}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer stats */}
|
||||
{!loading && todos.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-800 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-600">
|
||||
{todos.length} {todos.length === 1 ? "todo" : "todos"} shown
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user