diff --git a/.cline-context.md b/.cline-context.md index 38725a7..f2cc331 100644 --- a/.cline-context.md +++ b/.cline-context.md @@ -39,9 +39,7 @@ - Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings. - **Next Steps (Todo):** - - Fix sidebar list item counts calculation in demo & API modes. - - Completely isolate Trash view and Tag view from My Tasks list state in TaskList.tsx. - - Fix Restore and Permanent Delete in Trash view for both Demo and API modes. + - Continuous refinement of user experience based on feedback. - Enhance keyboard accessibility & global shortcuts (shortcuts for switching list/kanban view, quick task navigation, Command Palette bindings). - Add optional automatic synchronization background polling / webhook integration if requested. - Plan next feature iterations or custom integrations as requested by user. diff --git a/prisma/migrations/20260821_add_soft_delete/migration.sql b/prisma/migrations/20260821_add_soft_delete/migration.sql new file mode 100644 index 0000000..57dcaff --- /dev/null +++ b/prisma/migrations/20260821_add_soft_delete/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Task" ADD COLUMN "deletedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "Task_isDeleted_idx" ON "Task"("isDeleted"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bf5a74..3ba85d6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,9 @@ model Task { priority Int @default(0) sortOrder Int @default(0) + isDeleted Boolean @default(false) + deletedAt DateTime? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -75,6 +78,7 @@ model Task { @@index([userId]) @@index([listId]) @@index([parentId]) + @@index([isDeleted]) } model Tag { diff --git a/src/app/api/lists/route.ts b/src/app/api/lists/route.ts index 1c690cd..ed5aad0 100644 --- a/src/app/api/lists/route.ts +++ b/src/app/api/lists/route.ts @@ -12,7 +12,7 @@ export async function GET() { where: { userId: session.user.id }, orderBy: { sortOrder: "asc" }, include: { - _count: { select: { tasks: { where: { completed: false, parentId: null } } } }, + _count: { select: { tasks: { where: { isDeleted: false, completed: false, parentId: null } } } }, }, }); return NextResponse.json(lists); diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..eb0aab8 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const tags = await prisma.tag.findMany({ + where: { userId: session.user.id }, + orderBy: { name: "asc" }, + include: { + _count: { + select: { + tasks: { + where: { + task: { + isDeleted: false, + completed: false, + }, + }, + }, + }, + }, + }, + }); + + return NextResponse.json(tags); + } catch (err) { + console.error("[tags:GET]", err); + return NextResponse.json({ error: "Failed to fetch tags" }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + if (!body || !body.name?.trim()) { + return NextResponse.json({ error: "Tag name required" }, { status: 400 }); + } + + const name = body.name.trim(); + const color = body.color || "#4B7BF5"; + + const tag = await prisma.tag.upsert({ + where: { + userId_name: { + userId: session.user.id, + name, + }, + }, + update: { color }, + create: { + userId: session.user.id, + name, + color, + }, + }); + + return NextResponse.json(tag, { status: 201 }); + } catch (err) { + console.error("[tags:POST]", err); + return NextResponse.json({ error: "Failed to create/update tag" }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index bcc42cd..ee87bbf 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -36,7 +36,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) { if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 }); // Sanitize allowed fields - const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId"]; + const allowed = ["title", "note", "completed", "completedAt", "dueDate", "priority", "sortOrder", "listId", "parentId", "isDeleted", "deletedAt"]; const data: Record = {}; for (const key of allowed) { if (key in body) data[key] = body[key]; @@ -71,9 +71,25 @@ export async function PATCH(req: NextRequest, { params }: Ctx) { data.dueDate = data.dueDate ? new Date(data.dueDate as string) : null; } + // If restoring a child task (isDeleted: false), also restore ancestor parents + if (data.isDeleted === false) { + let currentParentId = task.parentId; + while (currentParentId) { + const parentTask: any = await prisma.task.findUnique({ where: { id: currentParentId } }); + if (!parentTask) break; + if (parentTask.isDeleted) { + await prisma.task.update({ + where: { id: currentParentId }, + data: { isDeleted: false, deletedAt: null } as any, + }); + } + currentParentId = parentTask.parentId; + } + } + const updated = await prisma.task.update({ where: { id }, - data, + data: data as any, include: { children: { orderBy: { sortOrder: "asc" } }, tags: { include: { tag: true } } }, }); return NextResponse.json(updated); @@ -83,7 +99,7 @@ export async function PATCH(req: NextRequest, { params }: Ctx) { } } -export async function DELETE(_: NextRequest, { params }: Ctx) { +export async function DELETE(req: NextRequest, { params }: Ctx) { try { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -92,10 +108,37 @@ export async function DELETE(_: NextRequest, { params }: Ctx) { const task = await prisma.task.findFirst({ where: { id, userId: session.user.id } }); if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 }); - await prisma.task.delete({ where: { id } }); - return NextResponse.json({ ok: true }); + const { searchParams } = new URL(req.url); + const permanent = searchParams.get("permanent") === "true"; + + if (permanent) { + await prisma.task.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } + + // Soft delete task and its children + const now = new Date(); + await prisma.task.update({ + where: { id }, + data: { isDeleted: true, deletedAt: now } as any, + }); + + // Recursively soft-delete children + const softDeleteChildren = async (parentId: string) => { + const children = await prisma.task.findMany({ where: { parentId } }); + for (const child of children) { + await prisma.task.update({ + where: { id: child.id }, + data: { isDeleted: true, deletedAt: now } as any, + }); + await softDeleteChildren(child.id); + } + }; + await softDeleteChildren(id); + + return NextResponse.json({ ok: true, softDeleted: true }); } catch (err) { console.error("[tasks/id:DELETE]", err); return NextResponse.json({ error: "Failed to delete task" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 2952166..b51b8fa 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -12,19 +12,52 @@ export async function GET(req: NextRequest) { const listId = searchParams.get("listId"); const showCompleted = searchParams.get("showCompleted") === "true"; const tagName = searchParams.get("tag"); + const isTrash = searchParams.get("isTrash") === "true"; + const countTrash = searchParams.get("countTrash") === "true"; + + if (countTrash) { + const trashCount = await prisma.task.count({ + where: { userId: session.user.id, isDeleted: true } as any, + }); + return NextResponse.json({ count: trashCount }); + } + + if (isTrash) { + // Return all deleted tasks (top-level or subtasks) + const trashTasks = await prisma.task.findMany({ + where: { userId: session.user.id, isDeleted: true } as any, + orderBy: [{ deletedAt: "desc" } as any, { createdAt: "desc" }], + include: { + children: { + orderBy: [{ sortOrder: "asc" }], + include: { tags: { include: { tag: true } } }, + }, + tags: { include: { tag: true } }, + }, + }); + return NextResponse.json(trashTasks); + } const where: Record = { userId: session.user.id, + isDeleted: false, ...(listId ? { listId, parentId: null } : tagName ? {} : { parentId: null }), ...(showCompleted ? {} : { completed: false }), ...(tagName ? { tags: { some: { tag: { name: tagName } } } } : {}), }; const tasks = await prisma.task.findMany({ - where, + where: where as any, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], include: { - children: { orderBy: [{ sortOrder: "asc" }] }, + children: { + where: { isDeleted: false } as any, + orderBy: [{ sortOrder: "asc" }], + include: { + children: { where: { isDeleted: false } as any, orderBy: [{ sortOrder: "asc" }] }, + tags: { include: { tag: true } }, + }, + }, tags: { include: { tag: true } }, }, }); @@ -35,6 +68,29 @@ export async function GET(req: NextRequest) { } } +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const trash = searchParams.get("trash") === "true"; + + if (trash) { + // Empty all trash permanently + await prisma.task.deleteMany({ + where: { userId: session.user.id, isDeleted: true } as any, + }); + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ error: "Invalid delete request" }, { status: 400 }); + } catch (err) { + console.error("[tasks:DELETE_BATCH]", err); + return NextResponse.json({ error: "Failed to empty trash" }, { status: 500 }); + } +} + export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 1af09ef..b24e83b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -117,6 +117,8 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { return () => document.removeEventListener("checkflow:openCommandPalette", handler); }, []); + const [apiTrashCount, setApiTrashCount] = useState(0); + // Task filter & reconstruct hierarchical structure for Demo and API modes useEffect(() => { if (isDemo) { @@ -136,8 +138,17 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { } } else { // Authenticated API mode + // Also fetch trash count for sidebar + fetch("/api/tasks?countTrash=true") + .then((r) => (r.ok ? r.json() : { count: 0 })) + .then((data) => setApiTrashCount(data.count || 0)) + .catch(() => {}); + if (isTrashActive) { - setTasks([]); + fetch("/api/tasks?isTrash=true") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => setTasks(Array.isArray(data) ? data : [])) + .catch((err) => console.error("Failed to load trash tasks", err)); } else if (selectedTag) { fetch(`/api/tasks?tag=${encodeURIComponent(selectedTag)}&showCompleted=${showCompleted}`) .then((r) => (r.ok ? r.json() : [])) @@ -195,7 +206,10 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { const store = getDemoStore(); setTasks(getAllTrashTasks(store.tasks) as Task[]); } else { - setTasks([]); + fetch("/api/tasks?isTrash=true") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => setTasks(Array.isArray(data) ? data : [])) + .catch((err) => console.error("Failed to load trash tasks", err)); } }, [isDemo]); @@ -357,7 +371,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { }, 6000); }, [isDemo, selectedTask]); - // Undo delete + // Undo delete (Restores exact soft-deleted task hierarchy) const handleUndoDelete = useCallback(async () => { if (!undoToast) return; if (undoTimerRef.current) clearTimeout(undoTimerRef.current); @@ -377,28 +391,16 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { refresh(); } else { try { - const res = await fetch("/api/tasks", { - method: "POST", + const res = await fetch(`/api/tasks/${task.id}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: task.title, - listId: task.listId, - parentId: task.parentId, - note: task.note, - completed: task.completed, - dueDate: task.dueDate, - priority: task.priority, - }), + body: JSON.stringify({ isDeleted: false, deletedAt: null }), }); if (res.ok) { - const created = await res.json(); - if (created.listId === selectedListId) { - setTasks((prev) => [...prev, created]); - } refresh(); } } catch (err) { - console.error("Failed to restore task", err); + console.error("Failed to restore task via Undo", err); } } }, [undoToast, isDemo, selectedListId, refresh]); @@ -418,29 +420,47 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { }; // Restore from Trash (Restores task and all its nested subtasks) - const handleRestoreTask = (id: string) => { + const handleRestoreTask = async (id: string) => { if (isDemo) { const store = getDemoStore(); const newTasks = restoreTaskInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); setTasks(getAllTrashTasks(newTasks) as Task[]); refresh(); + } else { + try { + await fetch(`/api/tasks/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isDeleted: false, deletedAt: null }), + }); + refresh(); + } catch (err) { + console.error("Failed to restore task", err); + } } }; // Permanent Delete - const handlePermanentDeleteTask = (id: string) => { + const handlePermanentDeleteTask = async (id: string) => { if (isDemo) { const store = getDemoStore(); const newTasks = deleteTaskInTree(store.tasks, id); saveDemoStore(store.lists, newTasks); setTasks(getAllTrashTasks(newTasks) as Task[]); refresh(); + } else { + try { + await fetch(`/api/tasks/${id}?permanent=true`, { method: "DELETE" }); + refresh(); + } catch (err) { + console.error("Failed to permanently delete task", err); + } } }; // Empty Trash - const handleEmptyTrash = () => { + const handleEmptyTrash = async () => { if (!confirm("Permanently empty all items in trash?")) return; if (isDemo) { const store = getDemoStore(); @@ -448,6 +468,13 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { saveDemoStore(store.lists, newTasks); setTasks([]); refresh(); + } else { + try { + await fetch("/api/tasks?trash=true", { method: "DELETE" }); + refresh(); + } catch (err) { + console.error("Failed to empty trash", err); + } } }; @@ -514,7 +541,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) { onTagSelect={handleTagSelect} isTrashActive={isTrashActive} onTrashSelect={handleTrashSelect} - trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : undefined} + trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : apiTrashCount} mobileOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} isDemo={isDemo} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9fa7221..c31627a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,6 +11,7 @@ import { getCustomTags, MockTag, getAllTrashTasks, getDemoStore, getTagTaskCount interface User { id: string; name?: string | null; email?: string | null } interface List { id: string; name: string; color: string; icon: string; _count?: { tasks: number } } +interface APITag { id: string; name: string; color: string; _count?: { tasks: number } } const LIST_COLORS = ["#5B8DEF", "#E05252", "#3DAD84", "#E8931A", "#8B6CF7", "#E4609B", "#0ABAD1", "#F17A3B", "#6875F5", "#14B8A6"]; @@ -74,14 +75,8 @@ export function Sidebar({ const [exportListId, setExportListId] = useState("all"); const [exportIncludeCompleted, setExportIncludeCompleted] = useState(true); const [exporting, setExporting] = useState(false); - const [tags, setTags] = useState(() => (typeof window !== "undefined" ? getCustomTags() : [])); - const [internalTrashCount, setInternalTrashCount] = useState(() => { - if (typeof window !== "undefined") { - const store = getDemoStore(); - return getAllTrashTasks(store.tasks).length; - } - return 0; - }); + const [tags, setTags] = useState>(() => (typeof window !== "undefined" ? getCustomTags() : [])); + const [internalTrashCount, setInternalTrashCount] = useState(0); const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount; @@ -98,17 +93,16 @@ export function Sidebar({ const inputRef = useRef(null); const fileRef = useRef(null); - useEffect(() => { - setTags(getCustomTags()); - const store = getDemoStore(); - const trashed = getAllTrashTasks(store.tasks); - setInternalTrashCount(trashed.length); - }, [lists]); - const initialListSelectedRef = useRef(false); useEffect(() => { - if (!isDemo) { + if (isDemo) { + setTags(getCustomTags()); + const store = getDemoStore(); + const trashed = getAllTrashTasks(store.tasks); + setInternalTrashCount(trashed.length); + } else { + // Load lists fetch("/api/lists") .then((r) => (r.ok ? r.json() : [])) .then((data) => { @@ -121,8 +115,22 @@ export function Sidebar({ } }) .catch((err) => console.error("Failed to load lists", err)); + + // Load tags + fetch("/api/tags") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => { + if (Array.isArray(data)) setTags(data); + }) + .catch((err) => console.error("Failed to load tags", err)); + + // Load trash count + fetch("/api/tasks?countTrash=true") + .then((r) => (r.ok ? r.json() : { count: 0 })) + .then((data) => setInternalTrashCount(data.count || 0)) + .catch(() => {}); } - }, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists]); + }, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists, lists.length]); useEffect(() => { if (showNewList) setTimeout(() => inputRef.current?.focus(), 50); @@ -581,7 +589,7 @@ export function Sidebar({ const active = filterActiveTree(store.tasks as MockTask[], false).filter((t) => t.listId === list.id); return active.length > 0 ? active.length : ""; })() - : list._count?.tasks || ""} + : (list._count?.tasks ? list._count.tasks : "")}