Add dependency visualization dashboard with D3.js

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-08 12:12:05 +01:00
parent 2bb372b806
commit 47b8bdfd93
10 changed files with 1584 additions and 20 deletions

View File

@ -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",

View File

@ -93,5 +93,9 @@
"trustedDependencies": [
"@biomejs/biome",
"node-pty"
]
],
"dependencies": {
"@types/d3": "^7.4.3",
"d3": "^7.9.0"
}
}

View File

@ -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> = {}): 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();
});
});

View File

@ -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<string | null>(null);
// View mode state (synced with URL)
const [activeView, setActiveView] = useState<ViewMode>(
(searchParams.get("view") as ViewMode) || "kanban",
);
// Filter state for dependency view
const [filters, setFilters] = useState<GraphFilters>({
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 (
<div className="container mx-auto px-4 py-8 transition-colors duration-200">
<Board
onEditTask={handleEditTask}
onNewTask={onNewTask}
highlightTaskId={highlightTaskId}
tasks={tasks}
onRefreshData={onRefreshData}
statuses={statuses}
isLoading={isLoading}
/>
{/* Tab Navigation */}
<BoardTabs activeView={activeView} onViewChange={handleViewChange} />
{/* View Content */}
{activeView === "kanban" ? (
<Board
onEditTask={handleEditTask}
onNewTask={onNewTask}
highlightTaskId={highlightTaskId}
tasks={tasks}
onRefreshData={onRefreshData}
statuses={statuses}
isLoading={isLoading}
/>
) : (
<div className="space-y-4">
{/* Header with filters */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Dependency Graph</h2>
<DependencyFilters
filters={filters}
onChange={setFilters}
statuses={statuses}
availableLabels={availableLabels}
/>
</div>
{/* Dependency Visualization (D3.js) */}
<DependencyViewD3 tasks={tasks} onEditTask={handleEditTask} filters={filters} />
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
type="button"
onClick={() => onViewChange("kanban")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeView === "kanban"
? "border-blue-500 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
/>
</svg>
Kanban Board
</span>
</button>
<button
type="button"
onClick={() => onViewChange("dependencies")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeView === "dependencies"
? "border-blue-500 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
<span className="flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
Dependencies
</span>
</button>
</div>
);
}

View File

@ -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<HTMLInputElement>) => {
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 (
<div className="flex flex-wrap items-center gap-4 text-sm">
{/* Show Completed Toggle */}
<label className="flex items-center gap-2 text-gray-600 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={filters.showCompleted || false}
onChange={handleShowCompletedChange}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
Show completed
</label>
{/* Status Filter Dropdown */}
<div className="relative group">
<button
type="button"
className="flex items-center gap-1 px-3 py-1.5 text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Status
{filters.status && filters.status.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
{filters.status.length}
</span>
)}
</button>
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 hidden group-hover:block z-10">
<div className="p-2 space-y-1">
{statuses.map((status) => (
<label
key={status}
className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
>
<input
type="checkbox"
checked={filters.status?.includes(status) || false}
onChange={() => handleStatusChange(status)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span className="text-gray-700 dark:text-gray-300">{status}</span>
</label>
))}
</div>
</div>
</div>
{/* Priority Filter Dropdown */}
<div className="relative group">
<button
type="button"
className="flex items-center gap-1 px-3 py-1.5 text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
Priority
{filters.priority && filters.priority.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
{filters.priority.length}
</span>
)}
</button>
<div className="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 hidden group-hover:block z-10">
<div className="p-2 space-y-1">
{(["high", "medium", "low"] as const).map((priority) => (
<label
key={priority}
className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
>
<input
type="checkbox"
checked={filters.priority?.includes(priority) || false}
onChange={() => handlePriorityChange(priority)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span
className={`text-gray-700 dark:text-gray-300 capitalize ${
priority === "high"
? "text-red-600 dark:text-red-400"
: priority === "medium"
? "text-yellow-600 dark:text-yellow-400"
: "text-green-600 dark:text-green-400"
}`}
>
{priority}
</span>
</label>
))}
</div>
</div>
</div>
{/* Clear Filters */}
{(filters.status?.length ||
filters.priority?.length ||
filters.labels?.length ||
filters.showCompleted) && (
<button
type="button"
onClick={() =>
onChange({ status: undefined, priority: undefined, labels: undefined, showCompleted: false })
}
className="px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Clear
</button>
)}
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<p className="text-lg font-medium">No tasks to display</p>
<p className="text-sm mt-1">Create some tasks to see the dependency graph</p>
</div>
</div>
);
}
// No nodes after filtering
if (nodeCount === 0) {
return (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<p className="text-lg font-medium">No tasks match filters</p>
<p className="text-sm mt-1">Try adjusting your filter criteria</p>
</div>
</div>
);
}
return (
<div className="relative">
{/* Stats bar */}
<div className="flex items-center justify-between mb-2 text-sm text-gray-500 dark:text-gray-400">
<span>
{nodeCount} task{nodeCount !== 1 ? "s" : ""} {edgeCount} dependenc{edgeCount !== 1 ? "ies" : "y"}
</span>
<span className="text-xs">Click a task to view details Hover to highlight dependencies</span>
</div>
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-800/50 z-10">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Loading graph...</span>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
<p className="font-medium">Failed to render graph</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{/* Graph container */}
<div
className="w-full overflow-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
style={{ minHeight: "500px" }}
>
<div
ref={containerRef}
className="min-h-[500px] p-4 dependency-graph-container"
style={{ minWidth: "100%" }}
/>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 mt-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">Legend:</span>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#22c55e" }} />
<span className="text-gray-600 dark:text-gray-300">Done</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#3b82f6" }} />
<span className="text-gray-600 dark:text-gray-300">In Progress</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#6b7280" }} />
<span className="text-gray-600 dark:text-gray-300">To Do</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded border-2" style={{ borderColor: "#ef4444" }} />
<span className="text-gray-600 dark:text-gray-300">High Priority</span>
</div>
</div>
</div>
);
}
/**
* Highlight the dependency path for a task (its dependencies and dependents)
*/
function highlightDependencyPath(svg: SVGElement, taskId: string, taskMap: Map<string, Task>): void {
const task = taskMap.get(taskId);
if (!task) return;
// Build set of connected task IDs
const connectedIds = new Set<string>([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 = "";
}
}
}

View File

@ -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<D3Node> {
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<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(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<SVGSVGElement, unknown>()
.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<D3Node>(nodesCopy)
.force(
"link",
d3
.forceLink<D3Node, D3Edge>(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<SVGGElement, D3Node>("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<string>([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<SVGGElement, D3Node>()
.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 (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg
className="w-16 h-16 mx-auto mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<p className="text-lg font-medium">No tasks to display</p>
<p className="text-sm mt-1">Create some tasks to see the dependency graph</p>
</div>
</div>
);
}
// No nodes after filtering
if (graph.nodes.length === 0) {
return (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg
className="w-16 h-16 mx-auto mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<p className="text-lg font-medium">No tasks match filters</p>
<p className="text-sm mt-1">Try adjusting your filter criteria</p>
</div>
</div>
);
}
return (
<div className="relative">
{/* Stats and controls bar */}
<div className="flex items-center justify-between mb-2 text-sm text-gray-500 dark:text-gray-400">
<span>
{graph.nodes.length} task{graph.nodes.length !== 1 ? "s" : ""} {graph.edges.length}{" "}
dependenc{graph.edges.length !== 1 ? "ies" : "y"}
</span>
<div className="flex items-center gap-4">
<span className="text-xs">
Drag to move Scroll to zoom Double-click node to unpin Double-click background to
reset
</span>
</div>
</div>
{/* Graph container */}
<div
ref={containerRef}
className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
style={{ height: "600px" }}
>
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="dependency-graph-d3"
style={{ display: "block" }}
/>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 mt-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">Legend:</span>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded-full" style={{ backgroundColor: "#22c55e" }} />
<span className="text-gray-600 dark:text-gray-300">Done</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded-full" style={{ backgroundColor: "#3b82f6" }} />
<span className="text-gray-600 dark:text-gray-300">In Progress</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded-full" style={{ backgroundColor: "#6b7280" }} />
<span className="text-gray-600 dark:text-gray-300">To Do</span>
</div>
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full border-2"
style={{ borderColor: "#ef4444", backgroundColor: "#6b7280" }}
/>
<span className="text-gray-600 dark:text-gray-300">High Priority</span>
</div>
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full text-white text-xs flex items-center justify-center font-bold"
style={{ backgroundColor: "#6366f1" }}
>
1
</span>
<span className="text-gray-600 dark:text-gray-300">Sequence #</span>
</div>
</div>
</div>
);
}

44
src/web/types/graph.ts Normal file
View File

@ -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;
}

View File

@ -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<string, GraphNode>();
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<number | undefined, GraphNode[]>();
// 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;
}