init
This commit is contained in:
171
.opencode/skills/plans-kanban/SKILL.md
Normal file
171
.opencode/skills/plans-kanban/SKILL.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: ck:plans-kanban
|
||||
description: View plans dashboard with progress tracking and timeline visualization. Use for kanban boards, plan status overview, phase progress, milestone tracking, project visibility.
|
||||
argument-hint: "[plans-dir]"
|
||||
metadata:
|
||||
author: claudekit
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# plans-kanban
|
||||
|
||||
Plans dashboard server with progress tracking and timeline visualization.
|
||||
|
||||
## ⚠️ Installation Required
|
||||
|
||||
**This skill requires npm dependencies.** Run one of the following:
|
||||
|
||||
```bash
|
||||
# Option 1: Install via ClaudeKit CLI (recommended)
|
||||
ck init # Runs install.sh which handles all skills
|
||||
|
||||
# Option 2: Manual installation
|
||||
cd .opencode/skills/plans-kanban
|
||||
npm install
|
||||
```
|
||||
|
||||
**Dependencies:** `gray-matter`
|
||||
|
||||
Without installation, you'll get **Error 500** when viewing plan details.
|
||||
|
||||
## Purpose
|
||||
|
||||
Visual dashboard for viewing plan directories with:
|
||||
- Progress tracking per plan
|
||||
- Timeline/Gantt visualization
|
||||
- Phase status indicators
|
||||
- Activity heatmap
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# View plans dashboard
|
||||
node .opencode/skills/plans-kanban/scripts/server.cjs \
|
||||
--dir ./plans \
|
||||
--open
|
||||
|
||||
# Remote access (all interfaces)
|
||||
node .opencode/skills/plans-kanban/scripts/server.cjs \
|
||||
--dir ./plans \
|
||||
--host 0.0.0.0 \
|
||||
--open
|
||||
|
||||
# Background mode
|
||||
node .opencode/skills/plans-kanban/scripts/server.cjs \
|
||||
--dir ./plans \
|
||||
--background
|
||||
|
||||
# Stop all running servers
|
||||
node .opencode/skills/plans-kanban/scripts/server.cjs --stop
|
||||
```
|
||||
|
||||
## Skill Invocation
|
||||
|
||||
Use `/ck:kanban` for quick access:
|
||||
|
||||
```bash
|
||||
/ck:kanban plans/ # View plans dashboard
|
||||
/ck:kanban --stop # Stop kanban server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard View
|
||||
- Plan cards with progress bars
|
||||
- Phase status breakdown (completed, in-progress, pending)
|
||||
- Last modified timestamps
|
||||
- Issue and branch links
|
||||
- Priority indicators
|
||||
|
||||
### Timeline Visualization
|
||||
- Gantt-style timeline of plans
|
||||
- Duration tracking
|
||||
- Activity heatmap
|
||||
|
||||
### Design
|
||||
- Glassmorphism UI with dark mode
|
||||
- Responsive grid layout
|
||||
- Warm accent colors
|
||||
|
||||
## CLI Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--dir <path>` | Plans directory | - |
|
||||
| `--port <number>` | Server port | 3500 |
|
||||
| `--host <addr>` | Host to bind (`0.0.0.0` for remote) | localhost |
|
||||
| `--open` | Auto-open browser | false |
|
||||
| `--background` | Run in background | false |
|
||||
| `--stop` | Stop all servers | - |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── server.cjs # Main entry point
|
||||
└── lib/
|
||||
├── port-finder.cjs # Port allocation (3500-3550)
|
||||
├── process-mgr.cjs # PID management
|
||||
├── http-server.cjs # HTTP routing
|
||||
├── plan-parser.cjs # Plan.md parsing
|
||||
├── plan-scanner.cjs # Directory scanning
|
||||
├── plan-metadata-extractor.cjs # Rich metadata
|
||||
└── dashboard-renderer.cjs # HTML generation
|
||||
|
||||
assets/
|
||||
├── dashboard-template.html # Dashboard HTML template
|
||||
├── dashboard.css # Styles
|
||||
└── dashboard.js # Client interactivity
|
||||
```
|
||||
|
||||
## HTTP Routes
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/` or `/kanban` | Dashboard view |
|
||||
| `/kanban?dir=<path>` | Dashboard for specific directory |
|
||||
| `/api/plans` | JSON API for plans data |
|
||||
| `/api/plans?dir=<path>` | JSON API for specific directory |
|
||||
| `/view?file=<path>` | Render markdown plan/phase files in reader view |
|
||||
| `/assets/*` | Static assets |
|
||||
| `/file/*` | Local file serving |
|
||||
|
||||
## Remote Access
|
||||
|
||||
When using `--host 0.0.0.0`, the server auto-detects your local network IP:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"url": "http://localhost:3500/kanban?dir=...",
|
||||
"networkUrl": "http://192.168.2.75:3500/kanban?dir=...",
|
||||
"port": 3500
|
||||
}
|
||||
```
|
||||
|
||||
Use `networkUrl` to access from other devices on the same network.
|
||||
|
||||
## Plan Structure
|
||||
|
||||
The dashboard scans for directories containing `plan.md` files:
|
||||
|
||||
```
|
||||
plans/
|
||||
├── 251215-feature-a/
|
||||
│ ├── plan.md # Required - parsed for phases
|
||||
│ ├── phase-01-setup.md
|
||||
│ └── phase-02-impl.md
|
||||
├── 251214-feature-b/
|
||||
│ └── plan.md
|
||||
└── templates/ # Excluded by default
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Port in use**: Server auto-increments from 3500-3550
|
||||
|
||||
**No plans found**: Ensure directories contain `plan.md` files
|
||||
|
||||
**Remote access denied**: Use `--host 0.0.0.0` to bind all interfaces
|
||||
|
||||
**PID files**: Located at `/tmp/plans-kanban-*.pid`
|
||||
119
.opencode/skills/plans-kanban/assets/dashboard-template.html
Normal file
119
.opencode/skills/plans-kanban/assets/dashboard-template.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Plans Dashboard</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
||||
<!-- Apply stored theme BEFORE CSS loads to prevent FOUC -->
|
||||
<script>
|
||||
(function(){
|
||||
var h=document.documentElement;
|
||||
var t=localStorage.getItem('theme');
|
||||
if(t)h.dataset.theme=t;
|
||||
else if(window.matchMedia('(prefers-color-scheme:dark)').matches)h.dataset.theme='dark';
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/novel-theme.css">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
</head>
|
||||
<body class="dashboard-view {{has-plans}}">
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<header class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h1>Plans</h1>
|
||||
<div class="header-stats">
|
||||
<span class="header-stat"><strong>{{stat-total}}</strong> total</span>
|
||||
<span class="header-stat-divider"></span>
|
||||
<span class="header-stat"><strong>{{stat-completed}}</strong> done</span>
|
||||
<span class="header-stat-divider"></span>
|
||||
<span class="header-stat"><strong>{{stat-in-progress}}</strong> active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<div class="search-box">
|
||||
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="search" id="plan-search" placeholder="Search plans..." aria-label="Search plans">
|
||||
</div>
|
||||
|
||||
<select id="sort-select" aria-label="Sort plans by">
|
||||
<option value="date-desc">Newest First</option>
|
||||
<option value="date-asc">Oldest First</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="progress-desc">Most Progress</option>
|
||||
<option value="progress-asc">Least Progress</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-pills" role="group" aria-label="Filter by status">
|
||||
<button class="filter-pill active" data-filter="all" aria-pressed="true">All</button>
|
||||
<button class="filter-pill" data-filter="completed" aria-pressed="false">Done</button>
|
||||
<button class="filter-pill" data-filter="in-progress" aria-pressed="false">Active</button>
|
||||
<button class="filter-pill" data-filter="pending" aria-pressed="false">Pending</button>
|
||||
</div>
|
||||
|
||||
<div role="status" aria-live="polite" class="result-count">
|
||||
<strong>{{plan-count}}</strong> plans
|
||||
</div>
|
||||
|
||||
<div class="view-toggle" role="group" aria-label="View mode">
|
||||
<button class="view-toggle-btn active" data-view="kanban" aria-pressed="true" title="Kanban view">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="5" height="18" rx="1"/><rect x="10" y="3" width="5" height="12" rx="1"/><rect x="17" y="3" width="5" height="8" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" data-view="grid" aria-pressed="false" title="Grid view">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle theme">
|
||||
<svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" role="main" aria-label="Plans Dashboard">
|
||||
{{timeline-section}}
|
||||
|
||||
<!-- Kanban Board View -->
|
||||
<div class="kanban-board" aria-label="Kanban board">
|
||||
{{kanban-columns}}
|
||||
</div>
|
||||
|
||||
<div class="loading-skeleton" aria-hidden="true">
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
</div>
|
||||
|
||||
<section class="plans-grid" aria-label="Plans list">
|
||||
{{plans-grid}}
|
||||
</section>
|
||||
|
||||
{{empty-state}}
|
||||
</main>
|
||||
|
||||
<div id="sr-announce" role="status" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
|
||||
|
||||
<script>window.__plans = {{plans-json}};</script>
|
||||
<script src="/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1594
.opencode/skills/plans-kanban/assets/dashboard.css
Normal file
1594
.opencode/skills/plans-kanban/assets/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
596
.opencode/skills/plans-kanban/assets/dashboard.js
Normal file
596
.opencode/skills/plans-kanban/assets/dashboard.js
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Dashboard Controls
|
||||
* Client-side sorting, filtering, and search for plans dashboard
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// State
|
||||
const state = {
|
||||
sort: 'date-desc',
|
||||
filter: 'all',
|
||||
search: '',
|
||||
view: 'kanban' // 'kanban' or 'grid'
|
||||
};
|
||||
|
||||
// Status column config
|
||||
const STATUS_COLUMNS = [
|
||||
{ id: 'pending', label: 'Pending', color: 'pending' },
|
||||
{ id: 'in-progress', label: 'In Progress', color: 'in-progress' },
|
||||
{ id: 'in-review', label: 'In Review', color: 'in-review' },
|
||||
{ id: 'completed', label: 'Done', color: 'completed' },
|
||||
{ id: 'cancelled', label: 'Cancelled', color: 'cancelled' }
|
||||
];
|
||||
|
||||
// Elements
|
||||
let allPlans = [];
|
||||
let grid = null;
|
||||
let kanbanBoard = null;
|
||||
let resultCount = null;
|
||||
let emptyState = null;
|
||||
let srAnnounce = null;
|
||||
|
||||
/**
|
||||
* Initialize dashboard
|
||||
*/
|
||||
function init() {
|
||||
allPlans = window.__plans || [];
|
||||
grid = document.querySelector('.plans-grid');
|
||||
kanbanBoard = document.querySelector('.kanban-board');
|
||||
resultCount = document.querySelector('.result-count');
|
||||
emptyState = document.querySelector('.empty-state');
|
||||
srAnnounce = document.getElementById('sr-announce');
|
||||
|
||||
// Mark as loaded
|
||||
document.body.classList.add('plans-loaded');
|
||||
|
||||
// Show empty state if no plans
|
||||
if (!allPlans.length) {
|
||||
if (emptyState) emptyState.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URL state
|
||||
parseURL();
|
||||
|
||||
// Bind events
|
||||
bindEvents();
|
||||
|
||||
// Initial render
|
||||
applyFiltersAndSort();
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNav();
|
||||
|
||||
// Setup theme toggle
|
||||
setupThemeToggle();
|
||||
|
||||
// Setup view toggle
|
||||
setupViewToggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind event listeners
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Search input with debounce
|
||||
const searchInput = document.getElementById('plan-search');
|
||||
if (searchInput) {
|
||||
let debounceTimer;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
state.search = e.target.value.toLowerCase().trim();
|
||||
applyFiltersAndSort();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Clear search on Escape
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
searchInput.value = '';
|
||||
state.search = '';
|
||||
applyFiltersAndSort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort select
|
||||
const sortSelect = document.getElementById('sort-select');
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', (e) => {
|
||||
state.sort = e.target.value;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
}
|
||||
|
||||
// Filter pills
|
||||
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
// Update active state
|
||||
document.querySelectorAll('.filter-pill').forEach(p => {
|
||||
p.classList.remove('active');
|
||||
p.setAttribute('aria-pressed', 'false');
|
||||
});
|
||||
pill.classList.add('active');
|
||||
pill.setAttribute('aria-pressed', 'true');
|
||||
|
||||
state.filter = pill.dataset.filter;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
});
|
||||
|
||||
// Card click to navigate
|
||||
grid?.addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.plan-card');
|
||||
if (card && !e.target.closest('.view-btn')) {
|
||||
const link = card.querySelector('.view-btn');
|
||||
if (link) link.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters and sorting
|
||||
*/
|
||||
function applyFiltersAndSort() {
|
||||
let filtered = allPlans.slice();
|
||||
|
||||
// Filter by status
|
||||
if (state.filter !== 'all') {
|
||||
filtered = filtered.filter(p => p.status === state.filter);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (state.search) {
|
||||
filtered = filtered.filter(p =>
|
||||
p.name.toLowerCase().includes(state.search) ||
|
||||
p.id.toLowerCase().includes(state.search)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
switch (state.sort) {
|
||||
case 'date-desc':
|
||||
return new Date(b.lastModified) - new Date(a.lastModified);
|
||||
case 'date-asc':
|
||||
return new Date(a.lastModified) - new Date(b.lastModified);
|
||||
case 'name-asc':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'progress-desc':
|
||||
return b.progress - a.progress;
|
||||
case 'progress-asc':
|
||||
return a.progress - b.progress;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
renderGrid(filtered);
|
||||
|
||||
// Also update kanban if in kanban view
|
||||
if (state.view === 'kanban') {
|
||||
renderKanbanBoard(allPlans); // Kanban uses all plans grouped by status
|
||||
}
|
||||
|
||||
updateURL();
|
||||
announce(`Showing ${filtered.length} plan${filtered.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render grid with filtered plans
|
||||
*/
|
||||
function renderGrid(plans) {
|
||||
const visibleIds = new Set(plans.map(p => p.id));
|
||||
|
||||
// Hide/show cards and set order
|
||||
document.querySelectorAll('.plan-card').forEach(card => {
|
||||
const id = card.dataset.id;
|
||||
const isVisible = visibleIds.has(id);
|
||||
card.style.display = isVisible ? '' : 'none';
|
||||
|
||||
if (isVisible) {
|
||||
const index = plans.findIndex(p => p.id === id);
|
||||
card.style.order = index;
|
||||
}
|
||||
});
|
||||
|
||||
// Update count
|
||||
if (resultCount) {
|
||||
resultCount.innerHTML = `Showing <strong>${plans.length}</strong> plan${plans.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Show/hide empty state
|
||||
if (emptyState) {
|
||||
emptyState.hidden = plans.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL parameters
|
||||
*/
|
||||
function parseURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (params.has('sort')) {
|
||||
state.sort = params.get('sort');
|
||||
}
|
||||
if (params.has('filter')) {
|
||||
state.filter = params.get('filter');
|
||||
}
|
||||
if (params.has('q')) {
|
||||
state.search = params.get('q');
|
||||
}
|
||||
|
||||
// Update controls to match state
|
||||
const sortSelect = document.getElementById('sort-select');
|
||||
if (sortSelect) sortSelect.value = state.sort;
|
||||
|
||||
const searchInput = document.getElementById('plan-search');
|
||||
if (searchInput) searchInput.value = state.search;
|
||||
|
||||
document.querySelectorAll('.filter-pill').forEach(p => {
|
||||
const isActive = p.dataset.filter === state.filter;
|
||||
p.classList.toggle('active', isActive);
|
||||
p.setAttribute('aria-pressed', String(isActive));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update URL with current state
|
||||
*/
|
||||
function updateURL() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (state.sort !== 'date-desc') params.set('sort', state.sort);
|
||||
if (state.filter !== 'all') params.set('filter', state.filter);
|
||||
if (state.search) params.set('q', state.search);
|
||||
|
||||
const url = params.toString()
|
||||
? `${window.location.pathname}?${params}`
|
||||
: window.location.pathname;
|
||||
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce message to screen readers
|
||||
*/
|
||||
function announce(message) {
|
||||
if (!srAnnounce) return;
|
||||
srAnnounce.textContent = '';
|
||||
// Force reflow
|
||||
void srAnnounce.offsetHeight;
|
||||
srAnnounce.textContent = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup keyboard navigation for cards
|
||||
*/
|
||||
function setupKeyboardNav() {
|
||||
grid?.addEventListener('keydown', (e) => {
|
||||
const cards = [...document.querySelectorAll('.plan-card:not([style*="display: none"])')];
|
||||
const current = document.activeElement;
|
||||
const index = cards.indexOf(current);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
let next;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
next = cards[index + 1] || cards[0];
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
next = cards[index - 1] || cards[cards.length - 1];
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
const link = current.querySelector('.view-btn');
|
||||
if (link) link.click();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
next = cards[0];
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
next = cards[cards.length - 1];
|
||||
break;
|
||||
}
|
||||
|
||||
if (next) next.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup theme toggle
|
||||
*/
|
||||
function setupThemeToggle() {
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
if (!toggle) return;
|
||||
|
||||
// Check saved preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
announce(`Theme changed to ${next} mode`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup view toggle (Kanban/Grid)
|
||||
*/
|
||||
function setupViewToggle() {
|
||||
const toggleBtns = document.querySelectorAll('.view-toggle-btn');
|
||||
if (!toggleBtns.length) return;
|
||||
|
||||
// Restore saved view preference
|
||||
const savedView = localStorage.getItem('dashboard-view') || 'kanban';
|
||||
state.view = savedView;
|
||||
updateViewMode();
|
||||
|
||||
toggleBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const view = btn.dataset.view;
|
||||
if (view === state.view) return;
|
||||
|
||||
state.view = view;
|
||||
localStorage.setItem('dashboard-view', view);
|
||||
updateViewMode();
|
||||
announce(`Switched to ${view} view`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update view mode (toggle between kanban and grid)
|
||||
*/
|
||||
function updateViewMode() {
|
||||
const body = document.body;
|
||||
|
||||
// Update toggle buttons
|
||||
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.view === state.view;
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-pressed', String(isActive));
|
||||
});
|
||||
|
||||
// Toggle view classes
|
||||
if (state.view === 'grid') {
|
||||
body.classList.add('view-mode-grid');
|
||||
} else {
|
||||
body.classList.remove('view-mode-grid');
|
||||
}
|
||||
|
||||
// Re-render kanban when switching to kanban view
|
||||
if (state.view === 'kanban') {
|
||||
renderKanbanBoard(getFilteredPlans());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered and sorted plans
|
||||
*/
|
||||
function getFilteredPlans() {
|
||||
let filtered = allPlans.slice();
|
||||
|
||||
// Filter by status (only for grid view, kanban shows all columns)
|
||||
if (state.filter !== 'all' && state.view === 'grid') {
|
||||
filtered = filtered.filter(p => p.status === state.filter);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (state.search) {
|
||||
filtered = filtered.filter(p =>
|
||||
p.name.toLowerCase().includes(state.search) ||
|
||||
p.id.toLowerCase().includes(state.search)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
switch (state.sort) {
|
||||
case 'date-desc':
|
||||
return new Date(b.lastModified) - new Date(a.lastModified);
|
||||
case 'date-asc':
|
||||
return new Date(a.lastModified) - new Date(b.lastModified);
|
||||
case 'name-asc':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'progress-desc':
|
||||
return b.progress - a.progress;
|
||||
case 'progress-asc':
|
||||
return a.progress - b.progress;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render kanban board with plans grouped by status
|
||||
*/
|
||||
function renderKanbanBoard(plans) {
|
||||
if (!kanbanBoard) return;
|
||||
|
||||
// Group plans by status
|
||||
const grouped = {};
|
||||
STATUS_COLUMNS.forEach(col => {
|
||||
grouped[col.id] = [];
|
||||
});
|
||||
|
||||
// Apply search filter for kanban
|
||||
let filteredPlans = plans;
|
||||
if (state.search) {
|
||||
filteredPlans = plans.filter(p =>
|
||||
p.name.toLowerCase().includes(state.search) ||
|
||||
p.id.toLowerCase().includes(state.search)
|
||||
);
|
||||
}
|
||||
|
||||
filteredPlans.forEach(plan => {
|
||||
const status = plan.status || 'pending';
|
||||
if (grouped[status]) {
|
||||
grouped[status].push(plan);
|
||||
} else {
|
||||
grouped['pending'].push(plan);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate column HTML
|
||||
const columnsHtml = STATUS_COLUMNS.map(col => {
|
||||
const columnPlans = grouped[col.id];
|
||||
const cardsHtml = columnPlans.length > 0
|
||||
? columnPlans.map(plan => renderKanbanCard(plan)).join('')
|
||||
: `<div class="kanban-empty">
|
||||
<svg class="kanban-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M9 9h6M9 13h6M9 17h4"/>
|
||||
</svg>
|
||||
<span>No plans</span>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="kanban-column" data-status="${col.id}">
|
||||
<div class="kanban-column-header">
|
||||
<div class="kanban-column-title">
|
||||
<span class="kanban-status-dot ${col.color}"></span>
|
||||
<span>${col.label}</span>
|
||||
</div>
|
||||
<span class="kanban-column-count">${columnPlans.length}</span>
|
||||
</div>
|
||||
<div class="kanban-cards">
|
||||
${cardsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
kanbanBoard.innerHTML = columnsHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single kanban card (enhanced with details)
|
||||
*/
|
||||
function renderKanbanCard(plan) {
|
||||
const progressPct = Math.round(plan.progress || 0);
|
||||
const dateStr = formatDate(plan.lastModified);
|
||||
|
||||
// Priority badge
|
||||
let priorityHtml = '';
|
||||
if (plan.priority) {
|
||||
const p = String(plan.priority).toUpperCase();
|
||||
let priorityClass = '';
|
||||
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') priorityClass = 'priority-high';
|
||||
else if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') priorityClass = 'priority-medium';
|
||||
else if (p === 'P3' || p === 'LOW') priorityClass = 'priority-low';
|
||||
if (priorityClass) {
|
||||
priorityHtml = `<span class="kanban-card-priority ${priorityClass}">${escapeHtml(plan.priority)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Description (truncated)
|
||||
let descriptionHtml = '';
|
||||
if (plan.description) {
|
||||
const desc = plan.description.length > 80 ? plan.description.slice(0, 77) + '...' : plan.description;
|
||||
descriptionHtml = `<p class="kanban-card-description">${escapeHtml(desc)}</p>`;
|
||||
}
|
||||
|
||||
// Tags (max 3 visible)
|
||||
let tagsHtml = '';
|
||||
if (plan.tags && Array.isArray(plan.tags) && plan.tags.length > 0) {
|
||||
const visibleTags = plan.tags.slice(0, 3);
|
||||
const hiddenCount = plan.tags.length - 3;
|
||||
tagsHtml = '<div class="kanban-card-tags">';
|
||||
tagsHtml += visibleTags.map(tag => `<span class="kanban-card-tag">${escapeHtml(tag)}</span>`).join('');
|
||||
if (hiddenCount > 0) {
|
||||
tagsHtml += `<span class="kanban-card-tag tag-more">+${hiddenCount}</span>`;
|
||||
}
|
||||
tagsHtml += '</div>';
|
||||
}
|
||||
|
||||
// Footer with effort and phases
|
||||
let footerHtml = '';
|
||||
const effortHtml = plan.totalEffortFormatted
|
||||
? `<span class="kanban-card-effort"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${escapeHtml(plan.totalEffortFormatted)}</span>`
|
||||
: '';
|
||||
const phasesHtml = plan.phasesTotal
|
||||
? `<span class="kanban-card-phases">${plan.phasesTotal} phases</span>`
|
||||
: '';
|
||||
if (effortHtml || phasesHtml) {
|
||||
footerHtml = `<div class="kanban-card-footer">${effortHtml}${phasesHtml}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<a href="/view?file=${encodeURIComponent(plan.path)}" class="kanban-card" data-id="${plan.id}">
|
||||
<div class="kanban-card-header">
|
||||
<h4 class="kanban-card-title">${escapeHtml(plan.name)}</h4>
|
||||
${priorityHtml}
|
||||
</div>
|
||||
${descriptionHtml}
|
||||
<div class="kanban-card-meta">
|
||||
<div class="kanban-card-progress">
|
||||
<div class="kanban-card-progress-bar">
|
||||
<div class="kanban-card-progress-fill" style="width: ${progressPct}%"></div>
|
||||
</div>
|
||||
<span>${progressPct}%</span>
|
||||
</div>
|
||||
<span class="kanban-card-date">${dateStr}</span>
|
||||
</div>
|
||||
${tagsHtml}
|
||||
${footerHtml}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
BIN
.opencode/skills/plans-kanban/assets/favicon.png
Normal file
BIN
.opencode/skills/plans-kanban/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
13
.opencode/skills/plans-kanban/package.json
Normal file
13
.opencode/skills/plans-kanban/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "plans-kanban",
|
||||
"version": "1.0.0",
|
||||
"description": "Kanban dashboard for viewing and managing implementation plans",
|
||||
"main": "scripts/server.cjs",
|
||||
"scripts": {
|
||||
"start": "node scripts/server.cjs",
|
||||
"test": "node scripts/tests/server.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3"
|
||||
}
|
||||
}
|
||||
884
.opencode/skills/plans-kanban/scripts/lib/dashboard-renderer.cjs
Normal file
884
.opencode/skills/plans-kanban/scripts/lib/dashboard-renderer.cjs
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* Dashboard Renderer
|
||||
* Generates HTML for the enhanced plans dashboard view with glassmorphism design
|
||||
*
|
||||
* @module dashboard-renderer
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
generateTimelineStats,
|
||||
generateActivityHeatmap
|
||||
} = require('./plan-metadata-extractor.cjs');
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} - Escaped string
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to specified length with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLen - Maximum length
|
||||
* @returns {string} - Truncated text
|
||||
*/
|
||||
function truncate(text, maxLen = 100) {
|
||||
if (!text) return '';
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen - 3).trim() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority color class based on priority level
|
||||
* @param {string} priority - Priority string (P1/P2/P3 or High/Medium/Low)
|
||||
* @returns {string} - CSS class name
|
||||
*/
|
||||
function getPriorityColorClass(priority) {
|
||||
if (!priority) return '';
|
||||
const p = String(priority).toUpperCase();
|
||||
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') return 'priority-high';
|
||||
if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') return 'priority-medium';
|
||||
if (p === 'P3' || p === 'LOW') return 'priority-low';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
* @param {string} isoDate - ISO date string
|
||||
* @returns {string} - Formatted date
|
||||
*/
|
||||
function formatDate(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
const date = new Date(isoDate);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 days ago")
|
||||
* @param {string} isoDate - ISO date string
|
||||
* @returns {string} - Relative time string
|
||||
*/
|
||||
function formatRelativeTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status label
|
||||
* @param {string} status - Status code
|
||||
* @returns {string} - Human-readable label
|
||||
*/
|
||||
function getStatusLabel(status) {
|
||||
const labels = {
|
||||
'completed': 'Completed',
|
||||
'complete': 'Completed',
|
||||
'in-progress': 'In Progress',
|
||||
'in-review': 'In Review',
|
||||
'cancelled': 'Cancelled',
|
||||
'pending': 'Pending'
|
||||
};
|
||||
return labels[status] || 'Pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG progress ring (kept for backward compatibility but hidden in new design)
|
||||
* @param {number} progress - Progress percentage (0-100)
|
||||
* @returns {string} - SVG HTML
|
||||
*/
|
||||
function generateProgressRing(progress) {
|
||||
// Hidden in new minimal design - kept for compatibility
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate simple progress bar (monochrome design)
|
||||
* @param {{total: number, completed: number, inProgress: number, pending: number}} phases
|
||||
* @returns {string} - Progress bar HTML
|
||||
*/
|
||||
function generateProgressBar(phases) {
|
||||
const total = phases.total || 1;
|
||||
const completedPct = ((phases.completed / total) * 100).toFixed(1);
|
||||
const inProgressPct = ((phases.inProgress / total) * 100).toFixed(1);
|
||||
|
||||
return `
|
||||
<div class="progress-bar" role="progressbar"
|
||||
aria-valuenow="${phases.completed}" aria-valuemin="0" aria-valuemax="${total}"
|
||||
aria-label="Progress: ${phases.completed} of ${total} phases completed">
|
||||
<div class="bar-segment completed" style="width: ${completedPct}%"></div>
|
||||
<div class="bar-segment in-progress" style="width: ${inProgressPct}%"></div>
|
||||
</div>
|
||||
<div class="phase-count"><strong>${phases.completed}</strong> of ${total} phases</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate status counts HTML (hidden in minimal design)
|
||||
* @param {{completed: number, inProgress: number, pending: number}} phases
|
||||
* @returns {string} - Status counts HTML
|
||||
*/
|
||||
function generateStatusCounts(phases) {
|
||||
// Hidden in minimal design
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate status badge HTML (simplified for monochrome design)
|
||||
* @param {string} status - Status string
|
||||
* @returns {string} - Status badge HTML
|
||||
*/
|
||||
function generateStatusBadge(status) {
|
||||
const statusClass = (status || 'pending').replace(/\s+/g, '-');
|
||||
// Simplified labels for minimal design
|
||||
const labels = {
|
||||
'completed': 'Done',
|
||||
'complete': 'Done',
|
||||
'in-progress': 'Active',
|
||||
'pending': 'Pending'
|
||||
};
|
||||
const label = labels[statusClass] || 'Pending';
|
||||
return `<span class="status-badge ${statusClass}">${label}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate meta tags HTML for plan card (duration, effort, priority, issue, tags)
|
||||
* @param {Object} plan - Plan metadata
|
||||
* @returns {string} - Meta tags HTML
|
||||
*/
|
||||
function generateCardMeta(plan) {
|
||||
const metaTags = [];
|
||||
|
||||
// Duration tag
|
||||
if (plan.durationFormatted) {
|
||||
const icon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
metaTags.push(`<span class="meta-tag duration" title="Duration">${icon} ${escapeHtml(plan.durationFormatted)}</span>`);
|
||||
}
|
||||
|
||||
// Effort tag
|
||||
if (plan.totalEffortFormatted) {
|
||||
metaTags.push(`<span class="meta-tag effort" title="Estimated effort"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> ${escapeHtml(plan.totalEffortFormatted)}</span>`);
|
||||
}
|
||||
|
||||
// Priority tag with color class
|
||||
if (plan.priority) {
|
||||
const priorityColorClass = getPriorityColorClass(plan.priority);
|
||||
metaTags.push(`<span class="meta-tag priority ${priorityColorClass}" title="Priority">${escapeHtml(plan.priority)}</span>`);
|
||||
}
|
||||
|
||||
// Issue tag - clickable link to GitHub (uses branch to derive repo, falls back to claudekit)
|
||||
if (plan.issue) {
|
||||
// TODO: Make repo configurable via project settings
|
||||
const issueUrl = `https://github.com/claudekit/claudekit/issues/${plan.issue}`;
|
||||
metaTags.push(`<a href="${issueUrl}" target="_blank" rel="noopener" class="meta-tag issue" title="Issue #${plan.issue}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> #${plan.issue}</a>`);
|
||||
}
|
||||
|
||||
if (metaTags.length === 0) return '';
|
||||
return `<div class="card-meta">${metaTags.join('')}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tags pills HTML
|
||||
* @param {Array<string>} tags - Array of tag strings
|
||||
* @param {number} maxVisible - Maximum visible tags (default 3)
|
||||
* @returns {string} - Tags HTML
|
||||
*/
|
||||
function generateTagsPills(tags, maxVisible = 3) {
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) return '';
|
||||
|
||||
const visibleTags = tags.slice(0, maxVisible);
|
||||
const hiddenCount = tags.length - maxVisible;
|
||||
|
||||
let html = '<div class="card-tags">';
|
||||
html += visibleTags.map(tag =>
|
||||
`<span class="tag-pill">${escapeHtml(tag)}</span>`
|
||||
).join('');
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
html += `<span class="tag-pill tag-more">+${hiddenCount}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for a single plan card (minimal design with rich metadata)
|
||||
* @param {Object} plan - Plan metadata
|
||||
* @returns {string} - Card HTML
|
||||
*/
|
||||
function generatePlanCard(plan) {
|
||||
const statusClass = (plan.status || 'pending').replace(/\s+/g, '-');
|
||||
const name = escapeHtml(plan.name);
|
||||
const relativeTime = formatRelativeTime(plan.lastModified);
|
||||
const cardMeta = generateCardMeta(plan);
|
||||
|
||||
// Description section (truncated)
|
||||
const descriptionHtml = plan.description
|
||||
? `<p class="card-description">${escapeHtml(truncate(plan.description, 100))}</p>`
|
||||
: '';
|
||||
|
||||
// Tags pills
|
||||
const tagsHtml = generateTagsPills(plan.tags);
|
||||
|
||||
return `
|
||||
<article class="plan-card" data-status="${statusClass}" data-id="${escapeHtml(plan.id)}" tabindex="0"
|
||||
data-created="${plan.createdDate || ''}" data-duration="${plan.durationDays || 0}"
|
||||
data-effort="${plan.totalEffortHours || 0}" data-priority="${plan.priority || ''}">
|
||||
<header class="card-header">
|
||||
<div class="card-header-content">
|
||||
<h2 class="plan-name">${name}</h2>
|
||||
<div class="plan-date">
|
||||
<time datetime="${plan.lastModified}">${relativeTime}</time>
|
||||
</div>
|
||||
</div>
|
||||
${generateStatusBadge(statusClass)}
|
||||
</header>
|
||||
<div class="card-body">
|
||||
${descriptionHtml}
|
||||
${generateProgressBar(plan.phases)}
|
||||
${cardMeta}
|
||||
${tagsHtml}
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<div class="phases-summary">${plan.phases.total} phases total</div>
|
||||
<a href="/view?file=${encodeURIComponent(plan.path)}" class="view-btn">
|
||||
View
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-stack plans into layers to avoid overlap (like Google Calendar)
|
||||
* Uses actual plan duration for in-progress items instead of extending to today
|
||||
* @param {Array} plans - Plans with createdDate and durationDays
|
||||
* @param {Date} rangeStart - Start of visible range
|
||||
* @param {Date} rangeEnd - End of visible range
|
||||
* @returns {Array} - Plans with layer assignments
|
||||
*/
|
||||
function assignLayers(plans, rangeStart, rangeEnd) {
|
||||
const rangeDays = Math.ceil((rangeEnd - rangeStart) / (1000 * 60 * 60 * 24));
|
||||
const now = new Date();
|
||||
const layers = []; // Each layer tracks occupied day ranges
|
||||
|
||||
// Filter to plans with dates, then sort by start date
|
||||
const sorted = [...plans]
|
||||
.filter(p => p.createdDate)
|
||||
.sort((a, b) => new Date(a.createdDate) - new Date(b.createdDate));
|
||||
|
||||
// Filter to plans within visible range
|
||||
const visible = sorted.filter(plan => {
|
||||
const startDate = new Date(plan.createdDate);
|
||||
const endDate = plan.completedDate
|
||||
? new Date(plan.completedDate)
|
||||
: new Date(startDate.getTime() + (plan.durationDays || 1) * 24 * 60 * 60 * 1000);
|
||||
return endDate >= rangeStart && startDate <= rangeEnd;
|
||||
});
|
||||
|
||||
return visible.map(plan => {
|
||||
const startDate = new Date(plan.createdDate);
|
||||
// Determine end date based on status
|
||||
let endDate;
|
||||
if (plan.completedDate) {
|
||||
endDate = new Date(plan.completedDate);
|
||||
} else if (plan.status === 'completed') {
|
||||
// Completed without explicit date: use lastModified or cap at today
|
||||
endDate = plan.lastModified ? new Date(plan.lastModified) : now;
|
||||
} else {
|
||||
// In-progress/pending: use duration from start
|
||||
endDate = new Date(startDate.getTime() + Math.max(1, plan.durationDays || 1) * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
// Completed plans can't extend past today
|
||||
if (plan.status === 'completed' && endDate > now) {
|
||||
endDate = now;
|
||||
}
|
||||
|
||||
// Calculate position as percentage (clamp to range)
|
||||
const startDay = Math.max(0, Math.ceil((startDate - rangeStart) / (1000 * 60 * 60 * 24)));
|
||||
const endDay = Math.min(rangeDays, Math.ceil((endDate - rangeStart) / (1000 * 60 * 60 * 24)));
|
||||
|
||||
// Ensure minimum visible width (2 days)
|
||||
const adjustedEndDay = Math.max(startDay + 2, endDay);
|
||||
const leftPct = (startDay / rangeDays) * 100;
|
||||
const widthPct = Math.min(100 - leftPct, Math.max(4, ((adjustedEndDay - startDay) / rangeDays) * 100));
|
||||
|
||||
// Find first layer without overlap (greedy algorithm)
|
||||
let layer = 0;
|
||||
let foundSlot = false;
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const hasOverlap = layers[i].some(range =>
|
||||
!(adjustedEndDay <= range.start || startDay >= range.end)
|
||||
);
|
||||
if (!hasOverlap) {
|
||||
layer = i;
|
||||
foundSlot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundSlot) {
|
||||
layer = layers.length;
|
||||
}
|
||||
|
||||
// Add to layer
|
||||
if (!layers[layer]) layers[layer] = [];
|
||||
layers[layer].push({ start: startDay, end: adjustedEndDay });
|
||||
|
||||
return { ...plan, layer, leftPct, widthPct, startDay, endDay: adjustedEndDay };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate timeline section HTML with Layered Gantt
|
||||
* @param {Array} plans - Array of plan metadata objects
|
||||
* @returns {string} - Timeline section HTML
|
||||
*/
|
||||
function generateTimelineSection(plans) {
|
||||
if (!plans || plans.length === 0) return '';
|
||||
|
||||
const stats = generateTimelineStats(plans);
|
||||
|
||||
// Calculate date range (last 3 weeks to now + 1 week)
|
||||
const now = new Date();
|
||||
const rangeStart = new Date(now.getTime() - 21 * 24 * 60 * 60 * 1000);
|
||||
const rangeEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const rangeDays = Math.ceil((rangeEnd - rangeStart) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Generate date axis labels (7 markers)
|
||||
const axisLabels = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(rangeStart.getTime() + (i * rangeDays / 6) * 24 * 60 * 60 * 1000);
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
axisLabels.push({
|
||||
label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
isToday
|
||||
});
|
||||
}
|
||||
|
||||
const axisHtml = axisLabels.map(a =>
|
||||
`<span class="gantt-axis-label${a.isToday ? ' today' : ''}">${a.label}</span>`
|
||||
).join('');
|
||||
|
||||
// Calculate today marker position
|
||||
const todayPct = ((now - rangeStart) / (rangeEnd - rangeStart)) * 100;
|
||||
|
||||
// Auto-stack plans into layers
|
||||
const layeredPlans = assignLayers(plans, rangeStart, rangeEnd);
|
||||
const maxLayer = layeredPlans.length > 0 ? Math.max(...layeredPlans.map(p => p.layer), 0) : 0;
|
||||
// Compact layout: 22px per layer (bar 18px + 4px gap), no max cap
|
||||
const trackHeight = Math.max(60, (maxLayer + 1) * 22 + 12);
|
||||
|
||||
// Generate gantt bars
|
||||
const barsHtml = layeredPlans.map(plan => {
|
||||
const statusClass = plan.status === 'completed' ? 'completed'
|
||||
: plan.status === 'in-progress' ? 'in-progress' : 'pending';
|
||||
const top = plan.layer * 22 + 6;
|
||||
const statusIcon = plan.status === 'completed' ? '✓' : plan.status === 'in-progress' ? '◐' : '○';
|
||||
|
||||
return `
|
||||
<a href="/view?file=${encodeURIComponent(plan.path)}" class="gantt-bar ${statusClass}"
|
||||
style="left: ${plan.leftPct.toFixed(1)}%; width: ${plan.widthPct.toFixed(1)}%; top: ${top}px;"
|
||||
data-id="${escapeHtml(plan.id)}">
|
||||
<span class="gantt-bar-label">${escapeHtml(plan.name)}</span>
|
||||
<span class="gantt-bar-status">${statusIcon}</span>
|
||||
<div class="gantt-tooltip">
|
||||
<div class="gantt-tooltip-title">${escapeHtml(plan.name)}</div>
|
||||
<div class="gantt-tooltip-meta">
|
||||
<span>${plan.durationFormatted || 'Today'}</span>
|
||||
<span>${plan.phases.completed}/${plan.phases.total} phases</span>
|
||||
${plan.totalEffortFormatted ? `<span>${plan.totalEffortFormatted}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Summary counts
|
||||
const completedCount = plans.filter(p => p.status === 'completed').length;
|
||||
const activeCount = plans.filter(p => p.status === 'in-progress').length;
|
||||
const pendingCount = plans.filter(p => p.status === 'pending').length;
|
||||
|
||||
return `
|
||||
<section class="timeline-section" aria-label="Project timeline">
|
||||
<div class="timeline-header">
|
||||
<h2 class="timeline-title">Timeline</h2>
|
||||
<div class="timeline-stats">
|
||||
<div class="timeline-stat">
|
||||
<span>Avg:</span>
|
||||
<strong>${stats.avgDurationDays}d</strong>
|
||||
</div>
|
||||
<div class="timeline-stat">
|
||||
<span>Effort:</span>
|
||||
<strong>${stats.totalEffortHours.toFixed(0)}h</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gantt-container">
|
||||
<div class="gantt-axis">${axisHtml}</div>
|
||||
<div class="gantt-track" style="height: ${trackHeight}px;">
|
||||
<div class="gantt-today-marker" style="left: ${todayPct.toFixed(1)}%;"></div>
|
||||
${barsHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-summary">
|
||||
<div class="timeline-summary-item">
|
||||
<span class="timeline-summary-dot completed"></span>
|
||||
<span>${completedCount} done</span>
|
||||
</div>
|
||||
<div class="timeline-summary-item">
|
||||
<span class="timeline-summary-dot in-progress"></span>
|
||||
<span>${activeCount} active</span>
|
||||
</div>
|
||||
<div class="timeline-summary-item">
|
||||
<span class="timeline-summary-dot pending"></span>
|
||||
<span>${pendingCount} pending</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate empty state HTML with animated icon
|
||||
* @returns {string} - Empty state HTML
|
||||
*/
|
||||
function generateEmptyState() {
|
||||
return `
|
||||
<div class="empty-state" hidden>
|
||||
<div class="empty-icon" aria-hidden="true">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No plans found</h2>
|
||||
<p>Create a plan directory with a plan.md file to get started with tracking your projects.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plans grid HTML
|
||||
* @param {Array} plans - Array of plan metadata objects
|
||||
* @returns {string} - Grid HTML
|
||||
*/
|
||||
function generatePlansGrid(plans) {
|
||||
if (!plans || plans.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return plans.map(generatePlanCard).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Status column configuration for kanban board
|
||||
*/
|
||||
const STATUS_COLUMNS = [
|
||||
{ id: 'pending', label: 'Pending', color: 'pending' },
|
||||
{ id: 'in-progress', label: 'In Progress', color: 'in-progress' },
|
||||
{ id: 'in-review', label: 'In Review', color: 'in-review' },
|
||||
{ id: 'completed', label: 'Done', color: 'completed' },
|
||||
{ id: 'cancelled', label: 'Cancelled', color: 'cancelled' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate kanban card HTML for a single plan (enhanced with details)
|
||||
* @param {Object} plan - Plan metadata
|
||||
* @returns {string} - Card HTML
|
||||
*/
|
||||
function generateKanbanCard(plan) {
|
||||
const progressPct = Math.round(plan.progress || 0);
|
||||
const dateStr = formatRelativeTime(plan.lastModified);
|
||||
|
||||
// Priority badge
|
||||
let priorityHtml = '';
|
||||
if (plan.priority) {
|
||||
const priorityColorClass = getPriorityColorClass(plan.priority);
|
||||
if (priorityColorClass) {
|
||||
priorityHtml = `<span class="kanban-card-priority ${priorityColorClass}">${escapeHtml(plan.priority)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Description (truncated)
|
||||
let descriptionHtml = '';
|
||||
if (plan.description) {
|
||||
descriptionHtml = `<p class="kanban-card-description">${escapeHtml(truncate(plan.description, 80))}</p>`;
|
||||
}
|
||||
|
||||
// Tags (max 3 visible)
|
||||
let tagsHtml = '';
|
||||
if (plan.tags && Array.isArray(plan.tags) && plan.tags.length > 0) {
|
||||
const visibleTags = plan.tags.slice(0, 3);
|
||||
const hiddenCount = plan.tags.length - 3;
|
||||
tagsHtml = '<div class="kanban-card-tags">';
|
||||
tagsHtml += visibleTags.map(tag => `<span class="kanban-card-tag">${escapeHtml(tag)}</span>`).join('');
|
||||
if (hiddenCount > 0) {
|
||||
tagsHtml += `<span class="kanban-card-tag tag-more">+${hiddenCount}</span>`;
|
||||
}
|
||||
tagsHtml += '</div>';
|
||||
}
|
||||
|
||||
// Footer with effort and phases
|
||||
let footerHtml = '';
|
||||
const effortHtml = plan.totalEffortFormatted
|
||||
? `<span class="kanban-card-effort"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${escapeHtml(plan.totalEffortFormatted)}</span>`
|
||||
: '';
|
||||
const phasesHtml = plan.phases && plan.phases.total
|
||||
? `<span class="kanban-card-phases">${plan.phases.total} phases</span>`
|
||||
: '';
|
||||
if (effortHtml || phasesHtml) {
|
||||
footerHtml = `<div class="kanban-card-footer">${effortHtml}${phasesHtml}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<a href="/view?file=${encodeURIComponent(plan.path)}" class="kanban-card" data-id="${escapeHtml(plan.id)}">
|
||||
<div class="kanban-card-header">
|
||||
<h4 class="kanban-card-title">${escapeHtml(plan.name)}</h4>
|
||||
${priorityHtml}
|
||||
</div>
|
||||
${descriptionHtml}
|
||||
<div class="kanban-card-meta">
|
||||
<div class="kanban-card-progress">
|
||||
<div class="kanban-card-progress-bar">
|
||||
<div class="kanban-card-progress-fill" style="width: ${progressPct}%"></div>
|
||||
</div>
|
||||
<span>${progressPct}%</span>
|
||||
</div>
|
||||
<span class="kanban-card-date">${dateStr}</span>
|
||||
</div>
|
||||
${tagsHtml}
|
||||
${footerHtml}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate kanban board columns HTML
|
||||
* @param {Array} plans - Array of plan metadata objects
|
||||
* @returns {string} - Kanban columns HTML
|
||||
*/
|
||||
function generateKanbanColumns(plans) {
|
||||
if (!plans || plans.length === 0) {
|
||||
// Return empty columns structure
|
||||
return STATUS_COLUMNS.map(col => `
|
||||
<div class="kanban-column" data-status="${col.id}">
|
||||
<div class="kanban-column-header">
|
||||
<div class="kanban-column-title">
|
||||
<span class="kanban-status-dot ${col.color}"></span>
|
||||
<span>${col.label}</span>
|
||||
</div>
|
||||
<span class="kanban-column-count">0</span>
|
||||
</div>
|
||||
<div class="kanban-cards">
|
||||
<div class="kanban-empty">
|
||||
<svg class="kanban-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M9 9h6M9 13h6M9 17h4"/>
|
||||
</svg>
|
||||
<span>No plans</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Group plans by status
|
||||
const grouped = {};
|
||||
STATUS_COLUMNS.forEach(col => {
|
||||
grouped[col.id] = [];
|
||||
});
|
||||
|
||||
plans.forEach(plan => {
|
||||
const status = plan.status || 'pending';
|
||||
if (grouped[status]) {
|
||||
grouped[status].push(plan);
|
||||
} else {
|
||||
grouped['pending'].push(plan);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate column HTML
|
||||
return STATUS_COLUMNS.map(col => {
|
||||
const columnPlans = grouped[col.id];
|
||||
const cardsHtml = columnPlans.length > 0
|
||||
? columnPlans.map(generateKanbanCard).join('')
|
||||
: `<div class="kanban-empty">
|
||||
<svg class="kanban-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M9 9h6M9 13h6M9 17h4"/>
|
||||
</svg>
|
||||
<span>No plans</span>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="kanban-column" data-status="${col.id}">
|
||||
<div class="kanban-column-header">
|
||||
<div class="kanban-column-title">
|
||||
<span class="kanban-status-dot ${col.color}"></span>
|
||||
<span>${col.label}</span>
|
||||
</div>
|
||||
<span class="kanban-column-count">${columnPlans.length}</span>
|
||||
</div>
|
||||
<div class="kanban-cards">
|
||||
${cardsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics from plans array
|
||||
* @param {Array} plans - Array of plan metadata objects
|
||||
* @returns {Object} - Statistics object
|
||||
*/
|
||||
function calculateStats(plans) {
|
||||
const stats = {
|
||||
total: plans.length,
|
||||
completed: 0,
|
||||
inProgress: 0,
|
||||
pending: 0
|
||||
};
|
||||
|
||||
plans.forEach(plan => {
|
||||
const status = (plan.status || 'pending').replace(/\s+/g, '-');
|
||||
if (status === 'completed' || status === 'complete') {
|
||||
stats.completed++;
|
||||
} else if (status === 'in-progress') {
|
||||
stats.inProgress++;
|
||||
} else {
|
||||
stats.pending++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render complete dashboard HTML
|
||||
* @param {Array} plans - Array of plan metadata objects
|
||||
* @param {Object} options - Render options
|
||||
* @param {string} options.assetsDir - Assets directory path
|
||||
* @param {string} options.plansDir - Plans directory path
|
||||
* @returns {string} - Complete HTML page
|
||||
*/
|
||||
function renderDashboard(plans, options = {}) {
|
||||
const { assetsDir } = options;
|
||||
|
||||
// Load template
|
||||
const templatePath = path.join(assetsDir, 'dashboard-template.html');
|
||||
let template;
|
||||
|
||||
try {
|
||||
template = fs.readFileSync(templatePath, 'utf8');
|
||||
} catch (err) {
|
||||
// Fallback inline template if file not found
|
||||
template = getInlineTemplate();
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = calculateStats(plans);
|
||||
|
||||
// Generate cards
|
||||
const plansGrid = generatePlansGrid(plans);
|
||||
const planCount = plans.length;
|
||||
|
||||
// Generate timeline section
|
||||
const timelineSection = generateTimelineSection(plans);
|
||||
|
||||
// Generate kanban columns
|
||||
const kanbanColumns = generateKanbanColumns(plans);
|
||||
|
||||
// Generate JSON for client-side filtering (include rich metadata)
|
||||
const plansJson = JSON.stringify(plans.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
progress: p.progress,
|
||||
lastModified: p.lastModified,
|
||||
phasesTotal: p.phases.total,
|
||||
path: p.path, // Required for kanban card links
|
||||
// Rich metadata
|
||||
createdDate: p.createdDate,
|
||||
completedDate: p.completedDate,
|
||||
durationDays: p.durationDays,
|
||||
durationFormatted: p.durationFormatted,
|
||||
totalEffortHours: p.totalEffortHours,
|
||||
totalEffortFormatted: p.totalEffortFormatted,
|
||||
priority: p.priority,
|
||||
issue: p.issue,
|
||||
branch: p.branch,
|
||||
// New frontmatter fields
|
||||
description: p.description,
|
||||
tags: p.tags || [],
|
||||
assignee: p.assignee
|
||||
})));
|
||||
|
||||
// Replace placeholders
|
||||
template = template
|
||||
.replace(/\{\{plans-grid\}\}/g, plansGrid)
|
||||
.replace(/\{\{kanban-columns\}\}/g, kanbanColumns)
|
||||
.replace(/\{\{plan-count\}\}/g, String(planCount))
|
||||
.replace(/\{\{plans-json\}\}/g, plansJson)
|
||||
.replace(/\{\{empty-state\}\}/g, generateEmptyState())
|
||||
.replace(/\{\{timeline-section\}\}/g, timelineSection)
|
||||
.replace(/\{\{has-plans\}\}/g, plans.length > 0 ? 'plans-loaded' : '')
|
||||
.replace(/\{\{stat-total\}\}/g, String(stats.total))
|
||||
.replace(/\{\{stat-completed\}\}/g, String(stats.completed))
|
||||
.replace(/\{\{stat-in-progress\}\}/g, String(stats.inProgress))
|
||||
.replace(/\{\{stat-pending\}\}/g, String(stats.pending));
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inline fallback template
|
||||
* @returns {string} - Inline HTML template
|
||||
*/
|
||||
function getInlineTemplate() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Plans Dashboard</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/novel-theme.css">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
</head>
|
||||
<body class="dashboard-view {{has-plans}}">
|
||||
<header class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h1>Plans Dashboard</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle theme">
|
||||
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main role="main" aria-label="Plans Dashboard">
|
||||
<section class="stats-hero" aria-label="Plan statistics">
|
||||
<div class="stat-card total">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-value">{{stat-total}}</div>
|
||||
<div class="stat-label">Total Plans</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value">{{stat-completed}}</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
<div class="stat-card in-progress">
|
||||
<div class="stat-icon">🔄</div>
|
||||
<div class="stat-value">{{stat-in-progress}}</div>
|
||||
<div class="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div class="stat-card pending">
|
||||
<div class="stat-icon">⏳</div>
|
||||
<div class="stat-value">{{stat-pending}}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-controls">
|
||||
<div class="search-box">
|
||||
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="search" id="plan-search" placeholder="Search plans..." aria-label="Search plans">
|
||||
</div>
|
||||
|
||||
<select id="sort-select" aria-label="Sort plans by">
|
||||
<option value="date-desc">Newest First</option>
|
||||
<option value="date-asc">Oldest First</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="progress-desc">Most Progress</option>
|
||||
<option value="progress-asc">Least Progress</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-pills" role="group" aria-label="Filter by status">
|
||||
<button class="filter-pill active" data-filter="all" aria-pressed="true">All</button>
|
||||
<button class="filter-pill" data-filter="completed" aria-pressed="false">Completed</button>
|
||||
<button class="filter-pill" data-filter="in-progress" aria-pressed="false">In Progress</button>
|
||||
<button class="filter-pill" data-filter="pending" aria-pressed="false">Pending</button>
|
||||
</div>
|
||||
|
||||
<div role="status" aria-live="polite" class="result-count">
|
||||
Showing <strong>{{plan-count}}</strong> plans
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="plans-grid" aria-label="Plans list">
|
||||
{{plans-grid}}
|
||||
</section>
|
||||
|
||||
{{empty-state}}
|
||||
</main>
|
||||
|
||||
<script>window.__plans = {{plans-json}};</script>
|
||||
<script src="/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderDashboard,
|
||||
generatePlanCard,
|
||||
generateCardMeta,
|
||||
generateTagsPills,
|
||||
generateProgressRing,
|
||||
generateProgressBar,
|
||||
generateStatusCounts,
|
||||
generateStatusBadge,
|
||||
generateTimelineSection,
|
||||
generateEmptyState,
|
||||
generatePlansGrid,
|
||||
generateKanbanColumns,
|
||||
generateKanbanCard,
|
||||
calculateStats,
|
||||
escapeHtml,
|
||||
truncate,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
getStatusLabel,
|
||||
getPriorityColorClass,
|
||||
STATUS_COLUMNS
|
||||
};
|
||||
310
.opencode/skills/plans-kanban/scripts/lib/http-server.cjs
Normal file
310
.opencode/skills/plans-kanban/scripts/lib/http-server.cjs
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* HTTP server for plans-kanban dashboard
|
||||
* Routes: /kanban, /api/plans, /assets/*, /file/*
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
const { scanPlans } = require('./plan-scanner.cjs');
|
||||
const { renderDashboard } = require('./dashboard-renderer.cjs');
|
||||
|
||||
// Import full page renderer from markdown-novel-viewer skill
|
||||
let generateFullPage = null;
|
||||
let mdViewerAssetsDir = null;
|
||||
try {
|
||||
const mdViewerDir = path.join(__dirname, '..', '..', '..', 'markdown-novel-viewer');
|
||||
mdViewerAssetsDir = path.join(mdViewerDir, 'assets');
|
||||
// We need to call the server's generateFullPage function
|
||||
// Since it's not exported, we'll create a minimal wrapper
|
||||
const { renderMarkdownFile, renderTOCHtml } = require(path.join(mdViewerDir, 'scripts', 'lib', 'markdown-renderer.cjs'));
|
||||
const { generateNavSidebar, generateNavFooter, detectPlan } = require(path.join(mdViewerDir, 'scripts', 'lib', 'plan-navigator.cjs'));
|
||||
|
||||
generateFullPage = (filePath, options = {}) => {
|
||||
const { html, toc, frontmatter, title } = renderMarkdownFile(filePath);
|
||||
const tocHtml = renderTOCHtml(toc);
|
||||
const navSidebar = generateNavSidebar(filePath);
|
||||
const navFooter = generateNavFooter(filePath);
|
||||
const planInfo = detectPlan(filePath);
|
||||
const { getNavigationContext } = require(path.join(mdViewerDir, 'scripts', 'lib', 'plan-navigator.cjs'));
|
||||
const navContext = getNavigationContext(filePath);
|
||||
|
||||
const templatePath = path.join(mdViewerAssetsDir, 'template.html');
|
||||
let template = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Generate back button for kanban
|
||||
const backUrl = options.dashboardUrl || '/kanban';
|
||||
const backButton = `
|
||||
<a href="${backUrl}" class="icon-btn back-btn" title="Back to Dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</a>`;
|
||||
|
||||
// Generate header nav (prev/next)
|
||||
let headerNav = '';
|
||||
if (navContext.prev || navContext.next) {
|
||||
const prevBtn = navContext.prev && fs.existsSync(navContext.prev.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.prev.file)}" class="header-nav-btn prev" title="${navContext.prev.name}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<span>Prev</span>
|
||||
</a>`
|
||||
: '';
|
||||
const nextBtn = navContext.next && fs.existsSync(navContext.next.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.next.file)}" class="header-nav-btn next" title="${navContext.next.name}">
|
||||
<span>Next</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</a>`
|
||||
: '';
|
||||
headerNav = `<div class="header-nav">${prevBtn}${nextBtn}</div>`;
|
||||
}
|
||||
|
||||
template = template
|
||||
.replace(/\{\{title\}\}/g, title)
|
||||
.replace('{{toc}}', tocHtml)
|
||||
.replace('{{nav-sidebar}}', navSidebar)
|
||||
.replace('{{nav-footer}}', navFooter)
|
||||
.replace('{{content}}', html)
|
||||
.replace('{{has-plan}}', planInfo.isPlan ? 'has-plan' : '')
|
||||
.replace('{{frontmatter}}', JSON.stringify(frontmatter || {}))
|
||||
.replace('{{back-button}}', backButton)
|
||||
.replace('{{header-nav}}', headerNav);
|
||||
|
||||
return template;
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[http-server] Could not load markdown renderer:', err.message);
|
||||
}
|
||||
|
||||
// Allowed base directories for file access
|
||||
let allowedBaseDirs = [];
|
||||
|
||||
/**
|
||||
* Set allowed directories for file serving
|
||||
* @param {string[]} dirs - Array of allowed directory paths
|
||||
*/
|
||||
function setAllowedDirs(dirs) {
|
||||
allowedBaseDirs = dirs.map(d => path.resolve(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path is within allowed directories
|
||||
* @param {string} filePath - Path to validate
|
||||
* @returns {boolean} - True if path is safe
|
||||
*/
|
||||
function isPathSafe(filePath) {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (resolved.includes('..') || filePath.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
if (allowedBaseDirs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowedBaseDirs.some(dir => resolved.startsWith(dir));
|
||||
}
|
||||
|
||||
// MIME types
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon'
|
||||
};
|
||||
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sendResponse(res, statusCode, contentType, content) {
|
||||
res.writeHead(statusCode, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
function sendError(res, statusCode, message) {
|
||||
sendResponse(res, statusCode, 'text/html', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error ${statusCode}</title></head>
|
||||
<body style="font-family: system-ui; padding: 2rem;">
|
||||
<h1>Error ${statusCode}</h1>
|
||||
<p>${message}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
function serveFile(res, filePath, skipValidation = false) {
|
||||
if (!skipValidation && !isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath);
|
||||
sendResponse(res, 200, getMimeType(filePath), content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP server for kanban dashboard
|
||||
* @param {Object} options - Server options
|
||||
* @param {string} options.assetsDir - Static assets directory
|
||||
* @param {string[]} options.allowedDirs - Allowed directories for file access
|
||||
* @param {string} options.plansDir - Plans directory for dashboard
|
||||
* @returns {http.Server}
|
||||
*/
|
||||
function createHttpServer(options) {
|
||||
const { assetsDir, allowedDirs = [], plansDir } = options;
|
||||
|
||||
if (allowedDirs.length > 0) {
|
||||
setAllowedDirs(allowedDirs);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
const pathname = decodeURIComponent(parsedUrl.pathname);
|
||||
|
||||
// Route: /assets/* - serve static files (check kanban assets first, then markdown-viewer assets)
|
||||
if (pathname.startsWith('/assets/')) {
|
||||
const relativePath = pathname.replace('/assets/', '');
|
||||
if (relativePath.includes('..')) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
// Check kanban assets first
|
||||
let assetPath = path.join(assetsDir, relativePath);
|
||||
if (!fs.existsSync(assetPath) && mdViewerAssetsDir) {
|
||||
// Fallback to markdown-novel-viewer assets
|
||||
assetPath = path.join(mdViewerAssetsDir, relativePath);
|
||||
}
|
||||
serveFile(res, assetPath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /file/* - serve local files (images, etc.)
|
||||
if (pathname.startsWith('/file/')) {
|
||||
const filePath = pathname.replace('/file', '');
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
serveFile(res, filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /api/plans - JSON API for plans data
|
||||
if (pathname === '/api/plans') {
|
||||
const customDir = parsedUrl.query?.dir;
|
||||
const dir = customDir || plansDir;
|
||||
|
||||
if (customDir && !isPathSafe(customDir)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
sendResponse(res, 200, 'application/json', JSON.stringify({ plans: [], error: 'Plans directory not configured' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = scanPlans(dir);
|
||||
sendResponse(res, 200, 'application/json', JSON.stringify({ plans }));
|
||||
} catch (err) {
|
||||
console.error('[http-server] API error:', err.message);
|
||||
sendResponse(res, 500, 'application/json', JSON.stringify({ error: 'Error scanning plans' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: / or /kanban - render dashboard
|
||||
if (pathname === '/' || pathname === '/kanban') {
|
||||
const customDir = parsedUrl.query?.dir;
|
||||
const dir = customDir || plansDir;
|
||||
|
||||
if (customDir && !isPathSafe(customDir)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
sendError(res, 400, 'Plans directory not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = scanPlans(dir);
|
||||
const html = renderDashboard(plans, { assetsDir, plansDir: dir });
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Dashboard error:', err.message);
|
||||
sendError(res, 500, 'Error rendering dashboard');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /view?file=<path> - render markdown file
|
||||
if (pathname === '/view') {
|
||||
const filePath = parsedUrl.query?.file;
|
||||
|
||||
if (!filePath) {
|
||||
sendError(res, 400, 'Missing ?file= parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!generateFullPage) {
|
||||
sendError(res, 500, 'Markdown renderer not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build dashboard URL with the plans directory
|
||||
const dashboardUrl = `/kanban?dir=${encodeURIComponent(plansDir)}`;
|
||||
const html = generateFullPage(filePath, { dashboardUrl });
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Render error:', err.message);
|
||||
sendError(res, 500, 'Error rendering markdown');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: 404
|
||||
sendError(res, 404, 'Not found');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createHttpServer,
|
||||
getMimeType,
|
||||
sendResponse,
|
||||
sendError,
|
||||
serveFile,
|
||||
isPathSafe,
|
||||
setAllowedDirs,
|
||||
MIME_TYPES
|
||||
};
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Plan Metadata Extractor
|
||||
* Extracts rich metadata from plan.md files including dates, effort, priority, issues
|
||||
* Supports YAML frontmatter (primary) with regex fallback for legacy plans
|
||||
*
|
||||
* @module plan-metadata-extractor
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const matter = require('gray-matter');
|
||||
|
||||
// Import shared normalizeStatus from plan-table-parser (DRY — single source of truth)
|
||||
const sharedParser = require('../../../_shared/lib/plan-table-parser.cjs');
|
||||
|
||||
/**
|
||||
* Normalize status string to standard format.
|
||||
* Delegates to shared parser for core statuses (pending|in-progress|completed),
|
||||
* then extends with metadata-specific statuses (cancelled, in-review).
|
||||
* @param {string} status - Raw status string
|
||||
* @returns {string} - Normalized status (pending|in-progress|completed|cancelled|in-review)
|
||||
*/
|
||||
function normalizeStatus(status) {
|
||||
if (!status) return 'pending';
|
||||
const s = String(status).toLowerCase().trim();
|
||||
// Metadata-specific statuses not in the shared parser
|
||||
if (s === 'cancelled' || s === 'canceled') return 'cancelled';
|
||||
if (s === 'in-review' || s === 'review') return 'in-review';
|
||||
// Delegate to shared parser for standard statuses
|
||||
return sharedParser.normalizeStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize priority to P1/P2/P3 format
|
||||
* @param {string} priority - Raw priority string
|
||||
* @returns {string|null} - Normalized priority (P1|P2|P3) or null
|
||||
*/
|
||||
function normalizePriority(priority) {
|
||||
if (!priority) return null;
|
||||
const p = String(priority).toUpperCase().trim();
|
||||
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') return 'P1';
|
||||
if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') return 'P2';
|
||||
if (p === 'P3' || p === 'LOW') return 'P3';
|
||||
if (p.match(/^P[0-3]$/)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from YAML frontmatter (primary method)
|
||||
* @param {string} content - File content
|
||||
* @returns {Object|null} - Extracted metadata or null if no frontmatter
|
||||
*/
|
||||
function extractFromFrontmatter(content) {
|
||||
if (!content || !content.trim().startsWith('---')) return null;
|
||||
|
||||
try {
|
||||
const { data } = matter(content);
|
||||
if (!data || Object.keys(data).length === 0) return null;
|
||||
|
||||
return {
|
||||
title: data.title || null,
|
||||
description: data.description || null,
|
||||
status: normalizeStatus(data.status),
|
||||
priority: normalizePriority(data.priority),
|
||||
effort: data.effort || null,
|
||||
issue: data.issue ? String(data.issue) : null,
|
||||
branch: data.branch || null,
|
||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||
createdDate: data.created ? new Date(data.created) : null,
|
||||
completedDate: data.completed ? new Date(data.completed) : null,
|
||||
assignee: data.assignee || null
|
||||
};
|
||||
} catch (e) {
|
||||
// YAML parse error - fall back to regex
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract description from ## Overview section
|
||||
* @param {string} content - File content
|
||||
* @returns {string|null} - First paragraph of Overview section or null
|
||||
*/
|
||||
function extractDescriptionFromOverview(content) {
|
||||
if (!content) return null;
|
||||
|
||||
// Look for ## Overview section
|
||||
const overviewMatch = content.match(/##\s*Overview\s*\n+([^\n#]+)/i);
|
||||
if (overviewMatch) {
|
||||
const desc = overviewMatch[1].trim();
|
||||
// Return first sentence or first 150 chars
|
||||
const firstSentence = desc.match(/^[^.!?]+[.!?]/);
|
||||
if (firstSentence) return firstSentence[0].trim();
|
||||
return desc.slice(0, 150).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from plan directory name (YYMMDD or YYYYMMDD format)
|
||||
* @param {string} dirName - Directory name like "251211-feature-name"
|
||||
* @returns {Date|null} - Parsed date or null
|
||||
*/
|
||||
function parseDateFromDirName(dirName) {
|
||||
// Match YYMMDD-HHMM- or YYMMDD- or YYYYMMDD-
|
||||
const match = dirName.match(/^(\d{6,8})(?:-(\d{4}))?-/);
|
||||
if (!match) return null;
|
||||
|
||||
const dateStr = match[1];
|
||||
let year, month, day;
|
||||
|
||||
if (dateStr.length === 6) {
|
||||
// YYMMDD format
|
||||
year = 2000 + parseInt(dateStr.slice(0, 2), 10);
|
||||
month = parseInt(dateStr.slice(2, 4), 10) - 1;
|
||||
day = parseInt(dateStr.slice(4, 6), 10);
|
||||
} else {
|
||||
// YYYYMMDD format
|
||||
year = parseInt(dateStr.slice(0, 4), 10);
|
||||
month = parseInt(dateStr.slice(4, 6), 10) - 1;
|
||||
day = parseInt(dateStr.slice(6, 8), 10);
|
||||
}
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from plan.md header section
|
||||
* Looks for patterns like **Key:** Value or **Key**: Value
|
||||
* @param {string} content - Plan file content
|
||||
* @returns {Object} - Extracted metadata
|
||||
*/
|
||||
function extractHeaderMetadata(content) {
|
||||
const metadata = {
|
||||
createdDate: null,
|
||||
completedDate: null,
|
||||
priority: null,
|
||||
issue: null,
|
||||
branch: null,
|
||||
planId: null,
|
||||
headerStatus: null
|
||||
};
|
||||
|
||||
// Only look at first ~50 lines for header metadata
|
||||
const headerSection = content.split('\n').slice(0, 50).join('\n');
|
||||
|
||||
// **Created:** 2025-12-01 or **Date:** 2025-12-11
|
||||
const createdMatch = headerSection.match(/\*\*(?:Created|Date):?\*\*:?\s*(\d{4}-\d{2}-\d{2})/i);
|
||||
if (createdMatch) {
|
||||
metadata.createdDate = new Date(createdMatch[1]);
|
||||
}
|
||||
|
||||
// **Status:** ✓ Complete (2025-12-01) - extract completion date
|
||||
const statusMatch = headerSection.match(/\*\*Status:?\*\*:?\s*(.+)/i);
|
||||
if (statusMatch) {
|
||||
metadata.headerStatus = statusMatch[1].trim();
|
||||
const completedMatch = statusMatch[1].match(/(?:complete|done).*?(\d{4}-\d{2}-\d{2})/i);
|
||||
if (completedMatch) {
|
||||
metadata.completedDate = new Date(completedMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// **Priority:** P1 - High
|
||||
const priorityMatch = headerSection.match(/\*\*Priority:?\*\*:?\s*(P[0-3]|High|Medium|Low)/i);
|
||||
if (priorityMatch) {
|
||||
metadata.priority = priorityMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
// **Issue:** #74 or **Issue**: https://github.com/.../issues/74
|
||||
const issueMatch = headerSection.match(/\*\*Issue:?\*\*:?\s*(?:#(\d+)|.*?issues\/(\d+))/i);
|
||||
if (issueMatch) {
|
||||
metadata.issue = issueMatch[1] || issueMatch[2];
|
||||
}
|
||||
|
||||
// **Branch:** `kai/feat/feature-name` or **Branch:** kai/feat/feature-name
|
||||
const branchMatch = headerSection.match(/\*\*Branch:?\*\*:?\s*`?([^`\n]+)`?/i);
|
||||
if (branchMatch) {
|
||||
metadata.branch = branchMatch[1].trim();
|
||||
}
|
||||
|
||||
// **Plan ID:** 20251201-1849-cli-ui-enhancement
|
||||
const planIdMatch = headerSection.match(/\*\*Plan ID\*\*:?\s*(\S+)/i);
|
||||
if (planIdMatch) {
|
||||
metadata.planId = planIdMatch[1].trim();
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse effort/time string to hours
|
||||
* @param {string} effortStr - Effort string like "4h", "2 hours", "30m", "1.5h"
|
||||
* @returns {number} - Hours as decimal
|
||||
*/
|
||||
function parseEffortToHours(effortStr) {
|
||||
if (!effortStr) return 0;
|
||||
|
||||
const str = effortStr.toLowerCase().trim();
|
||||
|
||||
// Match patterns: 4h, 4 hours, 4hr, 30m, 30 min, 1.5h
|
||||
const hoursMatch = str.match(/(\d+(?:\.\d+)?)\s*(?:h|hours?|hrs?)/);
|
||||
if (hoursMatch) {
|
||||
return parseFloat(hoursMatch[1]);
|
||||
}
|
||||
|
||||
const minutesMatch = str.match(/(\d+)\s*(?:m|min|minutes?)/);
|
||||
if (minutesMatch) {
|
||||
return parseInt(minutesMatch[1], 10) / 60;
|
||||
}
|
||||
|
||||
const daysMatch = str.match(/(\d+(?:\.\d+)?)\s*(?:d|days?)/);
|
||||
if (daysMatch) {
|
||||
return parseFloat(daysMatch[1]) * 8; // Assume 8h work day
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract effort estimates from phase table
|
||||
* Looks for Effort/Time/Estimate columns in tables
|
||||
* @param {string} content - Plan file content
|
||||
* @returns {{totalEffort: number, phaseEfforts: Array<{phase: number, effort: number, effortStr: string}>}}
|
||||
*/
|
||||
function extractEffortFromTable(content) {
|
||||
const result = {
|
||||
totalEffort: 0,
|
||||
phaseEfforts: []
|
||||
};
|
||||
|
||||
// Match table rows with effort column
|
||||
// Pattern: | Phase | Description | Status | Effort |
|
||||
// Or: | [Phase 1](path) | Description | Status | 4h |
|
||||
const tableRowRegex = /\|[^|]*\|[^|]*\|[^|]*\|\s*(\d+(?:\.\d+)?\s*(?:h|m|d|hours?|min|days?)?)\s*\|/gi;
|
||||
|
||||
let match;
|
||||
let phaseNum = 1;
|
||||
while ((match = tableRowRegex.exec(content)) !== null) {
|
||||
const effortStr = match[1].trim();
|
||||
const effort = parseEffortToHours(effortStr);
|
||||
if (effort > 0) {
|
||||
result.phaseEfforts.push({
|
||||
phase: phaseNum,
|
||||
effort,
|
||||
effortStr
|
||||
});
|
||||
result.totalEffort += effort;
|
||||
phaseNum++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate plan duration in days
|
||||
* @param {Date} startDate - Start date
|
||||
* @param {Date} endDate - End date (or now if not completed)
|
||||
* @returns {number} - Duration in days
|
||||
*/
|
||||
function calculateDuration(startDate, endDate) {
|
||||
if (!startDate) return 0;
|
||||
const end = endDate || new Date();
|
||||
const diffMs = end.getTime() - startDate.getTime();
|
||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration as human-readable string
|
||||
* @param {number} days - Duration in days
|
||||
* @returns {string} - Formatted string like "3 days" or "2 weeks"
|
||||
*/
|
||||
function formatDuration(days) {
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return '1 day';
|
||||
if (days < 7) return `${days} days`;
|
||||
if (days < 14) return '1 week';
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks`;
|
||||
if (days < 60) return '1 month';
|
||||
return `${Math.floor(days / 30)} months`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all rich metadata from a plan
|
||||
* Tries YAML frontmatter first, falls back to regex extraction
|
||||
* @param {string} planFilePath - Path to plan.md
|
||||
* @returns {Object} - Complete metadata object
|
||||
*/
|
||||
function extractPlanMetadata(planFilePath) {
|
||||
const content = fs.readFileSync(planFilePath, 'utf8');
|
||||
const dir = path.dirname(planFilePath);
|
||||
const dirName = path.basename(dir);
|
||||
const stats = fs.statSync(planFilePath);
|
||||
|
||||
// Try YAML frontmatter first (new format)
|
||||
const frontmatter = extractFromFrontmatter(content);
|
||||
|
||||
// Get header metadata (legacy regex extraction)
|
||||
const headerMeta = extractHeaderMetadata(content);
|
||||
|
||||
// Get date from directory name as fallback
|
||||
const dirDate = parseDateFromDirName(dirName);
|
||||
|
||||
// Merge frontmatter with regex fallback
|
||||
// Frontmatter takes priority, regex fills gaps
|
||||
const createdDate = frontmatter?.createdDate || headerMeta.createdDate || dirDate || null;
|
||||
const completedDate = frontmatter?.completedDate || headerMeta.completedDate || null;
|
||||
const priority = frontmatter?.priority || normalizePriority(headerMeta.priority);
|
||||
const issue = frontmatter?.issue || headerMeta.issue;
|
||||
const branch = frontmatter?.branch || headerMeta.branch;
|
||||
|
||||
// Extract description: frontmatter > Overview section
|
||||
const description = frontmatter?.description || extractDescriptionFromOverview(content);
|
||||
|
||||
// Tags from frontmatter only (no regex extraction for tags)
|
||||
const tags = frontmatter?.tags || [];
|
||||
|
||||
// Get effort data from table
|
||||
const effortData = extractEffortFromTable(content);
|
||||
// Override with frontmatter effort if present
|
||||
let totalEffort = effortData.totalEffort;
|
||||
if (frontmatter?.effort) {
|
||||
const parsed = parseEffortToHours(frontmatter.effort);
|
||||
if (parsed > 0) totalEffort = parsed;
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
const duration = calculateDuration(createdDate, completedDate);
|
||||
|
||||
return {
|
||||
// Dates
|
||||
createdDate: createdDate ? createdDate.toISOString() : null,
|
||||
completedDate: completedDate ? completedDate.toISOString() : null,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
|
||||
// Duration
|
||||
durationDays: duration,
|
||||
durationFormatted: formatDuration(duration),
|
||||
isCompleted: !!completedDate,
|
||||
|
||||
// Effort
|
||||
totalEffortHours: totalEffort,
|
||||
totalEffortFormatted: totalEffort > 0
|
||||
? `${totalEffort.toFixed(1)}h`
|
||||
: null,
|
||||
phaseEfforts: effortData.phaseEfforts,
|
||||
|
||||
// Metadata (merged from frontmatter + regex)
|
||||
title: frontmatter?.title || null,
|
||||
description,
|
||||
priority,
|
||||
issue,
|
||||
branch,
|
||||
tags,
|
||||
assignee: frontmatter?.assignee || null,
|
||||
planId: headerMeta.planId,
|
||||
// Use frontmatter status if available, otherwise regex-extracted header status
|
||||
headerStatus: frontmatter?.status || headerMeta.headerStatus,
|
||||
|
||||
// Source indicator for debugging
|
||||
hasFrontmatter: !!frontmatter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate timeline data for multiple plans
|
||||
* @param {Array} plans - Array of plan objects with metadata
|
||||
* @returns {Object} - Timeline statistics
|
||||
*/
|
||||
function generateTimelineStats(plans) {
|
||||
const now = new Date();
|
||||
const stats = {
|
||||
totalPlans: plans.length,
|
||||
completedPlans: 0,
|
||||
activePlans: 0,
|
||||
pendingPlans: 0,
|
||||
avgDurationDays: 0,
|
||||
longestPlan: null,
|
||||
totalEffortHours: 0,
|
||||
completedEffortHours: 0,
|
||||
thisWeekCompleted: 0,
|
||||
thisMonthCompleted: 0
|
||||
};
|
||||
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
|
||||
for (const plan of plans) {
|
||||
// Count by status
|
||||
if (plan.status === 'completed') {
|
||||
stats.completedPlans++;
|
||||
if (plan.completedDate) {
|
||||
const completed = new Date(plan.completedDate);
|
||||
if (completed >= weekAgo) stats.thisWeekCompleted++;
|
||||
if (completed >= monthAgo) stats.thisMonthCompleted++;
|
||||
}
|
||||
if (plan.totalEffortHours) {
|
||||
stats.completedEffortHours += plan.totalEffortHours;
|
||||
}
|
||||
} else if (plan.status === 'in-progress') {
|
||||
stats.activePlans++;
|
||||
} else {
|
||||
stats.pendingPlans++;
|
||||
}
|
||||
|
||||
// Track duration
|
||||
if (plan.durationDays > 0) {
|
||||
totalDuration += plan.durationDays;
|
||||
durationCount++;
|
||||
if (!stats.longestPlan || plan.durationDays > stats.longestPlan.durationDays) {
|
||||
stats.longestPlan = { name: plan.name, durationDays: plan.durationDays };
|
||||
}
|
||||
}
|
||||
|
||||
// Sum effort
|
||||
if (plan.totalEffortHours) {
|
||||
stats.totalEffortHours += plan.totalEffortHours;
|
||||
}
|
||||
}
|
||||
|
||||
stats.avgDurationDays = durationCount > 0
|
||||
? Math.round(totalDuration / durationCount)
|
||||
: 0;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate activity data for heatmap (last 12 weeks)
|
||||
* @param {Array} plans - Array of plan objects with metadata
|
||||
* @returns {Array} - Array of weekly activity counts
|
||||
*/
|
||||
function generateActivityHeatmap(plans) {
|
||||
const now = new Date();
|
||||
const weeks = [];
|
||||
|
||||
// Generate 12 weeks of data
|
||||
for (let w = 11; w >= 0; w--) {
|
||||
const weekStart = new Date(now.getTime() - w * 7 * 24 * 60 * 60 * 1000);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Start of week (Sunday)
|
||||
|
||||
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let activity = 0;
|
||||
for (const plan of plans) {
|
||||
// Count plans created or completed in this week
|
||||
if (plan.createdDate) {
|
||||
const created = new Date(plan.createdDate);
|
||||
if (created >= weekStart && created < weekEnd) activity++;
|
||||
}
|
||||
if (plan.completedDate) {
|
||||
const completed = new Date(plan.completedDate);
|
||||
if (completed >= weekStart && completed < weekEnd) activity++;
|
||||
}
|
||||
}
|
||||
|
||||
weeks.push({
|
||||
weekStart: weekStart.toISOString(),
|
||||
weekLabel: weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
activity,
|
||||
level: activity === 0 ? 0 : activity === 1 ? 1 : activity <= 3 ? 2 : 3
|
||||
});
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Core extraction functions
|
||||
extractPlanMetadata,
|
||||
extractFromFrontmatter,
|
||||
extractDescriptionFromOverview,
|
||||
extractHeaderMetadata,
|
||||
extractEffortFromTable,
|
||||
|
||||
// Normalization helpers
|
||||
normalizeStatus,
|
||||
normalizePriority,
|
||||
|
||||
// Date/time utilities
|
||||
parseDateFromDirName,
|
||||
parseEffortToHours,
|
||||
calculateDuration,
|
||||
formatDuration,
|
||||
|
||||
// Statistics generators
|
||||
generateTimelineStats,
|
||||
generateActivityHeatmap
|
||||
};
|
||||
21
.opencode/skills/plans-kanban/scripts/lib/plan-parser.cjs
Normal file
21
.opencode/skills/plans-kanban/scripts/lib/plan-parser.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Plan Parser - parses plan.md files to extract phase metadata
|
||||
* Delegates to shared parser module for format support
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePlanPhases, normalizeStatus } = require('../../../_shared/lib/plan-table-parser.cjs');
|
||||
|
||||
/**
|
||||
* Parse plan.md to extract phase metadata
|
||||
* @param {string} planFilePath - Path to plan.md
|
||||
* @returns {Array<{phase: number, phaseId: string, name: string, status: string, file: string, linkText: string}>}
|
||||
*/
|
||||
function parsePlanTable(planFilePath) {
|
||||
const content = fs.readFileSync(planFilePath, 'utf8');
|
||||
const dir = path.dirname(planFilePath);
|
||||
return parsePlanPhases(content, dir);
|
||||
}
|
||||
|
||||
module.exports = { parsePlanTable, normalizeStatus };
|
||||
272
.opencode/skills/plans-kanban/scripts/lib/plan-scanner.cjs
Normal file
272
.opencode/skills/plans-kanban/scripts/lib/plan-scanner.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Plan Scanner Utility
|
||||
* Recursively discovers plan directories and aggregates metadata for dashboard view
|
||||
*
|
||||
* @module plan-scanner
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePlanTable } = require('./plan-parser.cjs');
|
||||
const {
|
||||
extractPlanMetadata,
|
||||
generateTimelineStats,
|
||||
generateActivityHeatmap,
|
||||
normalizeStatus
|
||||
} = require('./plan-metadata-extractor.cjs');
|
||||
|
||||
/**
|
||||
* Calculate progress statistics from phases array
|
||||
* @param {Array<{status: string}>} phases - Array of phase objects with status
|
||||
* @returns {{total: number, completed: number, inProgress: number, pending: number, percentage: number}}
|
||||
*/
|
||||
function calculateProgress(phases) {
|
||||
if (!phases || phases.length === 0) {
|
||||
return { total: 0, completed: 0, inProgress: 0, pending: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: phases.length,
|
||||
completed: 0,
|
||||
inProgress: 0,
|
||||
pending: 0
|
||||
};
|
||||
|
||||
for (const phase of phases) {
|
||||
const status = (phase.status || '').toLowerCase();
|
||||
if (status === 'completed' || status === 'done') {
|
||||
stats.completed++;
|
||||
} else if (status === 'in-progress' || status === 'in progress' || status === 'active') {
|
||||
stats.inProgress++;
|
||||
} else {
|
||||
stats.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
stats.percentage = stats.total > 0
|
||||
? Math.round((stats.completed / stats.total) * 100)
|
||||
: 0;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse plan name from directory name (strip date prefix)
|
||||
* @param {string} dirName - Directory name like "251211-feature-name"
|
||||
* @returns {string} - Human readable name like "Feature Name"
|
||||
*/
|
||||
function parsePlanName(dirName) {
|
||||
// Remove date prefix: YYMMDD-, YYYYMMDD-, YYMMDD-HHMM-, YYYYMMDD-HHMM-
|
||||
const withoutDate = dirName.replace(/^\d{6,8}(-\d{4})?-/, '');
|
||||
|
||||
// Convert kebab-case to Title Case
|
||||
return withoutDate
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive overall status from phase statistics or header status
|
||||
* @param {{completed: number, inProgress: number, pending: number, total: number}} stats
|
||||
* @param {string} [headerStatus] - Optional status from plan header (e.g., **Status:** completed)
|
||||
* @returns {'completed' | 'in-progress' | 'in-review' | 'cancelled' | 'pending'}
|
||||
*/
|
||||
function deriveStatus(stats, headerStatus) {
|
||||
// If header explicitly defines status, use it (normalized)
|
||||
if (headerStatus) {
|
||||
const normalized = headerStatus.toLowerCase().trim();
|
||||
if (normalized.includes('complete') || normalized.includes('done')) {
|
||||
return 'completed';
|
||||
}
|
||||
if (normalized.includes('review')) {
|
||||
return 'in-review';
|
||||
}
|
||||
if (normalized.includes('cancel')) {
|
||||
return 'cancelled';
|
||||
}
|
||||
if (normalized.includes('progress') || normalized.includes('active')) {
|
||||
return 'in-progress';
|
||||
}
|
||||
if (normalized.includes('pending') || normalized.includes('todo') || normalized.includes('planned')) {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise derive from phase stats
|
||||
if (stats.completed === stats.total && stats.total > 0) {
|
||||
return 'completed';
|
||||
}
|
||||
if (stats.inProgress > 0 || stats.completed > 0) {
|
||||
return 'in-progress';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a single plan
|
||||
* @param {string} planFilePath - Absolute path to plan.md
|
||||
* @returns {Object|null} - Plan metadata object or null if invalid
|
||||
*/
|
||||
function getPlanMetadata(planFilePath) {
|
||||
try {
|
||||
if (!fs.existsSync(planFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directory = path.dirname(planFilePath);
|
||||
const dirName = path.basename(directory);
|
||||
const stats = fs.statSync(planFilePath);
|
||||
|
||||
// Parse phases from plan table
|
||||
const phases = parsePlanTable(planFilePath);
|
||||
const progress = calculateProgress(phases);
|
||||
|
||||
// Extract rich metadata (dates, effort, priority, etc.)
|
||||
const richMeta = extractPlanMetadata(planFilePath);
|
||||
|
||||
return {
|
||||
id: dirName,
|
||||
name: parsePlanName(dirName),
|
||||
path: planFilePath,
|
||||
directory: directory,
|
||||
phases: progress,
|
||||
progress: progress.percentage,
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
// Use frontmatter status if hasFrontmatter (already normalized), otherwise derive from phases
|
||||
status: richMeta.hasFrontmatter && richMeta.headerStatus
|
||||
? normalizeStatus(richMeta.headerStatus)
|
||||
: deriveStatus(progress, richMeta.headerStatus),
|
||||
// Rich metadata
|
||||
createdDate: richMeta.createdDate,
|
||||
completedDate: richMeta.completedDate,
|
||||
durationDays: richMeta.durationDays,
|
||||
durationFormatted: richMeta.durationFormatted,
|
||||
totalEffortHours: richMeta.totalEffortHours,
|
||||
totalEffortFormatted: richMeta.totalEffortFormatted,
|
||||
priority: richMeta.priority,
|
||||
issue: richMeta.issue,
|
||||
branch: richMeta.branch,
|
||||
// New frontmatter fields
|
||||
description: richMeta.description,
|
||||
tags: richMeta.tags || [],
|
||||
assignee: richMeta.assignee,
|
||||
title: richMeta.title
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[plan-scanner] Error reading plan: ${planFilePath}`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is safe (within allowed directory, no traversal)
|
||||
* @param {string} targetPath - Path to check
|
||||
* @param {string} baseDir - Allowed base directory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPathSafe(targetPath, baseDir) {
|
||||
const resolved = path.resolve(targetPath);
|
||||
const resolvedBase = path.resolve(baseDir);
|
||||
|
||||
// Must start with base directory
|
||||
if (!resolved.startsWith(resolvedBase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No null bytes
|
||||
if (targetPath.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory for plan.md files
|
||||
* @param {string} plansDir - Root directory to scan (e.g., ./plans)
|
||||
* @param {Object} options - Scan options
|
||||
* @param {number} options.maxDepth - Maximum recursion depth (default: 2)
|
||||
* @param {string[]} options.exclude - Directory names to exclude (default: ['node_modules', '.git', 'templates', 'reports', 'research'])
|
||||
* @returns {Array<Object>} - Array of plan metadata objects sorted by lastModified desc
|
||||
*/
|
||||
function scanPlans(plansDir, options = {}) {
|
||||
const {
|
||||
maxDepth = 2,
|
||||
exclude = ['node_modules', '.git', 'templates', 'reports', 'research']
|
||||
} = options;
|
||||
|
||||
const resolvedBase = path.resolve(plansDir);
|
||||
const plans = [];
|
||||
|
||||
/**
|
||||
* Recursive directory scanner
|
||||
* @param {string} dir - Current directory
|
||||
* @param {number} depth - Current depth
|
||||
*/
|
||||
function scanDir(dir, depth) {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
// Security: validate path
|
||||
if (!isPathSafe(dir, resolvedBase)) {
|
||||
console.error(`[plan-scanner] Path traversal blocked: ${dir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
console.error(`[plan-scanner] Cannot read directory: ${dir}`, err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip excluded directories
|
||||
if (exclude.includes(entry.name)) continue;
|
||||
|
||||
// Skip hidden directories
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Check for plan.md in this directory
|
||||
const planFile = path.join(fullPath, 'plan.md');
|
||||
if (fs.existsSync(planFile)) {
|
||||
const metadata = getPlanMetadata(planFile);
|
||||
if (metadata) {
|
||||
plans.push(metadata);
|
||||
}
|
||||
} else {
|
||||
// Recurse into subdirectory
|
||||
scanDir(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start scanning
|
||||
if (fs.existsSync(resolvedBase)) {
|
||||
scanDir(resolvedBase, 0);
|
||||
} else {
|
||||
console.error(`[plan-scanner] Plans directory not found: ${plansDir}`);
|
||||
}
|
||||
|
||||
// Sort by lastModified descending (newest first)
|
||||
plans.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanPlans,
|
||||
getPlanMetadata,
|
||||
calculateProgress,
|
||||
parsePlanName,
|
||||
deriveStatus,
|
||||
isPathSafe,
|
||||
// Re-export timeline helpers
|
||||
generateTimelineStats,
|
||||
generateActivityHeatmap
|
||||
};
|
||||
48
.opencode/skills/plans-kanban/scripts/lib/port-finder.cjs
Normal file
48
.opencode/skills/plans-kanban/scripts/lib/port-finder.cjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Port finder utility for plans-kanban server
|
||||
* Uses port range 3500-3550 to avoid conflicts with markdown-novel-viewer
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
|
||||
const DEFAULT_PORT = 3500;
|
||||
const PORT_RANGE_END = 3550;
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
* @param {number} port - Port to check
|
||||
* @returns {Promise<boolean>} - True if available
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first available port in range
|
||||
* @param {number} startPort - Starting port (default: 3500)
|
||||
* @returns {Promise<number>} - Available port
|
||||
* @throws {Error} - If no port available in range
|
||||
*/
|
||||
async function findAvailablePort(startPort = DEFAULT_PORT) {
|
||||
for (let port = startPort; port <= PORT_RANGE_END; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port in range ${startPort}-${PORT_RANGE_END}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
DEFAULT_PORT,
|
||||
PORT_RANGE_END
|
||||
};
|
||||
128
.opencode/skills/plans-kanban/scripts/lib/process-mgr.cjs
Normal file
128
.opencode/skills/plans-kanban/scripts/lib/process-mgr.cjs
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Process manager for plans-kanban server
|
||||
* Handles PID files and server lifecycle
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Cross-platform temp directory for PID files
|
||||
const PID_DIR = os.tmpdir();
|
||||
const PID_PREFIX = 'plans-kanban-';
|
||||
|
||||
/**
|
||||
* Get PID file path for a port
|
||||
* @param {number} port - Server port
|
||||
* @returns {string} - PID file path
|
||||
*/
|
||||
function getPidFilePath(port) {
|
||||
return path.join(PID_DIR, `${PID_PREFIX}${port}.pid`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write PID file for running server
|
||||
* @param {number} port - Server port
|
||||
* @param {number} pid - Process ID
|
||||
*/
|
||||
function writePidFile(port, pid) {
|
||||
fs.writeFileSync(getPidFilePath(port), String(pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PID from file
|
||||
* @param {number} port - Server port
|
||||
* @returns {number|null} - PID or null if not found
|
||||
*/
|
||||
function readPidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
return parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove PID file
|
||||
* @param {number} port - Server port
|
||||
*/
|
||||
function removePidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
fs.unlinkSync(pidPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all running kanban server instances
|
||||
* @returns {Array<{port: number, pid: number}>}
|
||||
*/
|
||||
function findRunningInstances() {
|
||||
const instances = [];
|
||||
const files = fs.readdirSync(PID_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(PID_PREFIX) && file.endsWith('.pid')) {
|
||||
const port = parseInt(file.replace(PID_PREFIX, '').replace('.pid', ''), 10);
|
||||
const pid = readPidFile(port);
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
instances.push({ port, pid });
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running kanban servers
|
||||
* @returns {number} - Number of servers stopped
|
||||
*/
|
||||
function stopAllServers() {
|
||||
const instances = findRunningInstances();
|
||||
let stopped = 0;
|
||||
|
||||
for (const { port, pid } of instances) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
removePidFile(port);
|
||||
stopped++;
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup graceful shutdown handlers
|
||||
* @param {number} port - Server port
|
||||
* @param {Function} cleanup - Additional cleanup function
|
||||
*/
|
||||
function setupShutdownHandlers(port, cleanup) {
|
||||
const handler = () => {
|
||||
if (cleanup) cleanup();
|
||||
removePidFile(port);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', handler);
|
||||
process.on('SIGINT', handler);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPidFilePath,
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
findRunningInstances,
|
||||
stopAllServers,
|
||||
setupShutdownHandlers,
|
||||
PID_PREFIX
|
||||
};
|
||||
260
.opencode/skills/plans-kanban/scripts/server.cjs
Executable file
260
.opencode/skills/plans-kanban/scripts/server.cjs
Executable file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Plans Kanban Server
|
||||
* Background HTTP server for plans dashboard with progress tracking
|
||||
*
|
||||
* Usage:
|
||||
* node server.cjs --dir ./plans [--port 3500] [--open] [--stop] [--host 0.0.0.0]
|
||||
*
|
||||
* Options:
|
||||
* --dir <path> Path to plans directory
|
||||
* --port <number> Server port (default: 3500, auto-increment if busy)
|
||||
* --host <addr> Host to bind (default: localhost, use 0.0.0.0 for all interfaces)
|
||||
* --open Auto-open browser after start
|
||||
* --stop Stop all running kanban servers
|
||||
* --background Run in background (detached) - legacy mode
|
||||
* --foreground Run in foreground (for CC background tasks)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawn, execSync } = require('child_process');
|
||||
|
||||
const { findAvailablePort, DEFAULT_PORT } = require('./lib/port-finder.cjs');
|
||||
const { writePidFile, stopAllServers, setupShutdownHandlers, findRunningInstances } = require('./lib/process-mgr.cjs');
|
||||
const { createHttpServer } = require('./lib/http-server.cjs');
|
||||
|
||||
/**
|
||||
* Parse command line arguments
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
dir: null,
|
||||
port: DEFAULT_PORT,
|
||||
host: 'localhost',
|
||||
open: false,
|
||||
stop: false,
|
||||
background: false,
|
||||
foreground: false,
|
||||
isChild: false
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if ((arg === '--dir' || arg === '--plans') && argv[i + 1]) {
|
||||
args.dir = argv[++i];
|
||||
} else if (arg === '--port' && argv[i + 1]) {
|
||||
args.port = parseInt(argv[++i], 10);
|
||||
} else if (arg === '--host' && argv[i + 1]) {
|
||||
args.host = argv[++i];
|
||||
} else if (arg === '--open') {
|
||||
args.open = true;
|
||||
} else if (arg === '--stop') {
|
||||
args.stop = true;
|
||||
} else if (arg === '--background') {
|
||||
args.background = true;
|
||||
} else if (arg === '--foreground') {
|
||||
args.foreground = true;
|
||||
} else if (arg === '--child') {
|
||||
args.isChild = true;
|
||||
} else if (!arg.startsWith('--') && !args.dir) {
|
||||
args.dir = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local network IP address for remote access
|
||||
*/
|
||||
function getLocalIP() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL with network URL for remote access
|
||||
*/
|
||||
function buildUrl(host, port, plansDir) {
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
const urlPath = `/kanban?dir=${encodeURIComponent(plansDir)}`;
|
||||
const url = `http://${displayHost}:${port}${urlPath}`;
|
||||
|
||||
let networkUrl = null;
|
||||
if (host === '0.0.0.0') {
|
||||
const localIP = getLocalIP();
|
||||
if (localIP) {
|
||||
networkUrl = `http://${localIP}:${port}${urlPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { url, networkUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser
|
||||
*/
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
let cmd;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
cmd = `open "${url}"`;
|
||||
} else if (platform === 'win32') {
|
||||
cmd = `start "${url}"`;
|
||||
} else {
|
||||
cmd = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore browser open errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const cwd = process.cwd();
|
||||
const assetsDir = path.join(__dirname, '..', 'assets');
|
||||
|
||||
// Handle --stop
|
||||
if (args.stop) {
|
||||
const instances = findRunningInstances();
|
||||
if (instances.length === 0) {
|
||||
console.log('No kanban server running to stop');
|
||||
process.exit(0);
|
||||
}
|
||||
const stopped = stopAllServers();
|
||||
console.log(`Stopped ${stopped} kanban server(s)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!args.dir) {
|
||||
console.error('Error: --dir argument required');
|
||||
console.error('Usage:');
|
||||
console.error(' node server.cjs --dir <plans-dir> [--port 3500] [--open]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve plans directory
|
||||
const plansDir = path.isAbsolute(args.dir) ? args.dir : path.resolve(cwd, args.dir);
|
||||
|
||||
if (!fs.existsSync(plansDir) || !fs.statSync(plansDir).isDirectory()) {
|
||||
console.error(`Error: Directory not found: ${args.dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Background mode - spawn child and exit (legacy mode for manual runs)
|
||||
// Skip if --foreground is set (for Claude Code background tasks)
|
||||
if (args.background && !args.foreground && !args.isChild) {
|
||||
const childArgs = ['--dir', plansDir, '--port', String(args.port), '--host', args.host, '--child'];
|
||||
if (args.open) childArgs.push('--open');
|
||||
|
||||
const child = spawn(process.execPath, [__filename, ...childArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: cwd
|
||||
});
|
||||
child.unref();
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const instances = findRunningInstances();
|
||||
const instance = instances.find(i => i.port >= args.port);
|
||||
const port = instance ? instance.port : args.port;
|
||||
|
||||
const { url, networkUrl } = buildUrl(args.host, port, plansDir);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url,
|
||||
dir: plansDir,
|
||||
port,
|
||||
host: args.host,
|
||||
mode: 'kanban'
|
||||
};
|
||||
if (networkUrl) result.networkUrl = networkUrl;
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Find available port
|
||||
const port = await findAvailablePort(args.port);
|
||||
if (port !== args.port) {
|
||||
console.error(`Port ${args.port} in use, using ${port}`);
|
||||
}
|
||||
|
||||
// Determine allowed directories
|
||||
const allowedDirs = [assetsDir, cwd, plansDir];
|
||||
|
||||
// Create server
|
||||
const server = createHttpServer({
|
||||
assetsDir,
|
||||
allowedDirs,
|
||||
plansDir
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(port, args.host, () => {
|
||||
const { url, networkUrl } = buildUrl(args.host, port, plansDir);
|
||||
|
||||
writePidFile(port, process.pid);
|
||||
setupShutdownHandlers(port, () => server.close());
|
||||
|
||||
// Output for CLI/command integration
|
||||
// In foreground mode (CC background task), always output JSON
|
||||
if (args.foreground || args.isChild || process.env.CLAUDE_COMMAND) {
|
||||
const result = {
|
||||
success: true,
|
||||
url,
|
||||
dir: plansDir,
|
||||
port,
|
||||
host: args.host,
|
||||
mode: 'kanban'
|
||||
};
|
||||
if (networkUrl) result.networkUrl = networkUrl;
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(`\nPlans Kanban Dashboard`);
|
||||
console.log(`${'─'.repeat(40)}`);
|
||||
console.log(`URL: ${url}`);
|
||||
if (networkUrl) {
|
||||
console.log(`Network: ${networkUrl}`);
|
||||
}
|
||||
console.log(`Plans: ${plansDir}`);
|
||||
console.log(`Port: ${port}`);
|
||||
console.log(`Host: ${args.host}`);
|
||||
console.log(`\nPress Ctrl+C to stop\n`);
|
||||
}
|
||||
|
||||
if (args.open) {
|
||||
openBrowser(url);
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`Server error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user