From 261c2695ae43661d4c25b88f14b8d1fdc7480ad2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 3 Dec 2025 21:26:29 -0800 Subject: [PATCH] Add interactive drag-and-drop task management to aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drag-and-drop to move tasks between kanban columns (To Do, In Progress, Done) - Add "New Task" button with modal form to create tasks - Add /api/tasks/update PATCH endpoint for status changes - Add /api/tasks/create POST endpoint for new tasks - Write changes directly to task markdown files on disk - Enable read-write mounts in docker-compose for task editing - Add toast notifications for user feedback - Optimistic UI updates with error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docker-compose.aggregator.yml | 7 +- src/aggregator/index.ts | 195 ++++++++++++++++++- src/aggregator/web/index.html | 346 +++++++++++++++++++++++++++++++++- 3 files changed, 536 insertions(+), 12 deletions(-) diff --git a/docker-compose.aggregator.yml b/docker-compose.aggregator.yml index be4766f..f278039 100644 --- a/docker-compose.aggregator.yml +++ b/docker-compose.aggregator.yml @@ -11,10 +11,11 @@ services: volumes: # Mount all project directories that contain backlog folders # The aggregator scans these paths for backlog/ subdirectories - - /opt/websites:/projects/websites:ro - - /opt/apps:/projects/apps:ro + # NOTE: Using rw (read-write) to allow task creation/updates from web UI + - /opt/websites:/projects/websites:rw + - /opt/apps:/projects/apps:rw # If you have repos in other locations, add them here: - # - /home/user/projects:/projects/home:ro + # - /home/user/projects:/projects/home:rw labels: - "traefik.enable=true" - "traefik.http.routers.backlog.rule=Host(`backlog.jeffemmett.com`)" diff --git a/src/aggregator/index.ts b/src/aggregator/index.ts index bf07141..6b4a622 100644 --- a/src/aggregator/index.ts +++ b/src/aggregator/index.ts @@ -10,7 +10,7 @@ import { type Server, type ServerWebSocket, $ } from "bun"; import { watch, type FSWatcher } from "node:fs"; -import { readdir, stat, readFile } from "node:fs/promises"; +import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, basename, dirname, relative } from "node:path"; import { parseTask } from "../markdown/parser.ts"; import type { Task } from "../types/index.ts"; @@ -108,6 +108,12 @@ export class BacklogAggregator { "/api/health": { GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }), }, + "/api/tasks/update": { + PATCH: async (req: Request) => this.handleUpdateTask(req), + }, + "/api/tasks/create": { + POST: async (req: Request) => this.handleCreateTask(req), + }, }, fetch: async (req: Request, server: Server) => { const url = new URL(req.url); @@ -499,6 +505,193 @@ export class BacklogAggregator { for (const s of common) statuses.add(s); return Response.json(Array.from(statuses)); } + + // Task modification handlers + private async handleUpdateTask(req: Request): Promise { + try { + const body = await req.json(); + const { projectPath, taskId, status } = body as { + projectPath: string; + taskId: string; + status: string; + }; + + if (!projectPath || !taskId || !status) { + return Response.json({ error: "Missing required fields: projectPath, taskId, status" }, { status: 400 }); + } + + // Find the task + const taskKey = `${projectPath}:${taskId}`; + const task = this.tasks.get(taskKey); + if (!task) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } + + // Find the task file path + const tasksDir = join(projectPath, "backlog", "tasks"); + const taskFileName = `${taskId}.md`; + const taskFilePath = join(tasksDir, taskFileName); + + // Read the current task file + let content: string; + try { + content = await readFile(taskFilePath, "utf-8"); + } catch { + return Response.json({ error: "Task file not found" }, { status: 404 }); + } + + // Update the status in the frontmatter + const updatedContent = this.updateTaskStatus(content, status); + + // Write the updated content back + await writeFile(taskFilePath, updatedContent, "utf-8"); + + console.log(`Task ${taskId} status updated to "${status}" in ${this.projects.get(projectPath)?.name}`); + + // The file watcher will pick up the change and broadcast update + // But we can also force an immediate update + await this.loadTask(projectPath, taskFilePath); + this.broadcastUpdate(); + + return Response.json({ success: true, taskId, status }); + } catch (error) { + console.error("Error updating task:", error); + return Response.json({ error: "Failed to update task" }, { status: 500 }); + } + } + + private async handleCreateTask(req: Request): Promise { + try { + const body = await req.json(); + const { projectPath, title, description, priority, status, labels } = body as { + projectPath: string; + title: string; + description?: string; + priority?: string; + status?: string; + labels?: string[]; + }; + + if (!projectPath || !title) { + return Response.json({ error: "Missing required fields: projectPath, title" }, { status: 400 }); + } + + // Verify project exists + if (!this.projects.has(projectPath)) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + // Generate next task ID + const taskId = await this.getNextTaskId(projectPath); + + // Generate the task markdown content + const now = new Date(); + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + + let frontmatter = `--- +id: ${taskId} +title: ${title} +status: ${status || "To Do"} +assignee: [] +created_date: '${dateStr}' +`; + + if (labels && labels.length > 0) { + frontmatter += `labels: [${labels.join(", ")}]\n`; + } else { + frontmatter += `labels: []\n`; + } + + if (priority) { + frontmatter += `priority: ${priority}\n`; + } + + frontmatter += `dependencies: []\n---\n\n`; + + let content = frontmatter; + content += `## Description\n\n${description || "No description provided."}\n`; + + // Ensure tasks directory exists + const tasksDir = join(projectPath, "backlog", "tasks"); + try { + await mkdir(tasksDir, { recursive: true }); + } catch { + // Directory might already exist + } + + // Write the task file + const taskFilePath = join(tasksDir, `${taskId}.md`); + await writeFile(taskFilePath, content, "utf-8"); + + console.log(`Created task ${taskId} in ${this.projects.get(projectPath)?.name}`); + + // Load the new task and broadcast + await this.loadTask(projectPath, taskFilePath); + this.broadcastUpdate(); + + return Response.json({ success: true, taskId, projectPath }); + } catch (error) { + console.error("Error creating task:", error); + return Response.json({ error: "Failed to create task" }, { status: 500 }); + } + } + + private updateTaskStatus(content: string, newStatus: string): string { + // Parse frontmatter and update status + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return content; + } + + const frontmatter = frontmatterMatch[1]; + const updatedFrontmatter = frontmatter.replace( + /^status:\s*.+$/m, + `status: ${newStatus}`, + ); + + // Add updated_date + const now = new Date(); + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + + let finalFrontmatter = updatedFrontmatter; + if (finalFrontmatter.includes("updated_date:")) { + finalFrontmatter = finalFrontmatter.replace( + /^updated_date:\s*.+$/m, + `updated_date: '${dateStr}'`, + ); + } else { + // Add updated_date before the closing --- + finalFrontmatter += `\nupdated_date: '${dateStr}'`; + } + + return content.replace( + /^---\n[\s\S]*?\n---/, + `---\n${finalFrontmatter}\n---`, + ); + } + + private async getNextTaskId(projectPath: string): Promise { + // Find the highest task ID in the project + const tasksPath = join(projectPath, "backlog", "tasks"); + let maxId = 0; + + try { + const entries = await readdir(tasksPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) continue; + const match = entry.name.match(/^task-(\d+)\.md$/); + if (match) { + const id = Number.parseInt(match[1], 10); + if (id > maxId) maxId = id; + } + } + } catch { + // Directory might not exist yet + } + + // Return next ID with zero-padding + return `task-${String(maxId + 1).padStart(3, "0")}`; + } } // CLI entry point diff --git a/src/aggregator/web/index.html b/src/aggregator/web/index.html index dab290a..3996ba9 100644 --- a/src/aggregator/web/index.html +++ b/src/aggregator/web/index.html @@ -48,7 +48,7 @@ color: #e2e8f0; min-width: 200px; } - .project-btn { + .project-btn, .action-btn { padding: 0.5rem 1rem; border-radius: 0.375rem; border: 1px solid #334155; @@ -58,8 +58,11 @@ display: flex; align-items: center; gap: 0.5rem; + font-size: 0.875rem; } .project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; } + .action-btn { background: #22c55e; border-color: #16a34a; font-weight: 500; } + .action-btn:hover { background: #16a34a; } .project-dot { width: 0.75rem; height: 0.75rem; @@ -78,6 +81,11 @@ padding: 1rem; display: flex; flex-direction: column; + transition: background 0.2s; + } + .column.drag-over { + background: #334155; + box-shadow: inset 0 0 0 2px #3b82f6; } .column-header { display: flex; @@ -94,19 +102,28 @@ border-radius: 9999px; font-size: 0.75rem; } - .tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; } + .tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; min-height: 100px; } .task-card { background: #0f172a; border-radius: 0.375rem; padding: 0.75rem; border-left: 4px solid #3b82f6; - cursor: pointer; - transition: transform 0.1s, box-shadow 0.1s; + cursor: grab; + transition: transform 0.1s, box-shadow 0.1s, opacity 0.2s; + user-select: none; } .task-card:hover { transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); } + .task-card.dragging { + opacity: 0.5; + cursor: grabbing; + } + .task-card.drag-preview { + transform: rotate(3deg); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5); + } .task-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; } .task-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; } .task-meta { font-size: 0.75rem; color: #64748b; } @@ -140,6 +157,91 @@ display: none; } .task-card.expanded .task-description { display: block; } + + /* Modal styles */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + align-items: center; + justify-content: center; + } + .modal-overlay.active { display: flex; } + .modal { + background: #1e293b; + border-radius: 0.5rem; + padding: 1.5rem; + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + } + .modal h2 { margin-bottom: 1rem; font-size: 1.25rem; } + .form-group { margin-bottom: 1rem; } + .form-group label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: #94a3b8; + } + .form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 0.5rem; + border-radius: 0.375rem; + border: 1px solid #334155; + background: #0f172a; + color: #e2e8f0; + font-size: 0.875rem; + } + .form-group textarea { min-height: 100px; resize: vertical; } + .form-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1.5rem; + } + .btn { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + border: none; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + } + .btn-primary { background: #3b82f6; color: white; } + .btn-primary:hover { background: #2563eb; } + .btn-secondary { background: #334155; color: #e2e8f0; } + .btn-secondary:hover { background: #475569; } + + /* Toast notifications */ + .toast-container { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .toast { + padding: 0.75rem 1rem; + border-radius: 0.375rem; + background: #334155; + color: #e2e8f0; + font-size: 0.875rem; + animation: slideIn 0.3s ease; + } + .toast.success { background: #166534; } + .toast.error { background: #991b1b; } + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } @@ -154,6 +256,7 @@
+
@@ -180,11 +283,72 @@
+ + + + +
+