Add Dependencies tab to aggregator static HTML

- Add Kanban/Dependencies tab switcher in header
- Add D3.js force-directed graph visualization
- Show completed toggle filter
- Color-coded nodes by status (green=done, blue=in-progress, gray=todo)
- Project-specific border colors
- Drag nodes, pan/zoom, hover to highlight connections
- Click node for task details

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-08 13:17:20 +01:00
parent a7ca81e1cd
commit 36b35e3cbe
1 changed files with 382 additions and 0 deletions

View File

@ -391,6 +391,103 @@
color: #e2e8f0;
min-width: 60px;
}
/* View Tabs */
.view-tabs {
display: flex;
gap: 0.25rem;
margin-left: 1rem;
}
.view-tab {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
background: #334155;
color: #e2e8f0;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.view-tab:hover { background: #475569; }
.view-tab.active { background: #3b82f6; }
/* Dependency Graph */
.dependency-container {
padding: 1rem 2rem;
display: none;
}
.dependency-container.visible { display: block; }
.dependency-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.dependency-header h2 { font-size: 1.5rem; font-weight: 600; }
.dependency-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #94a3b8;
}
.toggle-switch {
position: relative;
width: 40px;
height: 20px;
background: #334155;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.toggle-switch.active { background: #3b82f6; }
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch.active::after { transform: translateX(20px); }
#dependency-graph {
background: #1e293b;
border-radius: 0.5rem;
overflow: hidden;
min-height: 500px;
}
.graph-legend {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
font-size: 0.75rem;
color: #94a3b8;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.graph-hint {
margin-top: 0.5rem;
color: #64748b;
font-size: 0.8rem;
}
</style>
</head>
<body>
@ -398,6 +495,21 @@
<div class="header-left">
<h1>Backlog Aggregator</h1>
<span id="status-badge" class="status-badge">Connecting...</span>
<!-- View Mode Tabs -->
<div class="view-tabs">
<button class="view-tab active" id="view-kanban" data-view="kanban">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="view-tab" id="view-dependencies" data-view="dependencies">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="stats" id="stats">Loading...</div>
</header>
@ -469,6 +581,27 @@
</div>
</div>
<!-- Dependency Graph View -->
<div class="dependency-container" id="dependency-view">
<div class="dependency-header">
<h2>Dependency Graph</h2>
<div class="dependency-controls">
<div class="toggle-container">
<span>Show Completed</span>
<div class="toggle-switch" id="toggle-completed"></div>
</div>
</div>
</div>
<div id="dependency-graph"></div>
<div class="graph-legend">
<div class="legend-item"><span class="legend-dot" style="background:#22c55e"></span> Done</div>
<div class="legend-item"><span class="legend-dot" style="background:#3b82f6"></span> In Progress</div>
<div class="legend-item"><span class="legend-dot" style="background:#6b7280"></span> To Do</div>
<div class="legend-item"><span class="legend-dot" style="background:transparent;border:2px solid #94a3b8"></span> Border = Project</div>
</div>
<div class="graph-hint">Drag nodes to reposition • Double-click to unpin • Scroll to zoom • Click node for details</div>
</div>
<!-- New Task Modal -->
<div class="modal-overlay" id="new-task-modal">
<div class="modal">
@ -521,12 +654,18 @@
<!-- Toast container -->
<div class="toast-container" id="toast-container"></div>
<!-- D3.js for dependency graph -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
let projects = [];
let tasks = [];
let selectedProject = null;
let ws = null;
let draggedTask = null;
let currentView = 'kanban';
let showCompleted = false;
let graphSimulation = null;
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
@ -1188,6 +1327,249 @@
}
};
// View Tab Switching
function switchView(view) {
currentView = view;
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
document.querySelector(`[data-view="${view}"]`).classList.add('active');
if (view === 'kanban') {
document.getElementById('board').style.display = 'grid';
document.getElementById('filter-bar').style.display = 'flex';
document.getElementById('stats-panel').style.display = document.getElementById('stats-panel').classList.contains('visible') ? 'block' : 'none';
document.getElementById('dependency-view').classList.remove('visible');
} else {
document.getElementById('board').style.display = 'none';
document.getElementById('filter-bar').style.display = 'none';
document.getElementById('stats-panel').style.display = 'none';
document.getElementById('dependency-view').classList.add('visible');
renderDependencyGraph();
}
}
document.getElementById('view-kanban').onclick = () => switchView('kanban');
document.getElementById('view-dependencies').onclick = () => switchView('dependencies');
// Show Completed Toggle
document.getElementById('toggle-completed').onclick = () => {
showCompleted = !showCompleted;
document.getElementById('toggle-completed').classList.toggle('active', showCompleted);
if (currentView === 'dependencies') renderDependencyGraph();
};
// Dependency Graph Rendering with D3
function getStatusColor(status) {
const s = status.toLowerCase();
if (s.includes('done') || s.includes('complete')) return '#22c55e';
if (s.includes('progress')) return '#3b82f6';
return '#6b7280';
}
function renderDependencyGraph() {
const container = document.getElementById('dependency-graph');
container.innerHTML = '';
// Filter tasks
let filteredTasks = selectedProject
? tasks.filter(t => t.projectName === selectedProject)
: tasks;
if (!showCompleted) {
filteredTasks = filteredTasks.filter(t => {
const s = t.status.toLowerCase();
return !s.includes('done') && !s.includes('complete');
});
}
// Get tasks with dependencies
const taskIds = new Set(filteredTasks.map(t => t.id));
const tasksWithDeps = filteredTasks.filter(t =>
(t.dependencies && t.dependencies.length > 0) ||
filteredTasks.some(other => other.dependencies && other.dependencies.includes(t.id))
);
if (tasksWithDeps.length === 0) {
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:400px;color:#64748b;font-size:1rem;">No tasks with dependencies to display.</div>';
return;
}
// Build nodes and links
const nodes = tasksWithDeps.map(t => ({
id: t.id,
title: t.title,
status: t.status,
projectName: t.projectName,
projectColor: t.projectColor,
projectPath: t.projectPath,
priority: t.priority
}));
const nodeIds = new Set(nodes.map(n => n.id));
const links = [];
tasksWithDeps.forEach(t => {
if (t.dependencies) {
t.dependencies.forEach(depId => {
if (nodeIds.has(depId)) {
links.push({ source: depId, target: t.id });
}
});
}
});
// Set up SVG
const width = container.clientWidth || 800;
const height = 500;
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height);
// Add zoom behavior
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => g.attr('transform', event.transform));
svg.call(zoom);
// Arrow marker for edges
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '-0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.append('path')
.attr('d', 'M 0,-5 L 10,0 L 0,5')
.attr('fill', '#64748b');
// Create force simulation
if (graphSimulation) graphSimulation.stop();
graphSimulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).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(60));
// Draw links
const link = g.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', '#64748b')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead)');
// Draw nodes
const node = g.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('cursor', 'grab')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// Node circles
node.append('circle')
.attr('r', 20)
.attr('fill', d => getStatusColor(d.status))
.attr('stroke', d => d.projectColor)
.attr('stroke-width', 3);
// High priority indicator
node.filter(d => d.priority === 'high')
.append('circle')
.attr('r', 24)
.attr('fill', 'none')
.attr('stroke', '#ef4444')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '4,2');
// Task ID text
node.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('fill', 'white')
.attr('font-size', '10px')
.attr('font-weight', 'bold')
.text(d => d.id.replace('task-', ''));
// Title below node
node.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '35px')
.attr('fill', '#94a3b8')
.attr('font-size', '11px')
.text(d => d.title.length > 20 ? d.title.slice(0, 18) + '...' : d.title);
// Click to show task details
node.on('click', (event, d) => {
event.stopPropagation();
showToast(`${d.id}: ${d.title} (${d.projectName})`, 'info');
});
// Double-click to unpin
node.on('dblclick', (event, d) => {
d.fx = null;
d.fy = null;
});
// Hover effects
node.on('mouseenter', function(event, d) {
d3.select(this).select('circle').transition().duration(150).attr('r', 25);
// Highlight connected links
link.attr('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#3b82f6' : '#64748b')
.attr('stroke-width', l => (l.source.id === d.id || l.target.id === d.id) ? 3 : 2);
});
node.on('mouseleave', function(event, d) {
d3.select(this).select('circle').transition().duration(150).attr('r', 20);
link.attr('stroke', '#64748b').attr('stroke-width', 2);
});
// Update positions on tick
graphSimulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Drag functions
function dragstarted(event, d) {
if (!event.active) graphSimulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) graphSimulation.alphaTarget(0);
// Keep pinned after drag
}
// Initial zoom to fit
setTimeout(() => {
const bounds = g.node().getBBox();
if (bounds.width > 0) {
const scale = Math.min(0.9, Math.min(width / (bounds.width + 100), height / (bounds.height + 100)));
const tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
const ty = height / 2 - (bounds.y + bounds.height / 2) * scale;
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
}, 500);
}
// Initial connection
connectWebSocket();
</script>