Add interactive drag-and-drop task management to aggregator

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-03 21:26:29 -08:00
parent 3230156ada
commit 261c2695ae
3 changed files with 536 additions and 12 deletions

View File

@ -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`)"

View File

@ -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<Response> {
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<Response> {
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<string> {
// 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

View File

@ -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; }
}
</style>
</head>
<body>
@ -154,6 +256,7 @@
<div class="filter-bar" id="filter-bar">
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
<button class="project-btn active" id="all-projects">All Projects</button>
<button class="action-btn" id="new-task-btn" style="margin-left: auto;">+ New Task</button>
</div>
<div class="board" id="board">
@ -180,11 +283,72 @@
</div>
</div>
<!-- New Task Modal -->
<div class="modal-overlay" id="new-task-modal">
<div class="modal">
<h2>Create New Task</h2>
<form id="new-task-form">
<div class="form-group">
<label for="task-project">Project *</label>
<select id="task-project" required>
<option value="">Select a project...</option>
</select>
</div>
<div class="form-group">
<label for="task-title">Title *</label>
<input type="text" id="task-title" required placeholder="Task title...">
</div>
<div class="form-group">
<label for="task-description">Description</label>
<textarea id="task-description" placeholder="Describe the task..."></textarea>
</div>
<div class="form-group">
<label for="task-priority">Priority</label>
<select id="task-priority">
<option value="">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-group">
<label for="task-status">Status</label>
<select id="task-status">
<option value="To Do">To Do</option>
<option value="In Progress">In Progress</option>
<option value="Done">Done</option>
</select>
</div>
<div class="form-group">
<label for="task-labels">Labels (comma-separated)</label>
<input type="text" id="task-labels" placeholder="feature, backend, urgent">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-task">Cancel</button>
<button type="submit" class="btn btn-primary">Create Task</button>
</div>
</form>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toast-container"></div>
<script>
let projects = [];
let tasks = [];
let selectedProject = null;
let ws = null;
let draggedTask = null;
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -209,17 +373,29 @@
tasks = data.tasks || [];
renderProjects();
renderTasks();
updateProjectDropdown();
}
} catch (e) { console.error('Parse error:', e); }
};
}
function updateProjectDropdown() {
const select = document.getElementById('task-project');
select.innerHTML = '<option value="">Select a project...</option>';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.path;
opt.textContent = p.name;
select.appendChild(opt);
});
}
function renderProjects() {
const filterBar = document.getElementById('filter-bar');
// Remove old project buttons except filter and all-projects
const oldBtns = filterBar.querySelectorAll('.project-btn:not(#all-projects)');
oldBtns.forEach(btn => btn.remove());
const newTaskBtn = document.getElementById('new-task-btn');
projects.forEach(p => {
const btn = document.createElement('button');
btn.className = 'project-btn';
@ -230,7 +406,7 @@
btn.classList.add('active');
renderTasks();
};
filterBar.appendChild(btn);
filterBar.insertBefore(btn, newTaskBtn);
});
document.getElementById('stats').textContent =
@ -245,7 +421,6 @@
'done': 'done'
};
// Clear all columns
['todo', 'progress', 'done'].forEach(id => {
document.getElementById(`tasks-${id}`).innerHTML = '';
});
@ -263,7 +438,19 @@
const card = document.createElement('div');
card.className = 'task-card';
card.style.borderLeftColor = task.projectColor;
card.onclick = () => card.classList.toggle('expanded');
card.draggable = true;
card.dataset.taskId = task.id;
card.dataset.projectPath = task.projectPath;
card.dataset.currentStatus = task.status;
// Drag events
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragend', handleDragEnd);
card.addEventListener('click', (e) => {
if (!e.target.closest('.task-card').classList.contains('dragging')) {
card.classList.toggle('expanded');
}
});
let priorityClass = '';
if (task.priority) {
@ -299,6 +486,149 @@
document.getElementById('count-done').textContent = counts.done;
}
// Drag and Drop handlers
function handleDragStart(e) {
draggedTask = e.target;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
taskId: e.target.dataset.taskId,
projectPath: e.target.dataset.projectPath,
currentStatus: e.target.dataset.currentStatus
}));
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
draggedTask = null;
document.querySelectorAll('.column').forEach(col => col.classList.remove('drag-over'));
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleDragEnter(e) {
e.preventDefault();
const column = e.target.closest('.column');
if (column) column.classList.add('drag-over');
}
function handleDragLeave(e) {
const column = e.target.closest('.column');
if (column && !column.contains(e.relatedTarget)) {
column.classList.remove('drag-over');
}
}
async function handleDrop(e) {
e.preventDefault();
const column = e.target.closest('.column');
if (!column) return;
column.classList.remove('drag-over');
const newStatus = column.dataset.status;
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.currentStatus === newStatus) return;
// Optimistic update
const taskIndex = tasks.findIndex(t => t.id === data.taskId && t.projectPath === data.projectPath);
if (taskIndex !== -1) {
tasks[taskIndex].status = newStatus;
renderTasks();
}
// Send to server
const response = await fetch('/api/tasks/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: data.projectPath,
taskId: data.taskId,
status: newStatus
})
});
if (!response.ok) {
throw new Error('Failed to update task');
}
showToast(`Task moved to ${newStatus}`, 'success');
} catch (err) {
console.error('Drop error:', err);
showToast('Failed to update task', 'error');
// Revert on error - will be fixed on next WebSocket update
}
}
// Set up column drop zones
document.querySelectorAll('.column').forEach(column => {
column.addEventListener('dragover', handleDragOver);
column.addEventListener('dragenter', handleDragEnter);
column.addEventListener('dragleave', handleDragLeave);
column.addEventListener('drop', handleDrop);
});
// New Task Modal
document.getElementById('new-task-btn').onclick = () => {
document.getElementById('new-task-modal').classList.add('active');
};
document.getElementById('cancel-task').onclick = () => {
document.getElementById('new-task-modal').classList.remove('active');
document.getElementById('new-task-form').reset();
};
document.getElementById('new-task-modal').onclick = (e) => {
if (e.target.classList.contains('modal-overlay')) {
document.getElementById('new-task-modal').classList.remove('active');
document.getElementById('new-task-form').reset();
}
};
document.getElementById('new-task-form').onsubmit = async (e) => {
e.preventDefault();
const projectPath = document.getElementById('task-project').value;
const title = document.getElementById('task-title').value;
const description = document.getElementById('task-description').value;
const priority = document.getElementById('task-priority').value;
const status = document.getElementById('task-status').value;
const labelsStr = document.getElementById('task-labels').value;
const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()).filter(l => l) : [];
try {
const response = await fetch('/api/tasks/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath,
title,
description,
priority: priority || undefined,
status,
labels
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Failed to create task');
}
const result = await response.json();
showToast(`Task ${result.taskId} created!`, 'success');
document.getElementById('new-task-modal').classList.remove('active');
document.getElementById('new-task-form').reset();
} catch (err) {
console.error('Create error:', err);
showToast(err.message || 'Failed to create task', 'error');
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;