diff --git a/src/aggregator/index.ts b/src/aggregator/index.ts index 6b4a622..89a7333 100644 --- a/src/aggregator/index.ts +++ b/src/aggregator/index.ts @@ -10,16 +10,15 @@ import { type Server, type ServerWebSocket, $ } from "bun"; import { watch, type FSWatcher } from "node:fs"; -import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises"; -import { join, basename, dirname, relative } from "node:path"; +import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { join, basename, dirname } from "node:path"; import { parseTask } from "../markdown/parser.ts"; import type { Task } from "../types/index.ts"; import { sortByTaskId } from "../utils/task-sorting.ts"; +import { fileURLToPath } from "node:url"; // @ts-expect-error - Bun file import import favicon from "../web/favicon.png" with { type: "file" }; -import { dirname, join as pathJoin } from "node:path"; -import { fileURLToPath } from "node:url"; // Get the directory of this file const __filename = fileURLToPath(import.meta.url); @@ -114,6 +113,12 @@ export class BacklogAggregator { "/api/tasks/create": { POST: async (req: Request) => this.handleCreateTask(req), }, + "/api/tasks/archive": { + POST: async (req: Request) => this.handleArchiveTask(req), + }, + "/api/tasks/delete": { + DELETE: async (req: Request) => this.handleDeleteTask(req), + }, }, fetch: async (req: Request, server: Server) => { const url = new URL(req.url); @@ -137,7 +142,7 @@ export class BacklogAggregator { // Serve the HTML app for root path if (url.pathname === "/" || url.pathname === "") { - const htmlPath = pathJoin(__dirname, "web", "index.html"); + const htmlPath = join(__dirname, "web", "index.html"); const htmlFile = Bun.file(htmlPath); return new Response(htmlFile, { headers: { "Content-Type": "text/html" } }); } @@ -510,14 +515,19 @@ export class BacklogAggregator { private async handleUpdateTask(req: Request): Promise { try { const body = await req.json(); - const { projectPath, taskId, status } = body as { + const { projectPath, taskId, status, title, description, priority, labels, assignee } = body as { projectPath: string; taskId: string; - status: string; + status?: string; + title?: string; + description?: string; + priority?: string; + labels?: string[]; + assignee?: string[]; }; - if (!projectPath || !taskId || !status) { - return Response.json({ error: "Missing required fields: projectPath, taskId, status" }, { status: 400 }); + if (!projectPath || !taskId) { + return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 }); } // Find the task @@ -540,20 +550,49 @@ export class BacklogAggregator { return Response.json({ error: "Task file not found" }, { status: 404 }); } - // Update the status in the frontmatter - const updatedContent = this.updateTaskStatus(content, status); + // Update the fields in the frontmatter + let updatedContent = content; + if (status !== undefined) { + updatedContent = this.updateTaskField(updatedContent, "status", status); + } + if (title !== undefined) { + updatedContent = this.updateTaskField(updatedContent, "title", title); + } + if (priority !== undefined) { + updatedContent = this.updateTaskField(updatedContent, "priority", priority); + } + if (labels !== undefined) { + updatedContent = this.updateTaskField(updatedContent, "labels", `[${labels.join(", ")}]`); + } + if (assignee !== undefined) { + updatedContent = this.updateTaskField(updatedContent, "assignee", `[${assignee.join(", ")}]`); + } + if (description !== undefined) { + updatedContent = this.updateTaskDescription(updatedContent, description); + } + + // Add updated_date + updatedContent = this.addUpdatedDate(updatedContent); // 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}`); + const updatedFields = []; + if (status !== undefined) updatedFields.push(`status="${status}"`); + if (title !== undefined) updatedFields.push(`title="${title}"`); + if (priority !== undefined) updatedFields.push(`priority="${priority}"`); + if (labels !== undefined) updatedFields.push(`labels=[${labels.join(", ")}]`); + if (assignee !== undefined) updatedFields.push(`assignee=[${assignee.join(", ")}]`); + if (description !== undefined) updatedFields.push("description updated"); + + console.log(`Task ${taskId} updated (${updatedFields.join(", ")}) 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 }); + return Response.json({ success: true, taskId, updated: updatedFields }); } catch (error) { console.error("Error updating task:", error); return Response.json({ error: "Failed to update task" }, { status: 500 }); @@ -636,38 +675,167 @@ created_date: '${dateStr}' } } + private async handleArchiveTask(req: Request): Promise { + try { + const body = await req.json(); + const { projectPath, taskId } = body as { + projectPath: string; + taskId: string; + }; + + if (!projectPath || !taskId) { + return Response.json({ error: "Missing required fields: projectPath, taskId" }, { 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 archiveDir = join(projectPath, "backlog", "archive", "tasks"); + const taskFileName = `${taskId}.md`; + const taskFilePath = join(tasksDir, taskFileName); + const archiveFilePath = join(archiveDir, taskFileName); + + // Ensure archive directory exists + try { + await mkdir(archiveDir, { recursive: true }); + } catch { + // Directory might already exist + } + + // Read the task file + let content: string; + try { + content = await readFile(taskFilePath, "utf-8"); + } catch { + return Response.json({ error: "Task file not found" }, { status: 404 }); + } + + // Move to archive (write to archive, delete from tasks) + await writeFile(archiveFilePath, content, "utf-8"); + await unlink(taskFilePath); + + // Remove from in-memory tasks + this.tasks.delete(taskKey); + + console.log(`Task ${taskId} archived in ${this.projects.get(projectPath)?.name}`); + + this.broadcastUpdate(); + + return Response.json({ success: true, taskId, archived: true }); + } catch (error) { + console.error("Error archiving task:", error); + return Response.json({ error: "Failed to archive task" }, { status: 500 }); + } + } + + private async handleDeleteTask(req: Request): Promise { + try { + const body = await req.json(); + const { projectPath, taskId } = body as { + projectPath: string; + taskId: string; + }; + + if (!projectPath || !taskId) { + return Response.json({ error: "Missing required fields: projectPath, taskId" }, { 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); + + // Delete the file + try { + await unlink(taskFilePath); + } catch { + return Response.json({ error: "Task file not found" }, { status: 404 }); + } + + // Remove from in-memory tasks + this.tasks.delete(taskKey); + + console.log(`Task ${taskId} deleted from ${this.projects.get(projectPath)?.name}`); + + this.broadcastUpdate(); + + return Response.json({ success: true, taskId, deleted: true }); + } catch (error) { + console.error("Error deleting task:", error); + return Response.json({ error: "Failed to delete task" }, { status: 500 }); + } + } + private updateTaskStatus(content: string, newStatus: string): string { - // Parse frontmatter and update status + return this.updateTaskField(content, "status", newStatus); + } + + private updateTaskField(content: string, field: string, value: string): string { + // Parse frontmatter and update the field const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!frontmatterMatch) { + if (!frontmatterMatch || !frontmatterMatch[1]) { return content; } const frontmatter = frontmatterMatch[1]; - const updatedFrontmatter = frontmatter.replace( - /^status:\s*.+$/m, - `status: ${newStatus}`, - ); + const fieldRegex = new RegExp(`^${field}:\\s*.+$`, "m"); - // Add updated_date + let updatedFrontmatter: string; + if (fieldRegex.test(frontmatter)) { + // Update existing field + updatedFrontmatter = frontmatter.replace(fieldRegex, `${field}: ${value}`); + } else { + // Add new field + updatedFrontmatter = frontmatter + `\n${field}: ${value}`; + } + + return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`); + } + + private updateTaskDescription(content: string, newDescription: string): string { + // Find and replace the description section + const descriptionRegex = /## Description\n\n[\s\S]*?(?=\n## |\n---|\Z)/; + const newDescriptionSection = `## Description\n\n${newDescription}\n`; + + if (descriptionRegex.test(content)) { + return content.replace(descriptionRegex, newDescriptionSection); + } + // If no description section, add one after frontmatter + return content.replace(/^(---\n[\s\S]*?\n---\n\n?)/, `$1${newDescriptionSection}\n`); + } + + private addUpdatedDate(content: string): string { 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}'`; + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch || !frontmatterMatch[1]) { + return content; } - return content.replace( - /^---\n[\s\S]*?\n---/, - `---\n${finalFrontmatter}\n---`, - ); + const frontmatter = frontmatterMatch[1]; + let updatedFrontmatter: string; + + if (frontmatter.includes("updated_date:")) { + updatedFrontmatter = frontmatter.replace(/^updated_date:\s*.+$/m, `updated_date: '${dateStr}'`); + } else { + updatedFrontmatter = frontmatter + `\nupdated_date: '${dateStr}'`; + } + + return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`); } private async getNextTaskId(projectPath: string): Promise { diff --git a/src/aggregator/web/app.tsx b/src/aggregator/web/app.tsx index 8511978..0f80af4 100644 --- a/src/aggregator/web/app.tsx +++ b/src/aggregator/web/app.tsx @@ -36,9 +36,71 @@ function App() { const [lastUpdate, setLastUpdate] = useState(null); const [filter, setFilter] = useState(""); const [selectedProject, setSelectedProject] = useState(null); + const [actionError, setActionError] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); + const handleArchiveTask = async (task: AggregatedTask) => { + try { + const response = await fetch("/api/tasks/archive", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to archive task"); + } + setActionError(null); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to archive task"); + setTimeout(() => setActionError(null), 3000); + } + }; + + const handleDeleteTask = async (task: AggregatedTask) => { + if (!confirm(`Are you sure you want to delete "${task.title}"? This cannot be undone.`)) { + return; + } + try { + const response = await fetch("/api/tasks/delete", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to delete task"); + } + setActionError(null); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to delete task"); + setTimeout(() => setActionError(null), 3000); + } + }; + + const handleUpdateTask = async (task: AggregatedTask, updates: Partial) => { + try { + const response = await fetch("/api/tasks/update", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectPath: task.projectPath, + taskId: task.id, + ...updates, + }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to update task"); + } + setActionError(null); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Failed to update task"); + setTimeout(() => setActionError(null), 3000); + } + }; + const connectWebSocket = useCallback(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${protocol}//${window.location.host}`); @@ -85,7 +147,7 @@ function App() { }, [connectWebSocket]); // Group tasks by status - const statuses = ["To Do", "In Progress", "Done"]; + const statuses = ["To Do", "In Progress", "Done", "Won't Do"]; const tasksByStatus = statuses.reduce( (acc, status) => { acc[status] = tasks.filter((t) => { @@ -137,6 +199,19 @@ function App() {
+ {actionError && ( + + {actionError} + + )} {projects.length} projects | {tasks.length} tasks {lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`} @@ -216,7 +291,7 @@ function App() { style={{ padding: "2rem", display: "grid", - gridTemplateColumns: "repeat(3, 1fr)", + gridTemplateColumns: "repeat(4, 1fr)", gap: "1.5rem", minHeight: "calc(100vh - 180px)", }} @@ -259,7 +334,14 @@ function App() { {/* Tasks */}
{tasksByStatus[status]?.map((task) => ( - + ))} {(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
@@ -274,8 +356,19 @@ function App() { ); } -function TaskCard({ task }: { task: AggregatedTask }) { +interface TaskCardProps { + task: AggregatedTask; + onArchive: (task: AggregatedTask) => void; + onDelete: (task: AggregatedTask) => void; + onUpdate: (task: AggregatedTask, updates: Partial) => void; + statuses: string[]; +} + +function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardProps) { const [expanded, setExpanded] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [editingField, setEditingField] = useState(null); + const [editValue, setEditValue] = useState(""); const priorityColors: Record = { high: "#ef4444", @@ -283,49 +376,212 @@ function TaskCard({ task }: { task: AggregatedTask }) { low: "#22c55e", }; + const priorities = ["high", "medium", "low"]; + + const handleArchiveClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onArchive(task); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(task); + }; + + const startEditing = (field: string, currentValue: string, e: React.MouseEvent) => { + e.stopPropagation(); + setEditingField(field); + setEditValue(currentValue); + }; + + const saveEdit = async (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + if (editingField && editValue !== task[editingField as keyof AggregatedTask]) { + onUpdate(task, { [editingField]: editValue }); + } + setEditingField(null); + setEditValue(""); + }; + + const cancelEdit = (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + setEditingField(null); + setEditValue(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + saveEdit(e); + } else if (e.key === "Escape") { + cancelEdit(e); + } + }; + + const handleStatusChange = (e: React.ChangeEvent) => { + e.stopPropagation(); + onUpdate(task, { status: e.target.value }); + }; + + const handlePriorityChange = (e: React.ChangeEvent) => { + e.stopPropagation(); + onUpdate(task, { priority: e.target.value }); + }; + + const inputStyle: React.CSSProperties = { + backgroundColor: "#1e293b", + border: "1px solid #3b82f6", + borderRadius: "0.25rem", + padding: "0.25rem 0.5rem", + color: "#e2e8f0", + fontSize: "inherit", + width: "100%", + outline: "none", + }; + + const selectStyle: React.CSSProperties = { + backgroundColor: "#1e293b", + border: "1px solid #334155", + borderRadius: "0.25rem", + padding: "0.125rem 0.25rem", + color: "#e2e8f0", + fontSize: "0.625rem", + cursor: "pointer", + outline: "none", + }; + return (
setExpanded(!expanded)} + onClick={() => !editingField && setExpanded(!expanded)} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} style={{ backgroundColor: "#0f172a", borderRadius: "0.375rem", padding: "0.75rem", - cursor: "pointer", + cursor: editingField ? "default" : "pointer", borderLeft: `4px solid ${task.projectColor}`, transition: "transform 0.1s, box-shadow 0.1s", - }} - onMouseEnter={(e) => { - e.currentTarget.style.transform = "translateY(-1px)"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.3)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = "translateY(0)"; - e.currentTarget.style.boxShadow = "none"; + position: "relative", + transform: isHovering && !editingField ? "translateY(-1px)" : "translateY(0)", + boxShadow: isHovering && !editingField ? "0 4px 6px -1px rgba(0, 0, 0, 0.3)" : "none", }} > + {/* Action buttons - shown on hover */} + {isHovering && !editingField && ( +
+ + +
+ )} + {/* Task Header */}
-
{task.title}
-
- {task.id} | {task.projectName} + {editingField === "title" ? ( +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + style={{ ...inputStyle, fontSize: "0.875rem", fontWeight: 500 }} + /> + + +
+ ) : ( +
startEditing("title", task.title, e)} + title="Click to edit title" + > + {task.title} +
+ )} +
+ {task.id} | {task.projectName} +
- {task.priority && ( - - {task.priority} - - )} +
{/* Labels */} @@ -349,19 +605,65 @@ function TaskCard({ task }: { task: AggregatedTask }) { )} {/* Expanded Details */} - {expanded && task.description && ( + {expanded && (
- {task.description.slice(0, 500)} - {task.description.length > 500 && "..."} + {editingField === "description" ? ( +
+