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:
parent
2bb372b806
commit
47b8bdfd93
4
bun.lock
4
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",
|
||||
|
|
|
|||
|
|
@ -93,5 +93,9 @@
|
|||
"trustedDependencies": [
|
||||
"@biomejs/biome",
|
||||
"node-pty"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue