diff --git a/src/aggregator/web/app.tsx b/src/aggregator/web/app.tsx index b9ac9db..415f3b4 100644 --- a/src/aggregator/web/app.tsx +++ b/src/aggregator/web/app.tsx @@ -1,6 +1,9 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import * as d3 from "d3"; +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createRoot } from "react-dom/client"; +type ViewMode = "kanban" | "dependencies"; + interface Project { path: string; name: string; @@ -39,6 +42,8 @@ function App() { const [actionError, setActionError] = useState(null); const [draggedTask, setDraggedTask] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); + const [viewMode, setViewMode] = useState("kanban"); + const [showCompleted, setShowCompleted] = useState(false); const wsRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); @@ -251,6 +256,51 @@ function App() { > {connected ? "Live" : "Reconnecting..."} + {/* View Mode Tabs */} +
+ + +
{actionError && ( @@ -340,83 +390,108 @@ function App() { ))}
- {/* Kanban Board */} -
- {statuses.map((status) => { - const isDropTarget = dragOverColumn === status && draggedTask?.status !== status; - return ( -
handleColumnDragOver(e, status)} - onDragLeave={handleColumnDragLeave} - onDrop={(e) => handleColumnDrop(e, status)} - style={{ - backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b", - borderRadius: "0.5rem", - padding: "1rem", - display: "flex", - flexDirection: "column", - border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent", - transition: "all 0.2s ease", - }} - > - {/* Column Header */} + {/* View Content */} + {viewMode === "kanban" ? ( + /* Kanban Board */ +
+ {statuses.map((status) => { + const isDropTarget = dragOverColumn === status && draggedTask?.status !== status; + return (
handleColumnDragOver(e, status)} + onDragLeave={handleColumnDragLeave} + onDrop={(e) => handleColumnDrop(e, status)} style={{ + backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b", + borderRadius: "0.5rem", + padding: "1rem", display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "1rem", - paddingBottom: "0.5rem", - borderBottom: "1px solid #334155", + flexDirection: "column", + border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent", + transition: "all 0.2s ease", }} > -

{status}

- - {tasksByStatus[status]?.length || 0} - -
+

{status}

+ + {tasksByStatus[status]?.length || 0} + +
- {/* Tasks */} -
- {tasksByStatus[status]?.map((task) => ( - - ))} - {(!tasksByStatus[status] || tasksByStatus[status].length === 0) && ( -
- {draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"} -
- )} + {/* Tasks */} +
+ {tasksByStatus[status]?.map((task) => ( + + ))} + {(!tasksByStatus[status] || tasksByStatus[status].length === 0) && ( +
+ {draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"} +
+ )} +
+ ); + })} +
+ ) : ( + /* Dependency Graph View */ +
+
+
- ); - })} -
+ +
+ )} ); } @@ -763,6 +838,251 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses, onDragStart, ); } +// D3 Dependency Graph Component +interface DependencyGraphProps { + tasks: AggregatedTask[]; + projects: Project[]; + selectedProject: string | null; + filter: string; + showCompleted: boolean; +} + +interface D3Node extends d3.SimulationNodeDatum { + id: string; + title: string; + status: string; + priority?: string; + projectName: string; + projectColor: string; +} + +interface D3Edge extends d3.SimulationLinkDatum { + source: string | D3Node; + target: string | D3Node; +} + +function getNodeColor(status: string): string { + const lower = status.toLowerCase(); + if (lower.includes("done") || lower.includes("complete")) return "#22c55e"; + if (lower.includes("progress")) return "#3b82f6"; + return "#6b7280"; +} + +function isCompleted(status: string): boolean { + const lower = status.toLowerCase(); + return lower.includes("done") || lower.includes("complete"); +} + +function DependencyGraph({ tasks, projects, selectedProject, filter, showCompleted }: DependencyGraphProps) { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); + + // Filter tasks + const filteredTasks = useMemo(() => { + return tasks.filter((t) => { + if (!showCompleted && isCompleted(t.status)) return false; + if (selectedProject && t.projectName !== selectedProject) return false; + if (filter && !t.title.toLowerCase().includes(filter.toLowerCase())) return false; + return true; + }); + }, [tasks, showCompleted, selectedProject, filter]); + + // Build graph data - note: aggregated tasks don't have dependencies in YAML + // So we'll just show all tasks as nodes without edges for now + const d3Data = useMemo(() => { + const nodes: D3Node[] = filteredTasks.map((t) => ({ + id: `${t.projectPath}:${t.id}`, + title: t.title, + status: t.status, + priority: t.priority, + projectName: t.projectName, + projectColor: t.projectColor, + })); + // No edges since aggregated tasks don't track dependencies across projects + const edges: D3Edge[] = []; + return { nodes, edges }; + }, [filteredTasks]); + + // Handle resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDimensions({ + width: Math.max(600, rect.width), + height: Math.max(500, 600), + }); + } + }; + updateDimensions(); + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, []); + + // D3 visualization + useEffect(() => { + if (!svgRef.current || d3Data.nodes.length === 0) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const { width, height } = dimensions; + const g = svg.append("g").attr("class", "graph-container"); + + // Zoom behavior + const zoom = d3.zoom() + .scaleExtent([0.1, 4]) + .on("zoom", (event) => g.attr("transform", event.transform)); + svg.call(zoom); + svg.on("dblclick.zoom", () => svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity)); + + // Create copies for simulation + const nodesCopy: D3Node[] = d3Data.nodes.map((d) => ({ ...d })); + const edgesCopy: D3Edge[] = d3Data.edges.map((d) => ({ ...d })); + + // Force simulation + const simulation = d3.forceSimulation(nodesCopy) + .force("link", d3.forceLink(edgesCopy).id((d) => d.id).distance(150)) + .force("charge", d3.forceManyBody().strength(-400)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collision", d3.forceCollide().radius(45)); + + // Draw edges + const links = g.append("g").attr("class", "links") + .selectAll("line").data(edgesCopy).join("line") + .attr("stroke", "#9ca3af").attr("stroke-width", 2).style("opacity", 0.6); + + // Draw nodes + const nodes = g.append("g").attr("class", "nodes") + .selectAll("g").data(nodesCopy).join("g") + .attr("class", "node").attr("cursor", "grab"); + + // Node circles with project color border + nodes.append("circle") + .attr("r", 28) + .attr("fill", (d) => getNodeColor(d.status)) + .attr("stroke", (d) => d.projectColor) + .attr("stroke-width", 3); + + // Progress fill for completed + nodes.filter((d) => isCompleted(d.status)) + .append("circle").attr("r", 0).attr("fill", "#16a34a") + .transition().duration(800).ease(d3.easeElastic).attr("r", 24); + + // Task ID label + nodes.append("text") + .attr("text-anchor", "middle").attr("dy", 4) + .attr("fill", "white").attr("font-size", "10px").attr("font-weight", "bold") + .attr("pointer-events", "none") + .text((d) => d.id.split(":").pop()?.replace("task-", "") || ""); + + // Title below node + nodes.append("text") + .attr("text-anchor", "middle").attr("dy", 45) + .attr("fill", "#94a3b8").attr("font-size", "9px") + .attr("pointer-events", "none") + .text((d) => d.title.length > 18 ? `${d.title.substring(0, 18)}...` : d.title); + + // Project name above node + nodes.append("text") + .attr("text-anchor", "middle").attr("dy", -35) + .attr("fill", (d) => d.projectColor).attr("font-size", "8px").attr("font-weight", "bold") + .attr("pointer-events", "none") + .text((d) => d.projectName); + + // Drag behavior + const drag = d3.drag() + .on("start", (event, d) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; d.fy = d.y; + }) + .on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; }) + .on("end", (event, d) => { + if (!event.active) simulation.alphaTarget(0); + }); + nodes.call(drag); + + // Double-click to unpin + nodes.on("dblclick", (event, d) => { + event.stopPropagation(); + d.fx = null; d.fy = null; + simulation.alpha(0.3).restart(); + }); + + // Hover effects + nodes.on("mouseenter", function() { + d3.select(this).select("circle").attr("stroke-width", 5); + }).on("mouseleave", function() { + d3.select(this).select("circle").attr("stroke-width", 3); + }); + + // Tick + simulation.on("tick", () => { + links + .attr("x1", (d) => (d.source as D3Node).x || 0) + .attr("y1", (d) => (d.source as D3Node).y || 0) + .attr("x2", (d) => (d.target as D3Node).x || 0) + .attr("y2", (d) => (d.target as D3Node).y || 0); + nodes.attr("transform", (d) => `translate(${d.x || 0},${d.y || 0})`); + }); + + // Initial zoom to fit + setTimeout(() => { + const bounds = (g.node() as SVGGElement)?.getBBox(); + if (bounds && bounds.width > 0) { + const scale = Math.min(0.9, Math.min(width / (bounds.width + 100), height / (bounds.height + 100))); + const translateX = width / 2 - (bounds.x + bounds.width / 2) * scale; + const translateY = height / 2 - (bounds.y + bounds.height / 2) * scale; + svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale)); + } + }, 500); + + return () => { simulation.stop(); }; + }, [d3Data, dimensions]); + + if (filteredTasks.length === 0) { + return ( +
+ No tasks to display. Try adjusting your filters. +
+ ); + } + + return ( +
+
+ {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} • Drag to move • Scroll to zoom • Double-click to unpin +
+
+ +
+ {/* Legend */} +
+
+ + Done +
+
+ + In Progress +
+
+ + To Do +
+
+ Border color = Project +
+
+
+ ); +} + // Mount the app const root = createRoot(document.getElementById("root")!); root.render();