
Plans Kanban
- 44 installs
- 7 repo stars
- Updated June 18, 2026
- duc01226/easyplatform
Runs a plans dashboard server with progress tracking and timeline visualization.
About
A skill that serves a kanban-style dashboard for plans with progress and timeline views. A developer uses it to monitor plan execution across a project.
- Plans dashboard server
- Progress and timeline tracking
Plans Kanban by the numbers
- 44 all-time installs (skills.sh)
- Ranked #1,686 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/duc01226/easyplatform --skill plans-kanbanAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 7 |
| Last updated | June 18, 2026 |
| Repository | duc01226/easyplatform ↗ |
What it does
Runs a plans dashboard server with progress tracking and timeline visualization.
Files
Codex compatibility note:
>
- Invoke repository skills with$skill-namein Codex; this mirrored copy rewrites legacy Claude/skill-namereferences.
- Task tracker mandate: BEFORE executing any workflow or skill step, create/update task tracking for all steps and keep it synchronized as progress changes.
- User-question prompts mean to ask the user directly in Codex.
- Ignore Claude-specific mode-switch instructions when they appear.
- Strict execution contract: when a user explicitly invokes a skill, execute that skill protocol as written.
- Subagent authorization: when a skill is user-invoked or AI-detected and its protocol requires subagents, that skill activation authorizes use of the required spawn_agent subagent(s) for that task.- Do not skip, reorder, or merge protocol steps unless the user explicitly approves the deviation first.
- For workflow skills, execute each listed child-skill step explicitly and report step-by-step evidence.
- If a required step/tool cannot run in this environment, stop and ask the user before adapting.
<!-- CODEX:PROJECT-REFERENCE-LOADING:START -->
Codex Project-Reference Loading (No Hooks)
Codex does not receive Claude hook-based doc injection. When coding, planning, debugging, testing, or reviewing, open project docs explicitly using this routing.
Always read:
docs/project-config.json(project-specific paths, commands, modules, and workflow/test settings)docs/project-reference/docs-index-reference.md(routes to the fulldocs/project-reference/*catalog)docs/project-reference/lessons.md(always-on guardrails and anti-patterns)
Situation-based docs:
- Backend/CQRS/API/domain/entity changes:
backend-patterns-reference.md,domain-entities-reference.md,project-structure-reference.md - Frontend/UI/styling/design-system:
frontend-patterns-reference.md,scss-styling-guide.md,design-system/README.md - Spec/test-case planning or TC mapping:
feature-docs-reference.md - Integration test implementation/review:
integration-test-reference.md - E2E test implementation/review:
e2e-test-reference.md - Code review/audit work:
code-review-rules.mdplus domain docs above based on changed files
Do not read all docs blindly. Start from docs-index-reference.md, then open only relevant files for the task.
<!-- CODEX:PROJECT-REFERENCE-LOADING:END -->
Quick Summary
Goal: Visual dashboard server for viewing plan directories with progress tracking and timeline visualization.
Workflow:
1. Start Server — Point at a plans directory with CLI options 2. Browse Dashboard — View plan cards with progress bars, phase status, activity heatmap 3. Inspect Plans — Gantt-style timeline, priority indicators, issue/branch links
Key Rules:
- Requires
npm installbefore first use (gray-matter) - Use
$kanbanslash command for quick access - Scans for directories containing
plan.mdfiles
Be skeptical. Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence percentages (Idea should be more than 80%).
plans-kanban
Plans dashboard server with progress tracking and timeline visualization.
⚠️ Installation Required
This skill requires npm dependencies. Run one of the following:
# Option 1: Install via ClaudeKit CLI (recommended)
ck init # Runs install.sh which handles all skills
# Option 2: Manual installation
cd .claude/skills/plans-kanban
npm installDependencies: 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
# View plans dashboard
node .claude/skills/plans-kanban/scripts/server.cjs \
--dir ./plans \
--open
# Explicit local host
node .claude/skills/plans-kanban/scripts/server.cjs \
--dir ./plans \
--host localhost \
--open
# Background mode
node .claude/skills/plans-kanban/scripts/server.cjs \
--dir ./plans \
--background
# Stop all running servers
node .claude/skills/plans-kanban/scripts/server.cjs --stopSlash Command
Use $kanban for quick access:
$kanban plans/ # View plans dashboard
$kanban --stop # Stop kanban serverFeatures
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 | 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 interactivityHTTP 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 |
/assets/* | Static assets |
/file/* | Local file serving |
Local Access
Start on localhost unless you have explicitly accepted the network exposure of serving local files:
{
"success": true,
"url": "http://localhost:3500/kanban?dir=...",
"port": 3500
}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 defaultTroubleshooting
Port in use: Server auto-increments from 3500-3550
No plans found: Ensure directories contain plan.md files
Remote access denied: This dashboard is intended for local use; keep --host localhost unless you have explicitly accepted the network exposure.
PID files: Located at /tmp/plans-kanban-*.pid
---
[IMPORTANT] Use task tracking to break ALL work into small tasks BEFORE starting — including tasks for each file read. This prevents context loss from long files. For simple tasks, AI MUST ATTENTION ask user whether to skip.
<!-- SYNC:ai-mistake-prevention -->
AI Mistake Prevention — Failure modes to avoid on every task: Check downstream references before deleting. Deleting components causes documentation and code staleness cascades. Map all referencing files before removal. Verify AI-generated content against actual code. AI hallucinates APIs, class names, and method signatures. Always grep to confirm existence before documenting or referencing. Trace full dependency chain after edits. Changing a definition misses downstream variables and consumers derived from it. Always trace the full chain. Trace ALL code paths when verifying correctness. Confirming code exists is not confirming it executes. Always trace early exits, error branches, and conditional skips — not just happy path. When debugging, ask "whose responsibility?" before fixing. Trace whether bug is in caller (wrong data) or callee (wrong handling). Fix at responsible layer — never patch symptom site. Assume existing values are intentional — ask WHY before changing. Before changing any constant, limit, flag, or pattern: read comments, check git blame, examine surrounding code. Verify ALL affected outputs, not just the first. Changes touching multiple stacks require verifying EVERY output. One green check is not all green checks. Holistic-first debugging — resist nearest-attention trap. When investigating any failure, list EVERY precondition first (config, env vars, DB names, endpoints, DI registrations, data preconditions), then verify each against evidence before forming any code-layer hypothesis. Surgical changes — apply the diff test. Bug fix: every changed line must trace directly to the bug. Don't restyle or improve adjacent code. Enhancement task: implement improvements AND announce them explicitly. Surface ambiguity before coding — don't pick silently. If request has multiple interpretations, present each with effort estimate and ask. Never assume all-records, file-based, or more complex path.
<!-- /SYNC:ai-mistake-prevention -->
<!-- SYNC:critical-thinking-mindset -->
Critical Thinking Mindset — Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act.
Anti-hallucination: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination.
<!-- /SYNC:critical-thinking-mindset -->
<!-- SYNC:critical-thinking-mindset:reminder -->
MUST ATTENTION apply critical thinking — every claim needs traced proof, confidence >80% to act. Anti-hallucination: never present guess as fact.
<!-- /SYNC:critical-thinking-mindset:reminder -->
<!-- SYNC:ai-mistake-prevention:reminder -->
MUST ATTENTION apply AI mistake prevention — holistic-first debugging, fix at responsible layer, surface ambiguity before coding, re-read files after compaction.
<!-- /SYNC:ai-mistake-prevention:reminder -->
Closing Reminders
IMPORTANT MUST ATTENTION break work into small todo tasks using task tracking BEFORE starting IMPORTANT MUST ATTENTION search codebase for 3+ similar patterns before creating new code IMPORTANT MUST ATTENTION cite file:line evidence for every claim (confidence >80% to act) IMPORTANT MUST ATTENTION add a final review todo task to verify work quality IMPORTANT MUST ATTENTION include Test Specifications section and story_points in plan frontmatter
[TASK-PLANNING] Before acting, analyze task scope and systematically break it into small todo tasks and sub-tasks using task tracking.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:START -->
Hookless Prompt Protocol Mirror (Auto-Synced)
Source: .claude/hooks/lib/prompt-injections.cjs + .claude/.ck.json
[WORKFLOW-EXECUTION-PROTOCOL] [BLOCKING] Workflow Execution Protocol — MANDATORY IMPORTANT MUST CRITICAL. Do not skip for any reason.
Generic portability boundary: Reusable skills and protocol text stay project-neutral; project-specific conventions are discovered from docs/project-config.json and docs/project-reference/. Apply shared AI-SDD from shared/sdd-artifact-contract.md. Read docs/project-config.json and docs/project-reference/docs-index-reference.md, then open the project reference docs named there. Any supported AI tool may execute when this shared context and local docs are available.
1. DETECT: Match prompt against workflow catalog 2. ANALYZE: Find best-match workflow AND evaluate if a custom step combination would fit better 3. ASK (REQUIRED FORMAT): Use a direct user question with this structure unless the user explicitly invoked a workflow/skill and the local protocol treats explicit invocation as confirmation:
- Question: "Which workflow do you want to activate?"
- Option 1: "Activate [BestMatch Workflow] (Recommended)"
- Option 2: "Activate custom workflow: [step1 → step2 → ...]" (include one-line rationale)
4. ACTIVATE (if confirmed): Call $workflow-start <workflowId> for standard; sequence custom steps manually 5. CREATE TASKS: task tracking for ALL workflow steps 6. EXECUTE: Follow each step in sequence [CRITICAL-THINKING-MINDSET] Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act. Anti-hallucination principle: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination. AI Attention principle (Primacy-Recency): Put the 3 most critical rules at both top and bottom of long prompts/protocols so instruction adherence survives long context windows. Goal-driven execution: Define success criteria first, loop until verified, and stop only when observable checks pass. Tests verify intent: Tests must protect business rules/invariants and fail when the protected intent breaks, not only mirror current behavior.
[LESSON-LEARNED-REMINDER] [BLOCKING] Task Planning & Continuous Improvement — MANDATORY. Do not skip.
Break work into small tasks (task tracking) before starting. Add final task: "Analyze AI mistakes & lessons learned".
Extract lessons — ROOT CAUSE ONLY, not symptom fixes:
1. Name the FAILURE MODE (reasoning/assumption failure), not symptom — "assumed API existed without reading source" not "used wrong enum value". 2. Generality test: does this failure mode apply to ≥3 contexts/codebases? If not, abstract one level up. 3. Write as a universal rule — strip project-specific names/paths/classes. Useful on any codebase. 4. Consolidate: multiple mistakes sharing one failure mode → ONE lesson. 5. Recurrence gate: "Would this recur in future session WITHOUT this reminder?" — No → skip $learn. 6. Auto-fix gate: "Could $code-review/$code-simplifier/$security/$lint catch this?" — Yes → improve review skill instead. 7. BOTH gates pass → ask user to run $learn. [TASK-PLANNING] [MANDATORY] BEFORE executing any workflow or skill step, create/update task tracking for all planned steps, then keep it synchronized as each step starts/completes.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:END -->
<!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>
/**
* Dashboard CSS - Refined Novel Theme
* Monochromatic warm palette with single accent color
* Clean, professional, premium aesthetic
*/
/* ========================================
Custom Properties - Warm Monochrome
======================================== */
:root {
/* Warm palette aligned with novel-theme.css */
--dash-bg: #faf8f3;
--dash-surface: #ffffff;
--dash-surface-hover: #f5f3ee;
--dash-border: #e8e4dc;
--dash-border-subtle: #f0ede6;
--dash-text: #2d2a26;
--dash-text-secondary: #7a756d;
--dash-text-muted: #a8a29e;
/* Single accent - warm brown */
--dash-accent: #8b4513;
--dash-accent-hover: #6b3410;
--dash-accent-subtle: rgba(139, 69, 19, 0.08);
--dash-accent-glow: rgba(139, 69, 19, 0.15);
/* Status - monochrome approach */
--dash-status-done: var(--dash-accent);
--dash-status-active: var(--dash-accent);
--dash-status-pending: var(--dash-text-muted);
/* Layout */
--dash-radius: 12px;
--dash-radius-sm: 8px;
--dash-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
--dash-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
/* Typography */
--dash-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--dash-font-heading: 'Libre Baskerville', Georgia, serif;
/* Animation */
--dash-ease: cubic-bezier(0.4, 0, 0.2, 1);
--dash-duration: 200ms;
}
[data-theme='dark'] {
--dash-bg: #1a1a1a;
--dash-surface: #242424;
--dash-surface-hover: #2a2a2a;
--dash-border: #333333;
--dash-border-subtle: #2a2a2a;
--dash-text: #faf8f3;
--dash-text-secondary: #a8a198;
--dash-text-muted: #6b6560;
/* Single accent - warm gold */
--dash-accent: #d4a574;
--dash-accent-hover: #e5b885;
--dash-accent-subtle: rgba(212, 165, 116, 0.1);
--dash-accent-glow: rgba(212, 165, 116, 0.2);
--dash-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
--dash-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* ========================================
Base Layout
======================================== */
.dashboard-view {
min-height: 100vh;
background: var(--dash-bg);
font-family: var(--dash-font);
color: var(--dash-text);
}
/* ========================================
Header - Single Row Compact Layout
======================================== */
.dashboard-header {
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--dash-surface);
border-bottom: 1px solid var(--dash-border);
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
gap: 1.5rem;
flex-shrink: 0;
}
.dashboard-header h1 {
font-family: var(--dash-font-heading);
font-size: 1.25rem;
font-weight: 400;
color: var(--dash-text);
margin: 0;
letter-spacing: -0.01em;
}
/* Inline stats in header */
.header-stats {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8125rem;
color: var(--dash-text-secondary);
}
.header-stat strong {
color: var(--dash-text);
font-weight: 600;
}
.header-stat-divider {
width: 1px;
height: 12px;
background: var(--dash-border);
}
/* Header controls - all in one row */
.header-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
/* Theme Toggle */
#theme-toggle {
width: 32px;
height: 32px;
border-radius: var(--dash-radius-sm);
background: transparent;
border: 1px solid var(--dash-border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--dash-text-secondary);
transition: all var(--dash-duration) var(--dash-ease);
flex-shrink: 0;
}
#theme-toggle:hover {
background: var(--dash-surface-hover);
color: var(--dash-text);
border-color: var(--dash-accent);
}
#theme-toggle svg {
width: 16px;
height: 16px;
}
/* ========================================
Controls (in header)
======================================== */
/* Search */
.search-box {
position: relative;
min-width: 160px;
max-width: 220px;
}
.search-box input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2rem;
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius-sm);
background: var(--dash-bg);
color: var(--dash-text);
font-size: 0.8125rem;
font-family: var(--dash-font);
transition: all var(--dash-duration) var(--dash-ease);
}
.search-box input::placeholder {
color: var(--dash-text-muted);
}
.search-box input:focus {
outline: none;
border-color: var(--dash-accent);
box-shadow: 0 0 0 3px var(--dash-accent-subtle);
}
.search-icon {
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
color: var(--dash-text-muted);
pointer-events: none;
width: 14px;
height: 14px;
}
/* Sort Select */
#sort-select {
padding: 0.5rem 1.75rem 0.5rem 0.75rem;
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius-sm);
background: var(--dash-bg);
color: var(--dash-text);
font-size: 0.8125rem;
font-family: var(--dash-font);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%237A756D' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
transition: all var(--dash-duration) var(--dash-ease);
}
#sort-select:hover {
border-color: var(--dash-accent);
}
#sort-select:focus {
outline: none;
border-color: var(--dash-accent);
box-shadow: 0 0 0 3px var(--dash-accent-subtle);
}
/* Filter Pills - Simplified */
.filter-pills {
display: flex;
gap: 0.25rem;
padding: 3px;
background: var(--dash-bg);
border-radius: var(--dash-radius-sm);
border: 1px solid var(--dash-border);
}
.filter-pill {
padding: 0.3rem 0.625rem;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dash-text-secondary);
font-size: 0.75rem;
font-weight: 500;
font-family: var(--dash-font);
cursor: pointer;
transition: all var(--dash-duration) var(--dash-ease);
}
.filter-pill:hover {
color: var(--dash-text);
background: var(--dash-surface-hover);
}
.filter-pill.active {
background: var(--dash-accent);
color: white;
}
/* Result Count */
.result-count {
font-size: 0.75rem;
color: var(--dash-text-muted);
}
.result-count strong {
color: var(--dash-text);
font-weight: 600;
}
/* View Toggle */
.view-toggle {
display: flex;
gap: 2px;
padding: 2px;
background: var(--dash-bg);
border-radius: 6px;
border: 1px solid var(--dash-border);
}
.view-toggle-btn {
padding: 0.3rem;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dash-text-muted);
cursor: pointer;
transition: all var(--dash-duration) var(--dash-ease);
display: flex;
align-items: center;
justify-content: center;
}
.view-toggle-btn:hover {
color: var(--dash-text);
background: var(--dash-surface-hover);
}
.view-toggle-btn.active {
background: var(--dash-accent);
color: white;
}
.view-toggle-btn svg {
width: 16px;
height: 16px;
}
/* ========================================
Stats Hero - Minimal Version
======================================== */
.stats-hero {
display: none; /* Hidden - using inline stats instead */
}
/* ========================================
Kanban Board Layout (Vibe-Kanban Style)
======================================== */
.kanban-board {
display: grid;
grid-template-columns: repeat(5, minmax(240px, 1fr));
gap: 0;
min-height: calc(100vh - 160px);
overflow-x: auto;
}
.kanban-column {
display: flex;
flex-direction: column;
border-right: 1px solid var(--dash-border);
background: var(--dash-bg);
min-height: 100%;
}
.kanban-column:last-child {
border-right: none;
}
/* Column Header - Sticky */
.kanban-column-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--dash-surface);
border-bottom: 1px dashed var(--dash-border);
}
.kanban-column-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--dash-text);
}
.kanban-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.kanban-status-dot.pending {
background: var(--dash-text-muted);
}
.kanban-status-dot.in-progress {
background: #3b82f6;
}
.kanban-status-dot.in-review {
background: #f59e0b;
}
.kanban-status-dot.completed {
background: #10b981;
}
.kanban-status-dot.cancelled {
background: #ef4444;
}
.kanban-column-count {
font-size: 0.75rem;
font-weight: 500;
color: var(--dash-text-muted);
background: var(--dash-bg);
padding: 0.125rem 0.5rem;
border-radius: 100px;
}
/* Column Cards Container */
.kanban-cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
overflow-y: auto;
}
/* Kanban Card - Compact */
.kanban-card {
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius-sm);
padding: 0.875rem 1rem;
cursor: pointer;
transition: all var(--dash-duration) var(--dash-ease);
text-decoration: none;
color: inherit;
display: block;
}
.kanban-card:hover {
border-color: var(--dash-accent);
box-shadow: var(--dash-shadow-hover);
transform: translateY(-1px);
}
.kanban-card-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--dash-text);
margin: 0 0 0.375rem 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kanban-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--dash-text-muted);
}
.kanban-card-progress {
display: flex;
align-items: center;
gap: 0.375rem;
}
.kanban-card-progress-bar {
width: 40px;
height: 3px;
background: var(--dash-border);
border-radius: 2px;
overflow: hidden;
}
.kanban-card-progress-fill {
height: 100%;
background: var(--dash-accent);
border-radius: 2px;
transition: width 0.3s var(--dash-ease);
}
.kanban-card-date {
margin-left: auto;
}
/* Kanban Card Enhanced - Description */
.kanban-card-description {
font-size: 0.75rem;
color: var(--dash-text-secondary);
line-height: 1.4;
margin: 0.25rem 0 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Kanban Card Enhanced - Header with priority */
.kanban-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.kanban-card-header .kanban-card-title {
margin: 0;
flex: 1;
}
/* Kanban Card Priority Badge */
.kanban-card-priority {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.kanban-card-priority.priority-high {
background: #fee2e2;
color: #b91c1c;
}
.kanban-card-priority.priority-medium {
background: #fef3c7;
color: #b45309;
}
.kanban-card-priority.priority-low {
background: #d1fae5;
color: #047857;
}
[data-theme='dark'] .kanban-card-priority.priority-high {
background: rgba(185, 28, 28, 0.2);
color: #fca5a5;
}
[data-theme='dark'] .kanban-card-priority.priority-medium {
background: rgba(180, 83, 9, 0.2);
color: #fcd34d;
}
[data-theme='dark'] .kanban-card-priority.priority-low {
background: rgba(4, 120, 87, 0.2);
color: #6ee7b7;
}
/* Kanban Card Tags */
.kanban-card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.kanban-card-tag {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--dash-accent-subtle);
color: var(--dash-accent);
border-radius: 3px;
white-space: nowrap;
}
.kanban-card-tag.tag-more {
background: var(--dash-border);
color: var(--dash-text-muted);
}
/* Kanban Card Footer (effort + phases) */
.kanban-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--dash-border-subtle);
font-size: 0.6875rem;
color: var(--dash-text-muted);
}
.kanban-card-effort {
display: flex;
align-items: center;
gap: 0.25rem;
}
.kanban-card-effort svg {
width: 10px;
height: 10px;
opacity: 0.7;
}
.kanban-card-phases {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Empty Column State */
.kanban-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: var(--dash-text-muted);
font-size: 0.8125rem;
text-align: center;
}
.kanban-empty-icon {
width: 32px;
height: 32px;
margin-bottom: 0.5rem;
opacity: 0.4;
}
/* ========================================
Plans Grid (Card View - Alternative)
======================================== */
.plans-grid {
display: none; /* Hidden when kanban is active */
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
padding: 1.5rem 2rem 2rem;
}
/* Show grid when view mode is grid */
.view-mode-grid .plans-grid {
display: grid;
}
.view-mode-grid .kanban-board {
display: none;
}
/* Hide filter pills in kanban view - they only apply to grid view */
body:not(.view-mode-grid) .filter-pills {
display: none;
}
/* ========================================
Plan Cards - Clean Minimal Design
======================================== */
.plan-card {
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius);
padding: 1.25rem 1.5rem;
cursor: pointer;
transition: all var(--dash-duration) var(--dash-ease);
position: relative;
}
.plan-card:hover {
border-color: var(--dash-accent);
box-shadow: var(--dash-shadow-hover);
transform: translateY(-2px);
}
.plan-card:active {
transform: translateY(0);
}
/* Subtle accent indicator */
.plan-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: transparent;
border-radius: var(--dash-radius) 0 0 var(--dash-radius);
transition: background var(--dash-duration) var(--dash-ease);
}
.plan-card:hover::before {
background: var(--dash-accent);
}
/* Card completed state - always show accent */
.plan-card[data-status='completed']::before {
background: var(--dash-accent);
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.card-header-content {
flex: 1;
min-width: 0;
}
.plan-name {
font-family: var(--dash-font);
font-size: 1rem;
font-weight: 600;
color: var(--dash-text);
margin: 0 0 0.375rem 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.plan-date {
font-size: 0.75rem;
color: var(--dash-text-muted);
display: flex;
align-items: center;
gap: 0.25rem;
}
.plan-date svg {
width: 12px;
height: 12px;
opacity: 0.6;
}
/* Status Icon - Monochrome */
.status-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--dash-text-muted);
}
.status-icon.completed {
color: var(--dash-accent);
}
.status-icon.in-progress {
color: var(--dash-accent);
}
/* Status badge - simplified */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 100px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--dash-accent-subtle);
color: var(--dash-accent);
}
.status-badge.pending {
background: var(--dash-bg);
color: var(--dash-text-muted);
}
.status-badge::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.status-badge.in-progress::before {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Card Body */
.card-body {
margin-bottom: 1rem;
}
/* Progress Bar - Simple horizontal */
.progress-bar {
display: flex;
height: 4px;
border-radius: 2px;
background: var(--dash-border);
overflow: hidden;
margin-bottom: 0.625rem;
}
.bar-segment {
height: 100%;
background: var(--dash-accent);
transition: width 0.4s var(--dash-ease);
}
.bar-segment.in-progress {
background: var(--dash-accent);
opacity: 0.6;
}
.bar-segment.pending {
background: transparent;
}
/* Phase count */
.phase-count {
font-size: 0.75rem;
color: var(--dash-text-secondary);
}
.phase-count strong {
color: var(--dash-text);
font-weight: 600;
}
/* Hide progress ring - using bar instead */
.progress-visual,
.progress-ring-container {
display: none;
}
/* Status counts - simplified */
.status-counts {
display: none; /* Hidden in new design */
}
/* Card Footer */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid var(--dash-border-subtle);
}
.phases-summary {
font-size: 0.75rem;
color: var(--dash-text-muted);
}
/* View Button - Ghost style */
.view-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: transparent;
color: var(--dash-accent);
text-decoration: none;
border: 1px solid var(--dash-accent);
border-radius: var(--dash-radius-sm);
font-size: 0.8125rem;
font-weight: 500;
transition: all var(--dash-duration) var(--dash-ease);
}
.view-btn:hover {
background: var(--dash-accent);
color: white;
}
.view-btn svg {
width: 14px;
height: 14px;
transition: transform var(--dash-duration) var(--dash-ease);
}
.view-btn:hover svg {
transform: translateX(2px);
}
/* ========================================
Empty State
======================================== */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--dash-text-muted);
}
.empty-state[hidden] {
display: none;
}
.empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: var(--dash-bg);
border: 1px solid var(--dash-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--dash-text-muted);
}
.empty-icon svg {
width: 32px;
height: 32px;
}
.empty-state h2 {
font-family: var(--dash-font);
font-size: 1.125rem;
font-weight: 600;
color: var(--dash-text-secondary);
margin-bottom: 0.5rem;
}
.empty-state p {
font-size: 0.875rem;
max-width: 320px;
margin: 0 auto;
line-height: 1.5;
}
/* ========================================
Loading Skeleton
======================================== */
.loading-skeleton {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
padding: 1.5rem 2rem 2rem;
}
.plans-loaded .loading-skeleton {
display: none;
}
.skeleton-card {
height: 160px;
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius);
position: relative;
overflow: hidden;
}
.skeleton-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, var(--dash-surface-hover) 50%, transparent 100%);
transform: translateX(-100%);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
/* ========================================
Accessibility
======================================== */
.plan-card:focus,
.filter-pill:focus,
.view-btn:focus,
#sort-select:focus,
#plan-search:focus,
#theme-toggle:focus {
outline: none;
}
.plan-card:focus-visible,
.filter-pill:focus-visible,
.view-btn:focus-visible,
#sort-select:focus-visible,
#plan-search:focus-visible,
#theme-toggle:focus-visible {
outline: 2px solid var(--dash-accent);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
background: var(--dash-accent);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0 0 var(--dash-radius-sm) var(--dash-radius-sm);
z-index: 1000;
transition: top var(--dash-duration);
}
.skip-link:focus {
top: 0;
}
/* ========================================
Card Meta Tags (Duration, Effort, Priority, Issue)
======================================== */
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--dash-border-subtle);
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--dash-text-secondary);
background: var(--dash-bg);
border-radius: 4px;
text-decoration: none;
transition: all var(--dash-duration) var(--dash-ease);
}
.meta-tag svg {
flex-shrink: 0;
opacity: 0.7;
}
.meta-tag.duration {
color: var(--dash-text-secondary);
}
.meta-tag.effort {
color: var(--dash-accent);
background: var(--dash-accent-subtle);
}
.meta-tag.priority {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.meta-tag.priority.p1,
.meta-tag.priority.high,
.meta-tag.priority-high {
color: #fff;
background: #dc2626;
}
.meta-tag.priority.p2,
.meta-tag.priority.medium,
.meta-tag.priority-medium {
color: #000;
background: #facc15;
}
.meta-tag.priority.p3,
.meta-tag.priority.low,
.meta-tag.priority-low {
color: #fff;
background: #16a34a;
}
.meta-tag.issue {
color: var(--dash-text-secondary);
cursor: pointer;
text-decoration: none;
}
.meta-tag.issue:hover {
color: var(--dash-accent);
background: var(--dash-accent-subtle);
}
/* Card Description */
.card-description {
font-size: 0.85rem;
color: var(--dash-text-secondary);
margin: 0.5rem 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Card Tags */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.tag-pill {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
background: var(--dash-surface-hover);
color: var(--dash-text-secondary);
font-weight: 500;
}
.tag-pill:hover {
background: var(--dash-accent-subtle);
color: var(--dash-accent);
}
.tag-more {
background: var(--dash-border);
color: var(--dash-text-muted);
font-style: italic;
}
/* ========================================
Timeline Section - Layered Gantt (Minimal)
======================================== */
.timeline-section {
padding: 1rem 1.5rem;
margin: 1rem 1.5rem;
background: var(--dash-surface);
border-radius: var(--dash-radius);
border: 1px solid var(--dash-border);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.timeline-title {
font-family: var(--dash-font);
font-size: 0.75rem;
font-weight: 600;
color: var(--dash-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0;
display: inline;
}
/* Remove decorative lines from novel-theme h2 */
.timeline-title::before,
.timeline-title::after {
display: none;
}
.timeline-controls {
display: flex;
gap: 0.5rem;
}
.timeline-zoom-btn {
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 500;
color: var(--dash-text-muted);
background: transparent;
border: 1px solid var(--dash-border);
border-radius: 4px;
cursor: pointer;
transition: all var(--dash-duration) var(--dash-ease);
}
.timeline-zoom-btn:hover,
.timeline-zoom-btn.active {
color: var(--dash-accent);
border-color: var(--dash-accent);
background: var(--dash-accent-subtle);
}
.timeline-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--dash-text-muted);
}
.timeline-stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.timeline-stat strong {
color: var(--dash-text-secondary);
font-weight: 600;
}
/* Gantt Container */
.gantt-container {
position: relative;
margin-top: 0.25rem;
}
/* Date Axis */
.gantt-axis {
display: flex;
justify-content: space-between;
padding: 0 0 0.375rem;
border-bottom: 1px solid var(--dash-border-subtle);
margin-bottom: 0.5rem;
}
.gantt-axis-label {
font-size: 0.6875rem;
font-weight: 500;
color: var(--dash-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gantt-axis-label.today {
color: var(--dash-accent);
font-weight: 600;
}
/* Gantt Track (where bars live) */
.gantt-track {
position: relative;
min-height: 60px;
max-height: 220px;
overflow-y: auto;
overflow-x: hidden;
background: repeating-linear-gradient(
90deg,
transparent,
transparent calc(100% / 7 - 1px),
var(--dash-border-subtle) calc(100% / 7 - 1px),
var(--dash-border-subtle) calc(100% / 7)
);
}
.gantt-track::-webkit-scrollbar {
width: 4px;
}
.gantt-track::-webkit-scrollbar-thumb {
background: var(--dash-border);
border-radius: 2px;
}
/* Gantt Bar - Compact 18px height, clickable link */
.gantt-bar {
position: absolute;
height: 18px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s var(--dash-ease);
display: flex;
align-items: center;
padding: 0 0.375rem;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-decoration: none;
color: inherit;
}
.gantt-bar:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.gantt-bar.completed {
background: var(--dash-accent);
color: #fff;
font-weight: 600;
}
.gantt-bar.in-progress {
background: var(--dash-accent);
color: #fff;
font-weight: 600;
}
.gantt-bar.in-progress::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.2) 50%, transparent 100%);
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.gantt-bar.pending {
background: var(--dash-bg);
border: 1px solid var(--dash-border);
color: var(--dash-text);
font-weight: 500;
}
.gantt-bar-label {
font-size: 0.625rem;
font-weight: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
z-index: 1;
}
.gantt-bar-status {
margin-left: auto;
font-size: 0.5625rem;
opacity: 0.8;
flex-shrink: 0;
}
/* Gantt Tooltip */
.gantt-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--dash-text);
color: var(--dash-bg);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease;
z-index: 100;
pointer-events: none;
}
.gantt-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--dash-text);
}
.gantt-bar:hover .gantt-tooltip {
opacity: 1;
visibility: visible;
}
.gantt-tooltip-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.gantt-tooltip-meta {
display: flex;
gap: 0.75rem;
opacity: 0.8;
font-size: 0.6875rem;
}
/* Today Marker */
.gantt-today-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--dash-accent);
z-index: 5;
opacity: 0.6;
}
.gantt-today-marker::before {
content: 'Today';
position: absolute;
top: -1.25rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.5625rem;
font-weight: 600;
color: var(--dash-accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Empty State */
.gantt-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80px;
color: var(--dash-text-muted);
font-size: 0.8125rem;
}
/* Compact Summary */
.timeline-summary {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--dash-border-subtle);
font-size: 0.6875rem;
color: var(--dash-text-muted);
}
.timeline-summary-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.timeline-summary-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.timeline-summary-dot.completed {
background: var(--dash-accent);
}
.timeline-summary-dot.in-progress {
background: var(--dash-accent);
opacity: 0.6;
}
.timeline-summary-dot.pending {
background: var(--dash-border);
}
/* ========================================
Reduced Motion
======================================== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ========================================
Responsive
======================================== */
@media (max-width: 768px) {
.dashboard-header {
padding: 1rem 1.25rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.header-left {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.header-stats {
font-size: 0.8125rem;
}
.dashboard-controls {
padding: 1rem 1.25rem;
gap: 0.75rem;
}
.search-box {
min-width: 100%;
max-width: 100%;
}
#sort-select {
flex: 1;
}
.filter-pills {
width: 100%;
justify-content: center;
}
.result-count {
width: 100%;
text-align: center;
margin-left: 0;
}
.plans-grid {
grid-template-columns: 1fr;
padding: 1rem 1.25rem;
}
}
@media (max-width: 480px) {
.dashboard-header h1 {
font-size: 1.25rem;
}
.header-stat-divider {
display: none;
}
.header-stats {
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.filter-pill {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
}
.plan-card {
padding: 1rem 1.25rem;
}
}
/* ========================================
Print Styles
======================================== */
@media print {
.dashboard-header,
.dashboard-controls,
.loading-skeleton,
#theme-toggle {
display: none !important;
}
.dashboard-view {
background: white;
}
.plans-grid {
display: block;
padding: 0;
}
.plan-card {
break-inside: avoid;
margin-bottom: 1rem;
box-shadow: none;
border: 1px solid #ddd;
}
.plan-card:hover {
transform: none;
box-shadow: none;
}
.plan-card::before {
display: none;
}
}
/**
* 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();
}
})();
{
"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"
}
}
/**
* 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
};
/**
* 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
};
/**
* 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');
/**
* Normalize status string to standard format
* @param {string} status - Raw status string
* @returns {string} - Normalized status (pending|in-progress|completed|cancelled)
*/
function normalizeStatus(status) {
if (!status) return 'pending';
const s = String(status).toLowerCase().trim();
if (s === 'complete' || s === 'completed' || s === 'done') return 'completed';
if (s === 'in-progress' || s === 'in_progress' || s === 'active' || s === 'wip') return 'in-progress';
if (s === 'cancelled' || s === 'canceled') return 'cancelled';
if (s === 'in-review' || s === 'review') return 'in-review';
return 'pending';
}
/**
* 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
};