feat: add personal todos feature
All checks were successful
CI/CD / test (push) Successful in 16s
CI/CD / deploy (push) Successful in 1s

- 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:
2026-01-30 04:42:34 +00:00
parent d5693a7624
commit dd2c80224e
9 changed files with 902 additions and 1 deletions

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -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",

View 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>
);
}