Compare commits

...

12 Commits

Author SHA1 Message Date
Jeff Emmett a7ca81e1cd Add Dependencies tab to aggregator UI
- Add D3.js dependency graph visualization to backlog.jeffemmett.com
- Tab switcher between Kanban and Dependencies views
- Show completed toggle filter
- Force-directed graph layout with pan/zoom and drag support
- Color-coded nodes by status (green=done, blue=in-progress, gray=todo)
- Project-specific border colors
- Click to edit task, hover to highlight connections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:22:51 +01:00
Jeff Emmett 47b8bdfd93 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>
2026-01-08 12:12:05 +01:00
Jeff Emmett 2bb372b806 Update task task-003 2026-01-02 20:25:18 +01:00
Jeff Emmett 19fba96cf2 Create task task-008 2026-01-02 14:53:49 +01:00
Jeff Emmett 80326a7ec6 backlog: Archive task task-008 2025-12-26 23:23:29 -05:00
Jeff Emmett 805e63f01f Create task task-008 2025-12-26 23:20:20 -05:00
Jeff Emmett bf0131f886 Update task task-007 2025-12-26 23:18:35 -05:00
Jeff Emmett 4c255d6440 Update task task-006 2025-12-26 23:18:35 -05:00
Jeff Emmett a65dd62f49 Create task task-007 2025-12-26 23:09:29 -05:00
Jeff Emmett 65e5ba570c Create task task-006 2025-12-26 23:09:22 -05:00
Jeff Emmett a8fd547776 Create task task-005 2025-12-26 22:56:17 -05:00
Jeff Emmett 04771d6a28 Create task task-004 2025-12-26 22:56:17 -05:00
18 changed files with 2162 additions and 93 deletions

View File

@ -0,0 +1,29 @@
---
id: task-008
title: Redesign time morphism display and add possibility cones view
status: To Do
assignee: []
created_date: '2025-12-27 04:20'
labels:
- calendar
- ui
- time-morphisms
- visualization
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Improve the visual representation of time morphisms beyond simple zoom in/out arrows. Explore alternative UI patterns (gestures, radial menus, fluid transitions, etc.) that better convey the concept of moving between temporal scales. Additionally, implement a "possibility cones" view that visualizes potential futures branching from the present moment.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Replace zoom arrows with more intuitive time morphism controls
- [ ] #2 Time scale transitions feel fluid and conceptually coherent
- [ ] #3 Possibility cones view renders future branches from current moment
- [ ] #4 Cones can be expanded/collapsed to explore potential timelines
- [ ] #5 Visual language consistent with decolonized time philosophy
<!-- AC:END -->

View File

@ -1,10 +1,10 @@
---
id: task-003
title: 'Attention Pipeline: Workload visualization for upcoming work'
status: To Do
status: Done
assignee: []
created_date: '2025-12-26 01:57'
updated_date: '2025-12-26 01:57'
updated_date: '2026-01-02 19:25'
labels:
- feature
- dashboard
@ -12,7 +12,6 @@ labels:
- ux
dependencies: []
priority: high
estimated_hours: 16
---
## Description
@ -61,10 +60,23 @@ Build a workload view using existing fields (no schema changes):
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 New Workload tab visible in web dashboard
- [ ] #2 Tasks grouped by Today/This Week/Next 2 Weeks/Backlog
- [ ] #3 Total estimated hours shown per group
- [x] #1 New Workload tab visible in web dashboard
- [x] #2 Tasks grouped by Today/This Week/Next 2 Weeks/Backlog
- [x] #3 Total estimated hours shown per group
- [ ] #4 Capacity setting with runway calculation
- [ ] #5 Click task to open details modal
- [ ] #6 Responsive design (mobile-friendly)
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented three FolkJS components:
- folk-embed.ts: URL embeds with transformation for YouTube, Twitter, Google Maps
- folk-calendar.ts: Month view calendar with events
- folk-map.ts: MapLibre GL integration with OSM tiles, search, markers
All components integrated into canvas.html with toolbar buttons and createShapeElement handlers.
Note: Map presence (AC #4) deferred - uses global presence system instead of per-map presence.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,29 @@
---
id: task-004
title: Deploy Mapbox as a 3D mapping primitive
status: To Do
assignee: []
created_date: '2025-12-27 03:56'
labels:
- mapping
- 3d
- infrastructure
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Integrate Mapbox GL JS as a 3D mapping component that can be used as a foundational primitive across projects. This should support terrain visualization, 3D buildings, custom markers, and smooth camera controls for immersive geographic experiences.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Mapbox GL JS integrated and rendering 3D terrain
- [ ] #2 Custom marker/pin support with popup interactions
- [ ] #3 3D building extrusions enabled for urban areas
- [ ] #4 Smooth fly-to and orbit camera animations working
- [ ] #5 Component is reusable as a primitive in other projects
- [ ] #6 Basic styling/theming customization available
<!-- AC:END -->

View File

@ -0,0 +1,30 @@
---
id: task-005
title: Build AI-assisted mind mapping tool with live voice input
status: To Do
assignee: []
created_date: '2025-12-27 03:56'
labels:
- ai
- voice
- mind-mapping
- tools
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create an AI-powered mind mapping application that can accept live voice input for hands-free ideation. The AI should help organize thoughts, suggest connections between concepts, and auto-structure the mind map as users speak. Could integrate with Whisper for transcription and an LLM for semantic understanding and node suggestions.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Real-time voice transcription via Whisper or Web Speech API
- [ ] #2 Live mind map node creation from spoken ideas
- [ ] #3 AI suggests connections between related concepts
- [ ] #4 Auto-organization/layout of nodes as map grows
- [ ] #5 Manual editing and refinement of AI suggestions
- [ ] #6 Export mind map to common formats (JSON, Markdown, image)
<!-- AC:END -->

View File

@ -0,0 +1,28 @@
---
id: task-006
title: Gmail cleanup automation
status: To Do
assignee: []
created_date: '2025-12-27 04:09'
updated_date: '2025-12-27 04:18'
labels:
- dev-ops
- automation
- email
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build or configure automated Gmail cleanup tooling to manage inbox clutter. Could involve filters, bulk deletion scripts, unsubscribe automation, or integration with Gmail API for programmatic inbox management.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Identify and categorize email clutter sources
- [ ] #2 Bulk archive/delete old or unwanted emails
- [ ] #3 Set up filters for ongoing inbox hygiene
- [ ] #4 Unsubscribe from unnecessary mailing lists
<!-- AC:END -->

View File

@ -0,0 +1,29 @@
---
id: task-007
title: Empty and clean up storage
status: To Do
assignee: []
created_date: '2025-12-27 04:09'
updated_date: '2025-12-27 04:18'
labels:
- dev-ops
- cleanup
- storage
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Audit and clean up storage across services (Google Drive, local drives, server storage, cloud buckets). Identify large files, duplicates, and unused data that can be archived or deleted to reclaim space.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Audit storage usage across all services
- [ ] #2 Identify and remove duplicate files
- [ ] #3 Archive or delete large unused files
- [ ] #4 Empty trash/bin across all platforms
- [ ] #5 Document what was cleaned and space reclaimed
<!-- AC:END -->

View File

@ -0,0 +1,28 @@
---
id: task-008
title: 'Add [rS] favicon and fix shape interactions'
status: Done
assignee: []
created_date: '2026-01-02 13:53'
labels:
- ui
- branding
- bugfix
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Generated and deployed [rS] branded favicon (orange r, teal S, white square brackets on navy background). Added favicon to both rSpace-website landing page and rspace-online canvas. Fixed shape dragging by allowing drag from header elements with .header class or data-drag attribute.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Favicon displays [rS] with correct colors
- [ ] #2 Favicon deployed to rspace.online and subdomains
- [ ] #3 Logo appears in hero and footer on landing page
- [ ] #4 Shapes can be dragged by their header bars
- [ ] #5 Shapes can be resized via corner handles when focused
<!-- AC:END -->

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

@ -1,6 +1,9 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import * as d3 from "d3";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createRoot } from "react-dom/client";
type ViewMode = "kanban" | "dependencies";
interface Project {
path: string;
name: string;
@ -39,6 +42,8 @@ function App() {
const [actionError, setActionError] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<AggregatedTask | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("kanban");
const [showCompleted, setShowCompleted] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -251,6 +256,51 @@ function App() {
>
{connected ? "Live" : "Reconnecting..."}
</span>
{/* View Mode Tabs */}
<div style={{ display: "flex", gap: "0.25rem", marginLeft: "1rem" }}>
<button
onClick={() => setViewMode("kanban")}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: viewMode === "kanban" ? "#3b82f6" : "#334155",
color: "#e2e8f0",
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<svg width="16" height="16" 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
</button>
<button
onClick={() => setViewMode("dependencies")}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: viewMode === "dependencies" ? "#3b82f6" : "#334155",
color: "#e2e8f0",
cursor: "pointer",
fontSize: "0.875rem",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<svg width="16" height="16" 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
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
{actionError && (
@ -340,83 +390,108 @@ function App() {
))}
</div>
{/* Kanban Board */}
<div
style={{
padding: "2rem",
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1.5rem",
minHeight: "calc(100vh - 180px)",
}}
>
{statuses.map((status) => {
const isDropTarget = dragOverColumn === status && draggedTask?.status !== status;
return (
<div
key={status}
onDragOver={(e) => handleColumnDragOver(e, status)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, status)}
style={{
backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b",
borderRadius: "0.5rem",
padding: "1rem",
display: "flex",
flexDirection: "column",
border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent",
transition: "all 0.2s ease",
}}
>
{/* Column Header */}
{/* View Content */}
{viewMode === "kanban" ? (
/* Kanban Board */
<div
style={{
padding: "2rem",
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1.5rem",
minHeight: "calc(100vh - 180px)",
}}
>
{statuses.map((status) => {
const isDropTarget = dragOverColumn === status && draggedTask?.status !== status;
return (
<div
key={status}
onDragOver={(e) => handleColumnDragOver(e, status)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, status)}
style={{
backgroundColor: isDropTarget ? "#1e3a5f" : "#1e293b",
borderRadius: "0.5rem",
padding: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
paddingBottom: "0.5rem",
borderBottom: "1px solid #334155",
flexDirection: "column",
border: isDropTarget ? "2px dashed #3b82f6" : "2px solid transparent",
transition: "all 0.2s ease",
}}
>
<h2 style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}>{status}</h2>
<span
{/* Column Header */}
<div
style={{
backgroundColor: "#334155",
padding: "0.25rem 0.5rem",
borderRadius: "9999px",
fontSize: "0.75rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
paddingBottom: "0.5rem",
borderBottom: "1px solid #334155",
}}
>
{tasksByStatus[status]?.length || 0}
</span>
</div>
<h2 style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}>{status}</h2>
<span
style={{
backgroundColor: "#334155",
padding: "0.25rem 0.5rem",
borderRadius: "9999px",
fontSize: "0.75rem",
}}
>
{tasksByStatus[status]?.length || 0}
</span>
</div>
{/* Tasks */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
{tasksByStatus[status]?.map((task) => (
<TaskCard
key={`${task.projectPath}:${task.id}`}
task={task}
onArchive={handleArchiveTask}
onDelete={handleDeleteTask}
onUpdate={handleUpdateTask}
statuses={statuses}
onDragStart={handleTaskDragStart}
onDragEnd={handleTaskDragEnd}
isDragging={draggedTask?.id === task.id && draggedTask?.projectPath === task.projectPath}
/>
))}
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
{draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"}
</div>
)}
{/* Tasks */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
{tasksByStatus[status]?.map((task) => (
<TaskCard
key={`${task.projectPath}:${task.id}`}
task={task}
onArchive={handleArchiveTask}
onDelete={handleDeleteTask}
onUpdate={handleUpdateTask}
statuses={statuses}
onDragStart={handleTaskDragStart}
onDragEnd={handleTaskDragEnd}
isDragging={draggedTask?.id === task.id && draggedTask?.projectPath === task.projectPath}
/>
))}
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
{draggedTask && draggedTask.status !== status ? "Drop here to move task" : "No tasks"}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* Dependency Graph View */
<div style={{ padding: "2rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", color: "#94a3b8", cursor: "pointer" }}>
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)}
style={{ width: "1rem", height: "1rem" }}
/>
Show completed tasks
</label>
</div>
);
})}
</div>
<DependencyGraph
tasks={tasks}
projects={projects}
selectedProject={selectedProject}
filter={filter}
showCompleted={showCompleted}
/>
</div>
)}
</div>
);
}
@ -763,6 +838,251 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses, onDragStart,
);
}
// D3 Dependency Graph Component
interface DependencyGraphProps {
tasks: AggregatedTask[];
projects: Project[];
selectedProject: string | null;
filter: string;
showCompleted: boolean;
}
interface D3Node extends d3.SimulationNodeDatum {
id: string;
title: string;
status: string;
priority?: string;
projectName: string;
projectColor: string;
}
interface D3Edge extends d3.SimulationLinkDatum<D3Node> {
source: string | D3Node;
target: string | D3Node;
}
function getNodeColor(status: string): string {
const lower = status.toLowerCase();
if (lower.includes("done") || lower.includes("complete")) return "#22c55e";
if (lower.includes("progress")) return "#3b82f6";
return "#6b7280";
}
function isCompleted(status: string): boolean {
const lower = status.toLowerCase();
return lower.includes("done") || lower.includes("complete");
}
function DependencyGraph({ tasks, projects, selectedProject, filter, showCompleted }: DependencyGraphProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Filter tasks
const filteredTasks = useMemo(() => {
return tasks.filter((t) => {
if (!showCompleted && isCompleted(t.status)) return false;
if (selectedProject && t.projectName !== selectedProject) return false;
if (filter && !t.title.toLowerCase().includes(filter.toLowerCase())) return false;
return true;
});
}, [tasks, showCompleted, selectedProject, filter]);
// Build graph data - note: aggregated tasks don't have dependencies in YAML
// So we'll just show all tasks as nodes without edges for now
const d3Data = useMemo(() => {
const nodes: D3Node[] = filteredTasks.map((t) => ({
id: `${t.projectPath}:${t.id}`,
title: t.title,
status: t.status,
priority: t.priority,
projectName: t.projectName,
projectColor: t.projectColor,
}));
// No edges since aggregated tasks don't track dependencies across projects
const edges: D3Edge[] = [];
return { nodes, edges };
}, [filteredTasks]);
// Handle resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDimensions({
width: Math.max(600, rect.width),
height: Math.max(500, 600),
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
// D3 visualization
useEffect(() => {
if (!svgRef.current || d3Data.nodes.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const { width, height } = dimensions;
const g = svg.append("g").attr("class", "graph-container");
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on("zoom", (event) => g.attr("transform", event.transform));
svg.call(zoom);
svg.on("dblclick.zoom", () => svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity));
// Create copies for simulation
const nodesCopy: D3Node[] = d3Data.nodes.map((d) => ({ ...d }));
const edgesCopy: D3Edge[] = d3Data.edges.map((d) => ({ ...d }));
// Force simulation
const simulation = d3.forceSimulation<D3Node>(nodesCopy)
.force("link", d3.forceLink<D3Node, D3Edge>(edgesCopy).id((d) => d.id).distance(150))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(45));
// Draw edges
const links = g.append("g").attr("class", "links")
.selectAll("line").data(edgesCopy).join("line")
.attr("stroke", "#9ca3af").attr("stroke-width", 2).style("opacity", 0.6);
// Draw nodes
const nodes = g.append("g").attr("class", "nodes")
.selectAll<SVGGElement, D3Node>("g").data(nodesCopy).join("g")
.attr("class", "node").attr("cursor", "grab");
// Node circles with project color border
nodes.append("circle")
.attr("r", 28)
.attr("fill", (d) => getNodeColor(d.status))
.attr("stroke", (d) => d.projectColor)
.attr("stroke-width", 3);
// Progress fill for completed
nodes.filter((d) => isCompleted(d.status))
.append("circle").attr("r", 0).attr("fill", "#16a34a")
.transition().duration(800).ease(d3.easeElastic).attr("r", 24);
// Task ID label
nodes.append("text")
.attr("text-anchor", "middle").attr("dy", 4)
.attr("fill", "white").attr("font-size", "10px").attr("font-weight", "bold")
.attr("pointer-events", "none")
.text((d) => d.id.split(":").pop()?.replace("task-", "") || "");
// Title below node
nodes.append("text")
.attr("text-anchor", "middle").attr("dy", 45)
.attr("fill", "#94a3b8").attr("font-size", "9px")
.attr("pointer-events", "none")
.text((d) => d.title.length > 18 ? `${d.title.substring(0, 18)}...` : d.title);
// Project name above node
nodes.append("text")
.attr("text-anchor", "middle").attr("dy", -35)
.attr("fill", (d) => d.projectColor).attr("font-size", "8px").attr("font-weight", "bold")
.attr("pointer-events", "none")
.text((d) => d.projectName);
// Drag behavior
const drag = d3.drag<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);
});
nodes.call(drag);
// Double-click to unpin
nodes.on("dblclick", (event, d) => {
event.stopPropagation();
d.fx = null; d.fy = null;
simulation.alpha(0.3).restart();
});
// Hover effects
nodes.on("mouseenter", function() {
d3.select(this).select("circle").attr("stroke-width", 5);
}).on("mouseleave", function() {
d3.select(this).select("circle").attr("stroke-width", 3);
});
// Tick
simulation.on("tick", () => {
links
.attr("x1", (d) => (d.source as D3Node).x || 0)
.attr("y1", (d) => (d.source as D3Node).y || 0)
.attr("x2", (d) => (d.target as D3Node).x || 0)
.attr("y2", (d) => (d.target as D3Node).y || 0);
nodes.attr("transform", (d) => `translate(${d.x || 0},${d.y || 0})`);
});
// Initial zoom to fit
setTimeout(() => {
const bounds = (g.node() as SVGGElement)?.getBBox();
if (bounds && bounds.width > 0) {
const scale = Math.min(0.9, Math.min(width / (bounds.width + 100), height / (bounds.height + 100)));
const translateX = width / 2 - (bounds.x + bounds.width / 2) * scale;
const translateY = height / 2 - (bounds.y + bounds.height / 2) * scale;
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale));
}
}, 500);
return () => { simulation.stop(); };
}, [d3Data, dimensions]);
if (filteredTasks.length === 0) {
return (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
height: "400px", backgroundColor: "#1e293b", borderRadius: "0.5rem",
color: "#64748b", fontSize: "1rem"
}}>
No tasks to display. Try adjusting your filters.
</div>
);
}
return (
<div>
<div style={{ marginBottom: "0.5rem", color: "#94a3b8", fontSize: "0.875rem" }}>
{filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} Drag to move Scroll to zoom Double-click to unpin
</div>
<div ref={containerRef} style={{ backgroundColor: "#1e293b", borderRadius: "0.5rem", overflow: "hidden" }}>
<svg ref={svgRef} width={dimensions.width} height={dimensions.height} style={{ display: "block" }} />
</div>
{/* Legend */}
<div style={{ display: "flex", gap: "1.5rem", marginTop: "1rem", fontSize: "0.75rem", color: "#94a3b8" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ width: "12px", height: "12px", borderRadius: "50%", backgroundColor: "#22c55e" }} />
Done
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ width: "12px", height: "12px", borderRadius: "50%", backgroundColor: "#3b82f6" }} />
In Progress
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ width: "12px", height: "12px", borderRadius: "50%", backgroundColor: "#6b7280" }} />
To Do
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
Border color = Project
</div>
</div>
</div>
);
}
// Mount the app
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

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