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:
parent
a7ca81e1cd
commit
36b35e3cbe
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue