From 47b8bdfd93e38c91bffecc59dd7f404e8b5e310b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 8 Jan 2026 12:12:05 +0100 Subject: [PATCH] Add dependency visualization dashboard with D3.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BoardTabs component for switching between Kanban and Dependencies views - Add DependencyView (Mermaid-based) and DependencyViewD3 (D3.js force-directed graph) - Add DependencyFilters for status, priority, and completed task filtering - Add graph utilities for building dependency graphs and generating visualizations - Features: drag nodes, pan/zoom, hover highlighting, click-to-edit, sequence badges - Color coding: green (done), blue (in progress), gray (to do), red border (high priority) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bun.lock | 4 + package.json | 6 +- src/test/dependency-graph.test.ts | 192 +++++++++ src/web/components/BoardPage.tsx | 108 ++++- src/web/components/BoardTabs.tsx | 57 +++ src/web/components/DependencyFilters.tsx | 159 ++++++++ src/web/components/DependencyView.tsx | 309 ++++++++++++++ src/web/components/DependencyViewD3.tsx | 497 +++++++++++++++++++++++ src/web/types/graph.ts | 44 ++ src/web/utils/dependency-graph.ts | 228 +++++++++++ 10 files changed, 1584 insertions(+), 20 deletions(-) create mode 100644 src/test/dependency-graph.test.ts create mode 100644 src/web/components/BoardTabs.tsx create mode 100644 src/web/components/DependencyFilters.tsx create mode 100644 src/web/components/DependencyView.tsx create mode 100644 src/web/components/DependencyViewD3.tsx create mode 100644 src/web/types/graph.ts create mode 100644 src/web/utils/dependency-graph.ts diff --git a/bun.lock b/bun.lock index 8cb4e53..4351444 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "backlog.md", + "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0", + }, "devDependencies": { "@biomejs/biome": "2.3.8", "@modelcontextprotocol/sdk": "1.24.2", diff --git a/package.json b/package.json index 224f354..ab4cee7 100644 --- a/package.json +++ b/package.json @@ -93,5 +93,9 @@ "trustedDependencies": [ "@biomejs/biome", "node-pty" - ] + ], + "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0" + } } diff --git a/src/test/dependency-graph.test.ts b/src/test/dependency-graph.test.ts new file mode 100644 index 0000000..6491f57 --- /dev/null +++ b/src/test/dependency-graph.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import type { Task } from "../types"; +import { + buildDependencyGraph, + extractTaskIdFromNodeId, + filterGraph, + generateMermaidFlowchart, +} from "../web/utils/dependency-graph"; + +// Helper to create minimal tasks for testing +function createTask(id: string, overrides: Partial = {}): Task { + return { + id, + title: `Task ${id}`, + status: "To Do", + assignee: [], + createdDate: "2025-01-01", + labels: [], + dependencies: [], + ...overrides, + }; +} + +describe("buildDependencyGraph", () => { + test("creates nodes and edges from tasks", () => { + const tasks = [createTask("task-001"), createTask("task-002", { dependencies: ["task-001"] })]; + + const graph = buildDependencyGraph(tasks); + + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toEqual({ source: "task-001", target: "task-002" }); + }); + + test("calculates dependents correctly", () => { + const tasks = [ + createTask("task-001"), + createTask("task-002", { dependencies: ["task-001"] }), + createTask("task-003", { dependencies: ["task-001"] }), + ]; + + const graph = buildDependencyGraph(tasks); + const task001 = graph.nodes.find((n) => n.id === "task-001"); + + expect(task001?.dependents).toEqual(["task-002", "task-003"]); + }); + + test("annotates nodes with sequence index", () => { + const tasks = [ + createTask("task-001", { ordinal: 0 }), + createTask("task-002", { dependencies: ["task-001"] }), + createTask("task-003", { dependencies: ["task-002"] }), + ]; + + const graph = buildDependencyGraph(tasks); + + // First task should be in sequence 1 + const task001 = graph.nodes.find((n) => n.id === "task-001"); + expect(task001?.sequenceIndex).toBe(1); + + // Second task depends on first, should be in sequence 2 + const task002 = graph.nodes.find((n) => n.id === "task-002"); + expect(task002?.sequenceIndex).toBe(2); + + // Third task depends on second, should be in sequence 3 + const task003 = graph.nodes.find((n) => n.id === "task-003"); + expect(task003?.sequenceIndex).toBe(3); + }); + + test("handles tasks with no dependencies as unsequenced", () => { + const tasks = [ + createTask("task-001"), // No ordinal, no deps, no dependents + createTask("task-002"), // No ordinal, no deps, no dependents + ]; + + const graph = buildDependencyGraph(tasks); + + // Isolated tasks should go to unsequenced bucket + expect(graph.unsequenced).toHaveLength(2); + }); +}); + +describe("filterGraph", () => { + test("filters by status", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { status: "Done" }), + createTask("task-002", { status: "To Do" }), + ]); + + const filtered = filterGraph(graph, { showCompleted: false }); + + expect(filtered.nodes).toHaveLength(1); + expect(filtered.nodes[0]?.id).toBe("task-002"); + }); + + test("includes completed tasks when showCompleted is true", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { status: "Done" }), + createTask("task-002", { status: "To Do" }), + ]); + + const filtered = filterGraph(graph, { showCompleted: true }); + + expect(filtered.nodes).toHaveLength(2); + }); + + test("filters by priority", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { priority: "high" }), + createTask("task-002", { priority: "low" }), + ]); + + const filtered = filterGraph(graph, { priority: ["high"], showCompleted: true }); + + expect(filtered.nodes).toHaveLength(1); + expect(filtered.nodes[0]?.id).toBe("task-001"); + }); + + test("filters edges to match filtered nodes", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { status: "Done" }), + createTask("task-002", { status: "To Do", dependencies: ["task-001"] }), + ]); + + // When we hide completed tasks, the edge should be removed too + const filtered = filterGraph(graph, { showCompleted: false }); + + expect(filtered.edges).toHaveLength(0); + }); +}); + +describe("generateMermaidFlowchart", () => { + test("generates valid Mermaid syntax", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { status: "Done", ordinal: 0 }), + createTask("task-002", { status: "In Progress", dependencies: ["task-001"] }), + ]); + + const mermaid = generateMermaidFlowchart(graph); + + expect(mermaid).toContain("flowchart TB"); + expect(mermaid).toContain("task-001 --> task-002"); + expect(mermaid).toContain("classDef done"); + expect(mermaid).toContain("classDef inProgress"); + expect(mermaid).toContain("class task-001 done"); + expect(mermaid).toContain("class task-002 inProgress"); + }); + + test("generates subgraphs for sequences", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { ordinal: 0 }), + createTask("task-002", { dependencies: ["task-001"] }), + ]); + + const mermaid = generateMermaidFlowchart(graph); + + expect(mermaid).toContain('subgraph Seq1["Sequence 1"]'); + expect(mermaid).toContain('subgraph Seq2["Sequence 2"]'); + }); + + test("applies high priority styling", () => { + const graph = buildDependencyGraph([createTask("task-001", { priority: "high", ordinal: 0 })]); + + const mermaid = generateMermaidFlowchart(graph); + + expect(mermaid).toContain("class task-001 highPriority"); + }); + + test("escapes special characters in labels", () => { + const graph = buildDependencyGraph([ + createTask("task-001", { title: 'Task with "quotes" and [brackets]', ordinal: 0 }), + ]); + + const mermaid = generateMermaidFlowchart(graph); + + // Should escape quotes and remove brackets + expect(mermaid).not.toContain('"quotes"'); + expect(mermaid).not.toContain("[brackets]"); + }); +}); + +describe("extractTaskIdFromNodeId", () => { + test("extracts task ID from Mermaid node ID", () => { + expect(extractTaskIdFromNodeId("flowchart-task-001-123")).toBe("task-001"); + expect(extractTaskIdFromNodeId("flowchart-task-123-456")).toBe("task-123"); + }); + + test("returns null for invalid node IDs", () => { + expect(extractTaskIdFromNodeId("invalid")).toBeNull(); + expect(extractTaskIdFromNodeId("")).toBeNull(); + }); +}); diff --git a/src/web/components/BoardPage.tsx b/src/web/components/BoardPage.tsx index d0b7dfb..5d39f0c 100644 --- a/src/web/components/BoardPage.tsx +++ b/src/web/components/BoardPage.tsx @@ -1,7 +1,11 @@ -import React, { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import Board from './Board'; -import { type Task } from '../../types'; +import React, { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import Board from "./Board"; +import BoardTabs, { type ViewMode } from "./BoardTabs"; +import DependencyViewD3 from "./DependencyViewD3"; +import DependencyFilters from "./DependencyFilters"; +import type { Task } from "../../types"; +import type { GraphFilters } from "../types/graph"; interface BoardPageProps { onEditTask: (task: Task) => void; @@ -12,22 +16,66 @@ interface BoardPageProps { isLoading: boolean; } -export default function BoardPage({ onEditTask, onNewTask, tasks, onRefreshData, statuses, isLoading }: BoardPageProps) { +export default function BoardPage({ + onEditTask, + onNewTask, + tasks, + onRefreshData, + statuses, + isLoading, +}: BoardPageProps) { const [searchParams, setSearchParams] = useSearchParams(); const [highlightTaskId, setHighlightTaskId] = useState(null); + // View mode state (synced with URL) + const [activeView, setActiveView] = useState( + (searchParams.get("view") as ViewMode) || "kanban", + ); + + // Filter state for dependency view + const [filters, setFilters] = useState({ + showCompleted: false, + }); + + // Collect unique labels from all tasks + const availableLabels = [...new Set(tasks.flatMap((t) => t.labels || []))]; + + // Sync view mode to URL useEffect(() => { - const highlight = searchParams.get('highlight'); + setSearchParams( + (params) => { + if (activeView === "kanban") { + params.delete("view"); + } else { + params.set("view", activeView); + } + return params; + }, + { replace: true }, + ); + }, [activeView, setSearchParams]); + + // Handle highlight parameter from URL + useEffect(() => { + const highlight = searchParams.get("highlight"); if (highlight) { setHighlightTaskId(highlight); // Clear the highlight parameter after setting it - setSearchParams(params => { - params.delete('highlight'); - return params; - }, { replace: true }); + setSearchParams( + (params) => { + params.delete("highlight"); + return params; + }, + { replace: true }, + ); } }, [searchParams, setSearchParams]); + // Handle view change + const handleViewChange = (view: ViewMode) => { + setActiveView(view); + }; + // Clear highlight after it's been used const handleEditTask = (task: Task) => { setHighlightTaskId(null); // Clear highlight so popup doesn't reopen @@ -36,15 +84,37 @@ export default function BoardPage({ onEditTask, onNewTask, tasks, onRefreshData, return (
- + {/* Tab Navigation */} + + + {/* View Content */} + {activeView === "kanban" ? ( + + ) : ( +
+ {/* Header with filters */} +
+

Dependency Graph

+ +
+ + {/* Dependency Visualization (D3.js) */} + +
+ )}
); } diff --git a/src/web/components/BoardTabs.tsx b/src/web/components/BoardTabs.tsx new file mode 100644 index 0000000..401a9bf --- /dev/null +++ b/src/web/components/BoardTabs.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +export type ViewMode = "kanban" | "dependencies"; + +interface BoardTabsProps { + activeView: ViewMode; + onViewChange: (view: ViewMode) => void; +} + +export default function BoardTabs({ activeView, onViewChange }: BoardTabsProps) { + return ( +
+ + +
+ ); +} diff --git a/src/web/components/DependencyFilters.tsx b/src/web/components/DependencyFilters.tsx new file mode 100644 index 0000000..44ec22f --- /dev/null +++ b/src/web/components/DependencyFilters.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import type { GraphFilters } from "../types/graph"; + +interface DependencyFiltersProps { + filters: GraphFilters; + onChange: (filters: GraphFilters) => void; + statuses: string[]; + availableLabels: string[]; +} + +export default function DependencyFilters({ + filters, + onChange, + statuses, + availableLabels, +}: DependencyFiltersProps) { + const handleShowCompletedChange = (e: React.ChangeEvent) => { + onChange({ ...filters, showCompleted: e.target.checked }); + }; + + const handleStatusChange = (status: string) => { + const currentStatuses = filters.status || []; + const newStatuses = currentStatuses.includes(status) + ? currentStatuses.filter((s) => s !== status) + : [...currentStatuses, status]; + onChange({ ...filters, status: newStatuses.length > 0 ? newStatuses : undefined }); + }; + + const handlePriorityChange = (priority: "high" | "medium" | "low") => { + const currentPriorities = filters.priority || []; + const newPriorities = currentPriorities.includes(priority) + ? currentPriorities.filter((p) => p !== priority) + : [...currentPriorities, priority]; + onChange({ ...filters, priority: newPriorities.length > 0 ? newPriorities : undefined }); + }; + + return ( +
+ {/* Show Completed Toggle */} + + + {/* Status Filter Dropdown */} +
+ +
+
+ {statuses.map((status) => ( + + ))} +
+
+
+ + {/* Priority Filter Dropdown */} +
+ +
+
+ {(["high", "medium", "low"] as const).map((priority) => ( + + ))} +
+
+
+ + {/* Clear Filters */} + {(filters.status?.length || + filters.priority?.length || + filters.labels?.length || + filters.showCompleted) && ( + + )} +
+ ); +} diff --git a/src/web/components/DependencyView.tsx b/src/web/components/DependencyView.tsx new file mode 100644 index 0000000..5510bf4 --- /dev/null +++ b/src/web/components/DependencyView.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useRef, useMemo, useCallback, useState } from "react"; +import type { Task } from "../../types"; +import type { GraphFilters } from "../types/graph"; +import { + buildDependencyGraph, + generateMermaidFlowchart, + filterGraph, + extractTaskIdFromNodeId, +} from "../utils/dependency-graph"; +import { ensureMermaid } from "../utils/mermaid"; + +interface DependencyViewProps { + tasks: Task[]; + onEditTask: (task: Task) => void; + filters?: GraphFilters; +} + +export default function DependencyView({ tasks, onEditTask, filters }: DependencyViewProps) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Create task lookup map + const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); + + // Build and filter graph, generate Mermaid code + const { mermaidCode, nodeCount, edgeCount } = useMemo(() => { + const fullGraph = buildDependencyGraph(tasks); + const graph = filters ? filterGraph(fullGraph, filters) : fullGraph; + return { + mermaidCode: generateMermaidFlowchart(graph), + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + }; + }, [tasks, filters]); + + // Attach click handlers to rendered SVG nodes + const attachClickHandlers = useCallback( + (svg: SVGElement) => { + // Find all node groups (Mermaid uses .node class) + const nodes = svg.querySelectorAll(".node"); + for (const node of nodes) { + const nodeEl = node as SVGElement; + const nodeId = nodeEl.getAttribute("id") || ""; + const taskId = extractTaskIdFromNodeId(nodeId); + + if (taskId) { + nodeEl.style.cursor = "pointer"; + + // Click to open task details + nodeEl.addEventListener("click", () => { + const task = taskMap.get(taskId); + if (task) onEditTask(task); + }); + + // Hover effects for dependency highlighting + nodeEl.addEventListener("mouseenter", () => { + highlightDependencyPath(svg, taskId, taskMap); + }); + nodeEl.addEventListener("mouseleave", () => { + clearHighlights(svg); + }); + } + } + }, + [taskMap, onEditTask], + ); + + // Render Mermaid diagram + useEffect(() => { + if (!containerRef.current) return; + + const render = async () => { + setIsLoading(true); + setError(null); + + try { + const m = await ensureMermaid(); + + // Initialize with loose security to allow click handlers + m.default.initialize({ + startOnLoad: false, + securityLevel: "loose", + theme: "default", + }); + + // Generate unique ID for this render + const id = `dependency-graph-${Date.now()}`; + + // Render the diagram + const result = await m.default.render(id, mermaidCode); + + if (containerRef.current) { + containerRef.current.innerHTML = result.svg; + + // Bind any Mermaid-provided functions + if (result.bindFunctions && containerRef.current) { + result.bindFunctions(containerRef.current); + } + + // Attach our custom click handlers + const svgElement = containerRef.current.querySelector("svg"); + if (svgElement) { + attachClickHandlers(svgElement); + } + } + } catch (err) { + console.error("Failed to render dependency graph:", err); + setError(err instanceof Error ? err.message : "Failed to render graph"); + } finally { + setIsLoading(false); + } + }; + + render(); + }, [mermaidCode, attachClickHandlers]); + + // Empty state + if (tasks.length === 0) { + return ( +
+
+ + + +

No tasks to display

+

Create some tasks to see the dependency graph

+
+
+ ); + } + + // No nodes after filtering + if (nodeCount === 0) { + return ( +
+
+ + + +

No tasks match filters

+

Try adjusting your filter criteria

+
+
+ ); + } + + return ( +
+ {/* Stats bar */} +
+ + {nodeCount} task{nodeCount !== 1 ? "s" : ""} • {edgeCount} dependenc{edgeCount !== 1 ? "ies" : "y"} + + Click a task to view details • Hover to highlight dependencies +
+ + {/* Loading overlay */} + {isLoading && ( +
+
+ + + + + Loading graph... +
+
+ )} + + {/* Error state */} + {error && ( +
+

Failed to render graph

+

{error}

+
+ )} + + {/* Graph container */} +
+
+
+ + {/* Legend */} +
+ Legend: +
+ + Done +
+
+ + In Progress +
+
+ + To Do +
+
+ + High Priority +
+
+
+ ); +} + +/** + * Highlight the dependency path for a task (its dependencies and dependents) + */ +function highlightDependencyPath(svg: SVGElement, taskId: string, taskMap: Map): void { + const task = taskMap.get(taskId); + if (!task) return; + + // Build set of connected task IDs + const connectedIds = new Set([taskId]); + for (const depId of task.dependencies || []) { + connectedIds.add(depId); + } + // Find tasks that depend on this one + for (const [id, t] of taskMap) { + if (t.dependencies?.includes(taskId)) { + connectedIds.add(id); + } + } + + // Dim non-connected nodes + const allNodes = svg.querySelectorAll(".node"); + for (const node of allNodes) { + const nodeEl = node as SVGElement; + const nodeId = nodeEl.getAttribute("id") || ""; + const nodeTaskId = extractTaskIdFromNodeId(nodeId); + + if (nodeTaskId && !connectedIds.has(nodeTaskId)) { + nodeEl.style.opacity = "0.3"; + } + } + + // Dim non-connected edges + const allEdges = svg.querySelectorAll(".edgePath"); + for (const edge of allEdges) { + const edgeEl = edge as SVGElement; + const edgeId = edgeEl.getAttribute("id") || ""; + + // Check if edge connects to our task + let isConnected = false; + for (const connectedId of connectedIds) { + if (edgeId.includes(connectedId)) { + isConnected = true; + break; + } + } + + if (!isConnected) { + edgeEl.style.opacity = "0.1"; + } else { + // Highlight connected edges + const path = edgeEl.querySelector("path"); + if (path) { + path.style.stroke = "#3b82f6"; + path.style.strokeWidth = "3"; + } + } + } +} + +/** + * Clear all highlight effects + */ +function clearHighlights(svg: SVGElement): void { + // Reset node opacity + const allNodes = svg.querySelectorAll(".node"); + for (const node of allNodes) { + (node as SVGElement).style.opacity = ""; + } + + // Reset edge opacity and styles + const allEdges = svg.querySelectorAll(".edgePath"); + for (const edge of allEdges) { + const edgeEl = edge as SVGElement; + edgeEl.style.opacity = ""; + const path = edgeEl.querySelector("path"); + if (path) { + path.style.stroke = ""; + path.style.strokeWidth = ""; + } + } +} diff --git a/src/web/components/DependencyViewD3.tsx b/src/web/components/DependencyViewD3.tsx new file mode 100644 index 0000000..aefc79c --- /dev/null +++ b/src/web/components/DependencyViewD3.tsx @@ -0,0 +1,497 @@ +import * as d3 from "d3"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Task } from "../../types"; +import type { GraphFilters } from "../types/graph"; +import { buildDependencyGraph, filterGraph } from "../utils/dependency-graph"; + +interface DependencyViewD3Props { + tasks: Task[]; + onEditTask: (task: Task) => void; + filters?: GraphFilters; +} + +interface D3Node extends d3.SimulationNodeDatum { + id: string; + title: string; + status: string; + priority?: "high" | "medium" | "low"; + labels: string[]; + dependencies: string[]; + dependents: string[]; + sequenceIndex?: number; +} + +interface D3Edge extends d3.SimulationLinkDatum { + source: string | D3Node; + target: string | D3Node; +} + +// Color utilities +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 getNodeStrokeColor(status: string): string { + const lower = status.toLowerCase(); + if (lower.includes("done") || lower.includes("complete")) return "#16a34a"; + if (lower.includes("progress")) return "#2563eb"; + return "#4b5563"; +} + +function isCompleted(status: string): boolean { + const lower = status.toLowerCase(); + return lower.includes("done") || lower.includes("complete"); +} + +export default function DependencyViewD3({ tasks, onEditTask, filters }: DependencyViewD3Props) { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); + + // Create task lookup map + const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); + + // Build and filter graph + const graph = useMemo(() => { + const fullGraph = buildDependencyGraph(tasks); + return filters ? filterGraph(fullGraph, filters) : fullGraph; + }, [tasks, filters]); + + // Convert to D3-compatible format + const d3Data = useMemo(() => { + const nodes: D3Node[] = graph.nodes.map((n) => ({ + ...n, + x: undefined, + y: undefined, + })); + const edges: D3Edge[] = graph.edges.map((e) => ({ + source: e.source, + target: e.target, + })); + return { nodes, edges }; + }, [graph]); + + // Handle resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDimensions({ + width: Math.max(600, rect.width), + height: Math.max(500, rect.height), + }); + } + }; + + updateDimensions(); + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, []); + + // Handle node click + const handleNodeClick = useCallback( + (nodeId: string) => { + const task = taskMap.get(nodeId); + if (task) onEditTask(task); + }, + [taskMap, onEditTask], + ); + + // D3 visualization + useEffect(() => { + if (!svgRef.current || d3Data.nodes.length === 0) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const { width, height } = dimensions; + + // Create main group for zoom/pan + 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); + + // Double-click to reset zoom + svg.on("dblclick.zoom", () => { + svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity); + }); + + // Arrow marker for edges + const defs = svg.append("defs"); + + defs + .append("marker") + .attr("id", "arrowhead") + .attr("viewBox", "-10 -5 10 10") + .attr("refX", 28) + .attr("refY", 0) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M-10,-5L0,0L-10,5") + .attr("fill", "#9ca3af"); + + // Highlighted arrow marker + defs + .append("marker") + .attr("id", "arrowhead-highlight") + .attr("viewBox", "-10 -5 10 10") + .attr("refX", 28) + .attr("refY", 0) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M-10,-5L0,0L-10,5") + .attr("fill", "#3b82f6"); + + // Create copies for simulation (D3 mutates these) + 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(-500)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("x", d3.forceX(width / 2).strength(0.05)) + .force("y", d3.forceY(height / 2).strength(0.05)) + .force("collision", d3.forceCollide().radius(40)); + + // Draw edges + const linkGroup = g.append("g").attr("class", "links"); + + const links = linkGroup + .selectAll("line") + .data(edgesCopy) + .join("line") + .attr("stroke", "#9ca3af") + .attr("stroke-width", 2) + .attr("marker-end", "url(#arrowhead)") + .style("opacity", 0.6); + + // Draw nodes + const nodeGroup = g.append("g").attr("class", "nodes"); + + const nodes = nodeGroup + .selectAll("g") + .data(nodesCopy) + .join("g") + .attr("class", "node") + .attr("cursor", "pointer"); + + // Background circle for progress animation + nodes + .append("circle") + .attr("r", 28) + .attr("fill", (d) => getNodeColor(d.status)) + .attr("stroke", (d) => (d.priority === "high" ? "#ef4444" : getNodeStrokeColor(d.status))) + .attr("stroke-width", (d) => (d.priority === "high" ? 3 : 2)) + .attr("class", "node-bg"); + + // Progress fill circle (for completed tasks) + nodes + .filter((d) => isCompleted(d.status)) + .append("circle") + .attr("r", 0) + .attr("fill", "#16a34a") + .attr("class", "progress-fill") + .transition() + .duration(800) + .ease(d3.easeElastic) + .attr("r", 24); + + // Inner circle (white/dark bg) + nodes + .filter((d) => !isCompleted(d.status)) + .append("circle") + .attr("r", 20) + .attr("fill", (d) => getNodeColor(d.status)) + .attr("opacity", 0.9); + + // Task ID label + nodes + .append("text") + .attr("text-anchor", "middle") + .attr("dy", 4) + .attr("fill", "white") + .attr("font-size", "11px") + .attr("font-weight", "bold") + .attr("pointer-events", "none") + .text((d) => d.id.replace("task-", "")); + + // Title tooltip (below node) + nodes + .append("text") + .attr("text-anchor", "middle") + .attr("dy", 45) + .attr("fill", "currentColor") + .attr("class", "text-gray-700 dark:text-gray-300") + .attr("font-size", "10px") + .attr("pointer-events", "none") + .text((d) => (d.title.length > 20 ? `${d.title.substring(0, 20)}...` : d.title)); + + // Sequence badge + nodes + .filter((d) => d.sequenceIndex !== undefined) + .append("circle") + .attr("cx", 20) + .attr("cy", -20) + .attr("r", 10) + .attr("fill", "#6366f1") + .attr("stroke", "white") + .attr("stroke-width", 1); + + nodes + .filter((d) => d.sequenceIndex !== undefined) + .append("text") + .attr("x", 20) + .attr("y", -16) + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "9px") + .attr("font-weight", "bold") + .attr("pointer-events", "none") + .text((d) => d.sequenceIndex?.toString() || ""); + + // Click handler + nodes.on("click", (event, d) => { + event.stopPropagation(); + handleNodeClick(d.id); + }); + + // Hover effects + nodes + .on("mouseenter", function (event, d) { + // Highlight this node + d3.select(this).select(".node-bg").attr("stroke-width", 4); + + // Find connected nodes + const connectedIds = new Set([d.id]); + for (const dep of d.dependencies) connectedIds.add(dep); + for (const dep of d.dependents) connectedIds.add(dep); + + // Dim non-connected nodes + nodes.style("opacity", (n) => (connectedIds.has(n.id) ? 1 : 0.3)); + + // Highlight connected edges + links.each(function (l) { + const sourceId = typeof l.source === "string" ? l.source : l.source.id; + const targetId = typeof l.target === "string" ? l.target : l.target.id; + const isConnected = sourceId === d.id || targetId === d.id; + + d3.select(this) + .style("opacity", isConnected ? 1 : 0.1) + .attr("stroke", isConnected ? "#3b82f6" : "#9ca3af") + .attr("stroke-width", isConnected ? 3 : 2) + .attr("marker-end", isConnected ? "url(#arrowhead-highlight)" : "url(#arrowhead)"); + }); + }) + .on("mouseleave", function (_, d) { + // Reset all + nodes.style("opacity", 1); + d3.select(this).select(".node-bg").attr("stroke-width", d.priority === "high" ? 3 : 2); + links + .style("opacity", 0.6) + .attr("stroke", "#9ca3af") + .attr("stroke-width", 2) + .attr("marker-end", "url(#arrowhead)"); + }); + + // 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); + // Keep node pinned where user dropped it + // To unpin, double-click (handled below) + }); + + nodes.call(drag); + + // Double-click to unpin node + nodes.on("dblclick", (event, d) => { + event.stopPropagation(); + d.fx = null; + d.fy = null; + simulation.alpha(0.3).restart(); + }); + + // Tick function + 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) { + 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, handleNodeClick]); + + // Empty state + if (tasks.length === 0) { + return ( +
+
+ + + +

No tasks to display

+

Create some tasks to see the dependency graph

+
+
+ ); + } + + // No nodes after filtering + if (graph.nodes.length === 0) { + return ( +
+
+ + + +

No tasks match filters

+

Try adjusting your filter criteria

+
+
+ ); + } + + return ( +
+ {/* Stats and controls bar */} +
+ + {graph.nodes.length} task{graph.nodes.length !== 1 ? "s" : ""} • {graph.edges.length}{" "} + dependenc{graph.edges.length !== 1 ? "ies" : "y"} + +
+ + Drag to move • Scroll to zoom • Double-click node to unpin • Double-click background to + reset + +
+
+ + {/* Graph container */} +
+ +
+ + {/* Legend */} +
+ Legend: +
+ + Done +
+
+ + In Progress +
+
+ + To Do +
+
+ + High Priority +
+
+ + 1 + + Sequence # +
+
+
+ ); +} diff --git a/src/web/types/graph.ts b/src/web/types/graph.ts new file mode 100644 index 0000000..75b2574 --- /dev/null +++ b/src/web/types/graph.ts @@ -0,0 +1,44 @@ +import type { Sequence } from "../../types"; + +/** + * Node in the dependency graph + */ +export interface GraphNode { + id: string; + title: string; + status: string; + priority?: "high" | "medium" | "low"; + labels: string[]; + dependencies: string[]; + dependents: string[]; // Tasks that depend on this one (reverse edges) + sequenceIndex?: number; // From computeSequences() +} + +/** + * Edge connecting two nodes (source depends on target, or target depends on source) + * Direction: source -> target means "target depends on source" + */ +export interface GraphEdge { + source: string; // Task ID that is depended upon + target: string; // Task ID that has the dependency +} + +/** + * Complete dependency graph structure + */ +export interface DependencyGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; + sequences: Sequence[]; + unsequenced: GraphNode[]; +} + +/** + * Filter options for the dependency graph + */ +export interface GraphFilters { + status?: string[]; + priority?: Array<"high" | "medium" | "low">; + labels?: string[]; + showCompleted?: boolean; +} diff --git a/src/web/utils/dependency-graph.ts b/src/web/utils/dependency-graph.ts new file mode 100644 index 0000000..0b7df85 --- /dev/null +++ b/src/web/utils/dependency-graph.ts @@ -0,0 +1,228 @@ +import { computeSequences } from "../../core/sequences"; +import type { Task } from "../../types"; +import type { DependencyGraph, GraphEdge, GraphFilters, GraphNode } from "../types/graph"; + +/** + * Build a dependency graph from a list of tasks + */ +export function buildDependencyGraph(tasks: Task[]): DependencyGraph { + // 1. Create node map + const nodeMap = new Map(); + for (const task of tasks) { + nodeMap.set(task.id, { + id: task.id, + title: task.title, + status: task.status, + priority: task.priority, + labels: task.labels || [], + dependencies: task.dependencies || [], + dependents: [], + }); + } + + // 2. Build dependents (reverse edges) + for (const task of tasks) { + for (const depId of task.dependencies || []) { + const depNode = nodeMap.get(depId); + if (depNode) { + depNode.dependents.push(task.id); + } + } + } + + // 3. Build edges (source -> target means target depends on source) + const edges: GraphEdge[] = []; + for (const task of tasks) { + for (const depId of task.dependencies || []) { + if (nodeMap.has(depId)) { + edges.push({ source: depId, target: task.id }); + } + } + } + + // 4. Compute sequences using existing algorithm + const { sequences, unsequenced } = computeSequences(tasks); + + // 5. Annotate nodes with sequence index + for (const seq of sequences) { + for (const task of seq.tasks) { + const node = nodeMap.get(task.id); + if (node) node.sequenceIndex = seq.index; + } + } + + // 6. Convert unsequenced tasks to graph nodes + const unsequencedNodes = unsequenced.map((t) => nodeMap.get(t.id)).filter((n): n is GraphNode => n !== undefined); + + return { + nodes: Array.from(nodeMap.values()), + edges, + sequences, + unsequenced: unsequencedNodes, + }; +} + +/** + * Filter the graph by various criteria + */ +export function filterGraph(graph: DependencyGraph, filters: GraphFilters): DependencyGraph { + const filteredNodes = graph.nodes.filter((node) => { + // Filter out completed tasks if showCompleted is false + if (!filters.showCompleted && isCompleted(node.status)) { + return false; + } + // Filter by status + if (filters.status?.length && !filters.status.includes(node.status)) { + return false; + } + // Filter by priority + if (filters.priority?.length && node.priority && !filters.priority.includes(node.priority)) { + return false; + } + // Filter by labels + if (filters.labels?.length && !filters.labels.some((l) => node.labels.includes(l))) { + return false; + } + return true; + }); + + const nodeIds = new Set(filteredNodes.map((n) => n.id)); + const filteredEdges = graph.edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)); + const filteredUnsequenced = graph.unsequenced.filter((n) => nodeIds.has(n.id)); + + return { + ...graph, + nodes: filteredNodes, + edges: filteredEdges, + unsequenced: filteredUnsequenced, + }; +} + +/** + * Check if a status indicates completion + */ +function isCompleted(status: string): boolean { + const lower = status.toLowerCase(); + return lower.includes("done") || lower.includes("complete"); +} + +/** + * Get the status class for a node (for styling) + */ +function getStatusClass(status: string): string { + const lower = status.toLowerCase(); + if (lower.includes("done") || lower.includes("complete")) return "done"; + if (lower.includes("progress")) return "inProgress"; + return "todo"; +} + +/** + * Escape text for Mermaid labels + */ +function escapeLabel(text: string): string { + return ( + text + .replace(/"/g, "'") + .replace(/\n/g, " ") + .replace(/[[\](){}]/g, "") // Remove brackets that could break mermaid + .substring(0, 35) + (text.length > 35 ? "..." : "") + ); +} + +/** + * Generate a Mermaid flowchart from the dependency graph + */ +export function generateMermaidFlowchart(graph: DependencyGraph): string { + const lines: string[] = ["flowchart TB"]; + + // Define node styles + lines.push(" %% Node Styles"); + lines.push(" classDef done fill:#22c55e,stroke:#16a34a,color:#fff"); + lines.push(" classDef inProgress fill:#3b82f6,stroke:#2563eb,color:#fff"); + lines.push(" classDef todo fill:#6b7280,stroke:#4b5563,color:#fff"); + lines.push(" classDef highPriority stroke:#ef4444,stroke-width:3px"); + lines.push(""); + + // Group by sequence for layout hints + const bySequence = new Map(); + + // First add sequenced nodes + for (const node of graph.nodes) { + if (node.sequenceIndex !== undefined) { + if (!bySequence.has(node.sequenceIndex)) bySequence.set(node.sequenceIndex, []); + bySequence.get(node.sequenceIndex)?.push(node); + } + } + + // Add unsequenced nodes separately + if (graph.unsequenced.length > 0) { + bySequence.set(undefined, graph.unsequenced); + } + + // Render nodes with subgraphs for sequences + lines.push(" %% Nodes by Sequence"); + + // Sort sequences by index + const sortedSeqKeys = Array.from(bySequence.keys()).sort((a, b) => { + if (a === undefined) return 1; + if (b === undefined) return -1; + return a - b; + }); + + for (const seqIndex of sortedSeqKeys) { + const nodes = bySequence.get(seqIndex); + if (!nodes || nodes.length === 0) continue; + + if (seqIndex !== undefined) { + lines.push(` subgraph Seq${seqIndex}["Sequence ${seqIndex}"]`); + } else { + lines.push(' subgraph Unsequenced["Unsequenced"]'); + } + + for (const node of nodes) { + const label = escapeLabel(node.title); + const shortId = node.id.replace("task-", ""); + lines.push(` ${node.id}["#${shortId}: ${label}"]`); + } + + lines.push(" end"); + lines.push(""); + } + + // Add edges + if (graph.edges.length > 0) { + lines.push(" %% Dependencies"); + for (const edge of graph.edges) { + lines.push(` ${edge.source} --> ${edge.target}`); + } + lines.push(""); + } + + // Apply status classes + lines.push(" %% Apply Status Classes"); + for (const node of graph.nodes) { + const statusClass = getStatusClass(node.status); + lines.push(` class ${node.id} ${statusClass}`); + } + + // Apply high priority styling + const highPriorityNodes = graph.nodes.filter((n) => n.priority === "high"); + if (highPriorityNodes.length > 0) { + lines.push(""); + lines.push(" %% High Priority Styling"); + for (const node of highPriorityNodes) { + lines.push(` class ${node.id} highPriority`); + } + } + + return lines.join("\n"); +} + +/** + * Extract task ID from a Mermaid node element ID + * Mermaid generates IDs like "flowchart-task-001-123" + */ +export function extractTaskIdFromNodeId(nodeId: string): string | null { + const match = nodeId.match(/(task-\d+)/); + return match?.[1] ?? null; +}