Compare commits
12 Commits
5f0423888b
...
a7ca81e1cd
| Author | SHA1 | Date |
|---|---|---|
|
|
a7ca81e1cd | |
|
|
47b8bdfd93 | |
|
|
2bb372b806 | |
|
|
19fba96cf2 | |
|
|
80326a7ec6 | |
|
|
805e63f01f | |
|
|
bf0131f886 | |
|
|
4c255d6440 | |
|
|
a65dd62f49 | |
|
|
65e5ba570c | |
|
|
a8fd547776 | |
|
|
04771d6a28 |
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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