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:
parent
3e87147f63
commit
b3e055217d
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 />);
|
||||
|
|
@ -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>
|
||||
39
src/cli.ts
39
src/cli.ts
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue