Add multi-project backlog aggregator with real-time updates

- Create BacklogAggregator server that scans directories for backlog projects
- Add WebSocket support for real-time task updates across all projects
- Implement project color-coding for visual distinction in unified view
- Add React frontend with Kanban board showing aggregated tasks
- Add 'aggregator' CLI command to start the multi-project server
- Create Docker configuration for deployment at backlog.jeffemmett.com

The aggregator watches multiple backlog directories and broadcasts updates
via WebSocket when any task changes, enabling Claude agents to update tasks
and have changes reflect immediately in the unified dashboard.

🤖 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 20:35:43 -08:00
parent 3e87147f63
commit b3e055217d
6 changed files with 960 additions and 0 deletions

29
Dockerfile.aggregator Normal file
View File

@ -0,0 +1,29 @@
# Backlog Aggregator Dockerfile
# Multi-project real-time task aggregation server
FROM oven/bun:1 AS base
WORKDIR /app
# Install dependencies
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock* bunfig.toml /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile --ignore-scripts
# Build stage
FROM base AS release
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# Build CSS (needed for components)
RUN bun run build:css || true
# Expose port
EXPOSE 6420
# Set environment
ENV NODE_ENV=production
ENV PORT=6420
# Run the aggregator server
CMD ["bun", "src/aggregator/index.ts", "--port", "6420", "--paths", "/projects"]

View File

@ -0,0 +1,33 @@
# Backlog Aggregator - Multi-Project Real-time Task View
# Deploy at backlog.jeffemmett.com to see all project tasks in real-time
services:
backlog-aggregator:
build:
context: .
dockerfile: Dockerfile.aggregator
container_name: backlog-aggregator
restart: unless-stopped
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
# If you have repos in other locations, add them here:
# - /home/user/projects:/projects/home:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.backlog.rule=Host(`backlog.jeffemmett.com`)"
- "traefik.http.routers.backlog.entrypoints=web"
- "traefik.http.services.backlog.loadbalancer.server.port=6420"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
environment:
- PORT=6420
- NODE_ENV=production
command: ["bun", "src/aggregator/index.ts", "--port", "6420", "--paths", "/projects/websites,/projects/apps"]
networks:
traefik-public:
external: true

466
src/aggregator/index.ts Normal file
View File

@ -0,0 +1,466 @@
/**
* Backlog Aggregator Server
*
* Real-time multi-project task aggregator that:
* - Scans directories for backlog projects
* - Watches task files for changes
* - Provides unified WebSocket-based real-time updates
* - Serves aggregated task data with project metadata
*/
import { type Server, type ServerWebSocket, $ } from "bun";
import { watch, type FSWatcher } from "node:fs";
import { readdir, stat, readFile } 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";
import { sortByTaskId } from "../utils/task-sorting.ts";
// @ts-expect-error - Bun HTML import
import indexHtml from "./web/index.html";
// @ts-expect-error - Bun file import
import favicon from "../web/favicon.png" with { type: "file" };
interface ProjectConfig {
path: string;
name: string;
color: string;
}
interface AggregatedTask extends Task {
projectName: string;
projectColor: string;
projectPath: string;
}
interface AggregatorConfig {
scanPaths: string[];
port: number;
colors: string[];
}
const DEFAULT_COLORS = [
"#8b5cf6", // purple - canvas-website
"#22c55e", // green - hyperindex
"#3b82f6", // blue - mycofi
"#f97316", // orange - decolonize-time
"#ef4444", // red - ai-orchestrator
"#ec4899", // pink
"#14b8a6", // teal
"#eab308", // yellow
"#6366f1", // indigo
"#84cc16", // lime
];
export class BacklogAggregator {
private server: Server | null = null;
private sockets = new Set<ServerWebSocket<unknown>>();
private projects = new Map<string, ProjectConfig>();
private tasks = new Map<string, AggregatedTask>();
private watchers = new Map<string, FSWatcher>();
private colorIndex = 0;
private config: AggregatorConfig;
private debounceTimers = new Map<string, Timer>();
constructor(config: Partial<AggregatorConfig> = {}) {
this.config = {
scanPaths: config.scanPaths || ["/opt/websites", "/opt/apps"],
port: config.port || 6420,
colors: config.colors || DEFAULT_COLORS,
};
}
private getNextColor(): string {
const color = this.config.colors[this.colorIndex % this.config.colors.length];
this.colorIndex++;
return color;
}
async start(): Promise<void> {
console.log("Starting Backlog Aggregator...");
// Initial scan for projects
await this.scanForProjects();
// Start the HTTP/WebSocket server
this.server = Bun.serve({
port: this.config.port,
development: process.env.NODE_ENV === "development",
routes: {
"/": indexHtml,
"/api/projects": {
GET: async () => this.handleGetProjects(),
},
"/api/tasks": {
GET: async (req: Request) => this.handleGetTasks(req),
},
"/api/tasks/:project/:id": {
GET: async (req: Request & { params: { project: string; id: string } }) =>
this.handleGetTask(req.params.project, req.params.id),
},
"/api/statuses": {
GET: async () => this.handleGetStatuses(),
},
"/api/health": {
GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }),
},
},
fetch: async (req: Request, server: Server) => {
const url = new URL(req.url);
// Handle WebSocket upgrade
if (req.headers.get("upgrade") === "websocket") {
const success = server.upgrade(req, { data: undefined });
if (success) {
return new Response(null, { status: 101 });
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Serve favicon
if (url.pathname.startsWith("/favicon")) {
const faviconFile = Bun.file(favicon);
return new Response(faviconFile, {
headers: { "Content-Type": "image/png" },
});
}
return new Response("Not Found", { status: 404 });
},
websocket: {
open: (ws: ServerWebSocket) => {
this.sockets.add(ws);
// Send initial state
ws.send(
JSON.stringify({
type: "init",
projects: Array.from(this.projects.values()),
tasks: Array.from(this.tasks.values()),
}),
);
},
message: (ws: ServerWebSocket, message: string | Buffer) => {
const data = typeof message === "string" ? message : message.toString();
if (data === "ping") {
ws.send("pong");
}
},
close: (ws: ServerWebSocket) => {
this.sockets.delete(ws);
},
},
});
const url = `http://localhost:${this.config.port}`;
console.log(`Backlog Aggregator running at ${url}`);
console.log(`Watching ${this.projects.size} projects with ${this.tasks.size} tasks`);
// Set up periodic rescan for new projects
setInterval(() => this.scanForProjects(), 60000); // Every minute
}
async stop(): Promise<void> {
// Stop all watchers
for (const [path, watcher] of this.watchers) {
watcher.close();
}
this.watchers.clear();
// Clear debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
// Stop server
if (this.server) {
await this.server.stop();
this.server = null;
}
console.log("Aggregator stopped");
}
private async scanForProjects(): Promise<void> {
const foundProjects = new Set<string>();
for (const scanPath of this.config.scanPaths) {
try {
const entries = await readdir(scanPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const projectPath = join(scanPath, entry.name);
const backlogPath = join(projectPath, "backlog");
try {
const backlogStat = await stat(backlogPath);
if (backlogStat.isDirectory()) {
foundProjects.add(projectPath);
await this.addProject(projectPath);
}
} catch {
// No backlog directory
}
}
} catch (error) {
console.warn(`Could not scan ${scanPath}:`, error);
}
}
// Remove projects that no longer exist
for (const [path] of this.projects) {
if (!foundProjects.has(path)) {
this.removeProject(path);
}
}
}
private async addProject(projectPath: string): Promise<void> {
if (this.projects.has(projectPath)) return;
// Try to load project name from config
let projectName = basename(projectPath);
const configPath = join(projectPath, "backlog", "config.yml");
try {
const configContent = await readFile(configPath, "utf-8");
const nameMatch = configContent.match(/project_name:\s*["']?([^"'\n]+)["']?/);
if (nameMatch) {
projectName = nameMatch[1].trim();
}
} catch {
// Use directory name
}
const project: ProjectConfig = {
path: projectPath,
name: projectName,
color: this.getNextColor(),
};
this.projects.set(projectPath, project);
console.log(`Added project: ${projectName} (${projectPath})`);
// Load initial tasks
await this.loadProjectTasks(projectPath);
// Set up file watcher
this.watchProject(projectPath);
}
private removeProject(projectPath: string): void {
const project = this.projects.get(projectPath);
if (!project) return;
// Remove tasks
for (const [key] of this.tasks) {
if (key.startsWith(projectPath + ":")) {
this.tasks.delete(key);
}
}
// Stop watcher
const watcher = this.watchers.get(projectPath);
if (watcher) {
watcher.close();
this.watchers.delete(projectPath);
}
this.projects.delete(projectPath);
console.log(`Removed project: ${project.name}`);
this.broadcastUpdate();
}
private async loadProjectTasks(projectPath: string): Promise<void> {
const project = this.projects.get(projectPath);
if (!project) return;
const tasksPath = join(projectPath, "backlog", "tasks");
try {
const entries = await readdir(tasksPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const taskPath = join(tasksPath, entry.name);
await this.loadTask(projectPath, taskPath);
}
} catch (error) {
console.warn(`Could not load tasks from ${tasksPath}:`, error);
}
}
private async loadTask(projectPath: string, taskPath: string): Promise<void> {
const project = this.projects.get(projectPath);
if (!project) return;
try {
const content = await readFile(taskPath, "utf-8");
const task = parseTask(content, taskPath);
if (task) {
const aggregatedTask: AggregatedTask = {
...task,
projectName: project.name,
projectColor: project.color,
projectPath: projectPath,
};
const key = `${projectPath}:${task.id}`;
this.tasks.set(key, aggregatedTask);
}
} catch (error) {
console.warn(`Could not load task ${taskPath}:`, error);
}
}
private watchProject(projectPath: string): void {
const tasksPath = join(projectPath, "backlog", "tasks");
try {
const watcher = watch(tasksPath, { recursive: true }, (eventType, filename) => {
if (!filename || !filename.endsWith(".md")) return;
// Debounce rapid changes
const key = `${projectPath}:${filename}`;
const existing = this.debounceTimers.get(key);
if (existing) {
clearTimeout(existing);
}
this.debounceTimers.set(
key,
setTimeout(async () => {
this.debounceTimers.delete(key);
const taskPath = join(tasksPath, filename);
try {
await stat(taskPath);
await this.loadTask(projectPath, taskPath);
console.log(`Task updated: ${filename} in ${this.projects.get(projectPath)?.name}`);
} catch {
// File deleted - remove from tasks
const taskId = basename(filename, ".md").replace(/^task-/, "task-");
const taskKey = `${projectPath}:task-${taskId.replace(/^task-/, "")}`;
// Try various key formats
for (const [k] of this.tasks) {
if (k.startsWith(`${projectPath}:`) && k.includes(basename(filename, ".md"))) {
this.tasks.delete(k);
break;
}
}
console.log(`Task removed: ${filename} from ${this.projects.get(projectPath)?.name}`);
}
this.broadcastUpdate();
}, 100),
);
});
watcher.on("error", (error) => {
console.warn(`Watcher error for ${projectPath}:`, error);
});
this.watchers.set(projectPath, watcher);
} catch (error) {
console.warn(`Could not watch ${tasksPath}:`, error);
}
}
private broadcastUpdate(): void {
const message = JSON.stringify({
type: "update",
projects: Array.from(this.projects.values()),
tasks: Array.from(this.tasks.values()),
timestamp: Date.now(),
});
for (const ws of this.sockets) {
try {
ws.send(message);
} catch {
// Socket closed
}
}
}
// HTTP Handlers
private handleGetProjects(): Response {
return Response.json(Array.from(this.projects.values()));
}
private handleGetTasks(req: Request): Response {
const url = new URL(req.url);
const project = url.searchParams.get("project");
const status = url.searchParams.get("status");
const priority = url.searchParams.get("priority");
let tasks = Array.from(this.tasks.values());
if (project) {
tasks = tasks.filter((t) => t.projectName === project || t.projectPath.includes(project));
}
if (status) {
tasks = tasks.filter((t) => t.status.toLowerCase() === status.toLowerCase());
}
if (priority) {
tasks = tasks.filter((t) => (t.priority ?? "").toLowerCase() === priority.toLowerCase());
}
// Sort by project then by task ID
tasks = tasks.sort((a, b) => {
const projectCompare = a.projectName.localeCompare(b.projectName);
if (projectCompare !== 0) return projectCompare;
return sortByTaskId([a, b])[0] === a ? -1 : 1;
});
return Response.json(tasks);
}
private handleGetTask(project: string, id: string): Response {
for (const [key, task] of this.tasks) {
if ((task.projectName === project || task.projectPath.includes(project)) && task.id === id) {
return Response.json(task);
}
}
return Response.json({ error: "Task not found" }, { status: 404 });
}
private handleGetStatuses(): Response {
// Collect unique statuses from all projects
const statuses = new Set<string>();
for (const task of this.tasks.values()) {
statuses.add(task.status);
}
// Return common defaults plus any found
const common = ["To Do", "In Progress", "Done"];
for (const s of common) statuses.add(s);
return Response.json(Array.from(statuses));
}
}
// CLI entry point
if (import.meta.main) {
const args = process.argv.slice(2);
const portIndex = args.indexOf("--port");
const port = portIndex !== -1 ? Number.parseInt(args[portIndex + 1], 10) : 6420;
const pathsIndex = args.indexOf("--paths");
const paths = pathsIndex !== -1 ? args[pathsIndex + 1].split(",") : ["/opt/websites", "/opt/apps"];
const aggregator = new BacklogAggregator({ port, scanPaths: paths });
process.on("SIGINT", async () => {
await aggregator.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
await aggregator.stop();
process.exit(0);
});
aggregator.start().catch((error) => {
console.error("Failed to start aggregator:", error);
process.exit(1);
});
}

380
src/aggregator/web/app.tsx Normal file
View File

@ -0,0 +1,380 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
interface Project {
path: string;
name: string;
color: string;
}
interface AggregatedTask {
id: string;
title: string;
status: string;
priority?: string;
description?: string;
assignee: string[];
labels: string[];
projectName: string;
projectColor: string;
projectPath: string;
createdDate?: string;
updatedDate?: string;
}
interface WebSocketMessage {
type: "init" | "update";
projects: Project[];
tasks: AggregatedTask[];
timestamp?: number;
}
function App() {
const [projects, setProjects] = useState<Project[]>([]);
const [tasks, setTasks] = useState<AggregatedTask[]>([]);
const [connected, setConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [filter, setFilter] = useState<string>("");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectWebSocket = useCallback(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onopen = () => {
setConnected(true);
console.log("WebSocket connected");
};
ws.onmessage = (event) => {
try {
const data: WebSocketMessage = JSON.parse(event.data);
if (data.type === "init" || data.type === "update") {
setProjects(data.projects);
setTasks(data.tasks);
setLastUpdate(new Date());
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onclose = () => {
setConnected(false);
console.log("WebSocket disconnected, reconnecting...");
reconnectTimeoutRef.current = setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
wsRef.current = ws;
}, []);
useEffect(() => {
connectWebSocket();
return () => {
wsRef.current?.close();
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [connectWebSocket]);
// Group tasks by status
const statuses = ["To Do", "In Progress", "Done"];
const tasksByStatus = statuses.reduce(
(acc, status) => {
acc[status] = tasks.filter((t) => {
const statusMatch = t.status.toLowerCase() === status.toLowerCase();
const projectMatch = !selectedProject || t.projectName === selectedProject;
const filterMatch =
!filter ||
t.title.toLowerCase().includes(filter.toLowerCase()) ||
t.projectName.toLowerCase().includes(filter.toLowerCase());
return statusMatch && projectMatch && filterMatch;
});
return acc;
},
{} as Record<string, AggregatedTask[]>,
);
return (
<div
style={{
minHeight: "100vh",
backgroundColor: "#0f172a",
color: "#e2e8f0",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
{/* Header */}
<header
style={{
padding: "1rem 2rem",
backgroundColor: "#1e293b",
borderBottom: "1px solid #334155",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<h1 style={{ margin: 0, fontSize: "1.5rem", fontWeight: 600 }}>Backlog Aggregator</h1>
<span
style={{
padding: "0.25rem 0.5rem",
borderRadius: "9999px",
fontSize: "0.75rem",
backgroundColor: connected ? "#22c55e" : "#ef4444",
color: "white",
}}
>
{connected ? "Live" : "Reconnecting..."}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<span style={{ fontSize: "0.875rem", color: "#94a3b8" }}>
{projects.length} projects | {tasks.length} tasks
{lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`}
</span>
</div>
</header>
{/* Project Filter Bar */}
<div
style={{
padding: "1rem 2rem",
backgroundColor: "#1e293b",
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
}}
>
<input
type="text"
placeholder="Filter tasks..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "1px solid #334155",
backgroundColor: "#0f172a",
color: "#e2e8f0",
minWidth: "200px",
}}
/>
<button
onClick={() => setSelectedProject(null)}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: selectedProject === null ? "2px solid #3b82f6" : "1px solid #334155",
backgroundColor: selectedProject === null ? "#1e40af" : "#0f172a",
color: "#e2e8f0",
cursor: "pointer",
}}
>
All Projects
</button>
{projects.map((project) => (
<button
key={project.path}
onClick={() => setSelectedProject(project.name)}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: selectedProject === project.name ? `2px solid ${project.color}` : "1px solid #334155",
backgroundColor: selectedProject === project.name ? project.color + "33" : "#0f172a",
color: "#e2e8f0",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: "0.75rem",
height: "0.75rem",
borderRadius: "50%",
backgroundColor: project.color,
}}
/>
{project.name}
</button>
))}
</div>
{/* Kanban Board */}
<div
style={{
padding: "2rem",
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.5rem",
minHeight: "calc(100vh - 180px)",
}}
>
{statuses.map((status) => (
<div
key={status}
style={{
backgroundColor: "#1e293b",
borderRadius: "0.5rem",
padding: "1rem",
display: "flex",
flexDirection: "column",
}}
>
{/* Column Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
paddingBottom: "0.5rem",
borderBottom: "1px solid #334155",
}}
>
<h2 style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}>{status}</h2>
<span
style={{
backgroundColor: "#334155",
padding: "0.25rem 0.5rem",
borderRadius: "9999px",
fontSize: "0.75rem",
}}
>
{tasksByStatus[status]?.length || 0}
</span>
</div>
{/* Tasks */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
{tasksByStatus[status]?.map((task) => (
<TaskCard key={`${task.projectPath}:${task.id}`} task={task} />
))}
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
No tasks
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}
function TaskCard({ task }: { task: AggregatedTask }) {
const [expanded, setExpanded] = useState(false);
const priorityColors: Record<string, string> = {
high: "#ef4444",
medium: "#f59e0b",
low: "#22c55e",
};
return (
<div
onClick={() => setExpanded(!expanded)}
style={{
backgroundColor: "#0f172a",
borderRadius: "0.375rem",
padding: "0.75rem",
cursor: "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";
}}
>
{/* Task Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.875rem", fontWeight: 500, marginBottom: "0.25rem" }}>{task.title}</div>
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>
{task.id} | {task.projectName}
</div>
</div>
{task.priority && (
<span
style={{
padding: "0.125rem 0.375rem",
borderRadius: "0.25rem",
fontSize: "0.625rem",
fontWeight: 500,
textTransform: "uppercase",
backgroundColor: priorityColors[task.priority.toLowerCase()] + "22",
color: priorityColors[task.priority.toLowerCase()],
}}
>
{task.priority}
</span>
)}
</div>
{/* Labels */}
{task.labels && task.labels.length > 0 && (
<div style={{ display: "flex", gap: "0.25rem", flexWrap: "wrap", marginTop: "0.5rem" }}>
{task.labels.map((label) => (
<span
key={label}
style={{
padding: "0.125rem 0.375rem",
borderRadius: "0.25rem",
fontSize: "0.625rem",
backgroundColor: "#334155",
color: "#94a3b8",
}}
>
{label}
</span>
))}
</div>
)}
{/* Expanded Details */}
{expanded && task.description && (
<div
style={{
marginTop: "0.75rem",
paddingTop: "0.75rem",
borderTop: "1px solid #334155",
fontSize: "0.8rem",
color: "#94a3b8",
whiteSpace: "pre-wrap",
}}
>
{task.description.slice(0, 500)}
{task.description.length > 500 && "..."}
</div>
)}
{/* Assignees */}
{task.assignee && task.assignee.length > 0 && (
<div style={{ marginTop: "0.5rem", fontSize: "0.75rem", color: "#64748b" }}>
{task.assignee.join(", ")}
</div>
)}
</div>
);
}
// Mount the app
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backlog Aggregator - Real-time Task View</title>
<link rel="icon" type="image/png" href="/favicon.png">
<script type="module" src="./app.tsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -3066,6 +3066,45 @@ program
}
});
// Aggregator command for multi-project view
program
.command("aggregator")
.description("start the multi-project backlog aggregator with real-time updates")
.option("-p, --port <port>", "port to run server on", "6420")
.option("--paths <paths>", "comma-separated list of directories to scan for backlog projects")
.action(async (options) => {
try {
const { BacklogAggregator } = await import("./aggregator/index.ts");
const port = Number.parseInt(options.port, 10) || 6420;
const scanPaths = options.paths
? options.paths.split(",").map((p: string) => p.trim())
: [process.cwd()];
const aggregator = new BacklogAggregator({ port, scanPaths });
const shutdown = async (signal: string) => {
console.log(`\n${signal} received, shutting down...`);
try {
const stopPromise = aggregator.stop();
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 3000));
await Promise.race([stopPromise, timeout]);
} finally {
process.exit(0);
}
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
process.once("SIGQUIT", () => void shutdown("SIGQUIT"));
await aggregator.start();
} catch (err) {
console.error("Failed to start aggregator", err);
process.exitCode = 1;
}
});
// Completion command group
registerCompletionCommand(program);