@sienklogic/plan-build-run 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dashboard/package.json +3 -1
  3. package/dashboard/public/css/layout.css +237 -82
  4. package/dashboard/public/css/tokens.css +59 -0
  5. package/dashboard/public/js/sidebar-toggle.js +21 -7
  6. package/dashboard/public/js/sse-client.js +99 -0
  7. package/dashboard/public/js/theme-toggle.js +46 -0
  8. package/dashboard/src/app.js +4 -0
  9. package/dashboard/src/middleware/current-phase.js +24 -0
  10. package/dashboard/src/routes/events.routes.js +5 -0
  11. package/dashboard/src/routes/index.routes.js +2 -1
  12. package/dashboard/src/routes/pages.routes.js +94 -6
  13. package/dashboard/src/services/analytics.service.js +143 -0
  14. package/dashboard/src/services/milestone.service.js +50 -4
  15. package/dashboard/src/services/roadmap.service.js +73 -0
  16. package/dashboard/src/services/todo.service.js +11 -2
  17. package/dashboard/src/services/watcher.service.js +1 -1
  18. package/dashboard/src/utils/cache.js +55 -0
  19. package/dashboard/src/views/analytics.ejs +5 -0
  20. package/dashboard/src/views/dependencies.ejs +5 -0
  21. package/dashboard/src/views/error.ejs +16 -9
  22. package/dashboard/src/views/partials/analytics-content.ejs +71 -0
  23. package/dashboard/src/views/partials/breadcrumbs.ejs +14 -0
  24. package/dashboard/src/views/partials/dashboard-content.ejs +1 -0
  25. package/dashboard/src/views/partials/dependencies-content.ejs +16 -0
  26. package/dashboard/src/views/partials/empty-state.ejs +7 -0
  27. package/dashboard/src/views/partials/head.ejs +4 -1
  28. package/dashboard/src/views/partials/header.ejs +9 -0
  29. package/dashboard/src/views/partials/layout-bottom.ejs +1 -10
  30. package/dashboard/src/views/partials/layout-top.ejs +7 -0
  31. package/dashboard/src/views/partials/milestone-detail-content.ejs +1 -0
  32. package/dashboard/src/views/partials/milestones-content.ejs +55 -19
  33. package/dashboard/src/views/partials/phase-content.ejs +1 -0
  34. package/dashboard/src/views/partials/phase-doc-content.ejs +1 -1
  35. package/dashboard/src/views/partials/phases-content.ejs +1 -0
  36. package/dashboard/src/views/partials/roadmap-content.ejs +1 -0
  37. package/dashboard/src/views/partials/sidebar.ejs +88 -43
  38. package/dashboard/src/views/partials/todo-create-content.ejs +1 -0
  39. package/dashboard/src/views/partials/todo-detail-content.ejs +5 -1
  40. package/dashboard/src/views/partials/todos-content.ejs +44 -3
  41. package/package.json +1 -1
  42. package/plugins/copilot-pbr/plugin.json +1 -1
  43. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  44. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
@@ -139,6 +139,79 @@ function extractMilestones(roadmapContent) {
139
139
  * @param {string} projectDir - Absolute path to the project root
140
140
  * @returns {Promise<{phases: Array, milestones: Array}>}
141
141
  */
142
+ /**
143
+ * Generate a Mermaid flowchart string from ROADMAP.md phase dependencies.
144
+ *
145
+ * @param {string} projectDir - Absolute path to the project root
146
+ * @returns {Promise<string>} Mermaid flowchart definition
147
+ */
148
+ export async function generateDependencyMermaid(projectDir) {
149
+ const { phases, milestones } = await getRoadmapData(projectDir);
150
+
151
+ if (phases.length === 0) {
152
+ return 'graph TD\n empty["No phases found"]';
153
+ }
154
+
155
+ // Read raw ROADMAP.md for dependencies
156
+ let dependencyMap = new Map();
157
+ try {
158
+ const roadmapPath = join(projectDir, '.planning', 'ROADMAP.md');
159
+ const rawContent = stripBOM(await readFile(roadmapPath, 'utf-8')).replace(/\r\n/g, '\n');
160
+ dependencyMap = extractAllDependencies(rawContent);
161
+ } catch (_err) {
162
+ // No ROADMAP.md — proceed with no deps
163
+ }
164
+
165
+ // Detect current phase (first non-complete phase, or last phase)
166
+ const currentPhase = phases.find(p => p.status !== 'Complete') || phases[phases.length - 1];
167
+
168
+ const lines = ['graph TD'];
169
+
170
+ // Group phases by milestone
171
+ const phaseById = new Map(phases.map(p => [p.id, p]));
172
+
173
+ if (milestones.length > 0) {
174
+ for (const ms of milestones) {
175
+ const msPhases = phases.filter(p => p.id >= ms.startPhase && p.id <= ms.endPhase);
176
+ if (msPhases.length === 0) continue;
177
+ const safeName = ms.name.replace(/"/g, '#quot;');
178
+ lines.push(` subgraph ${safeName}`);
179
+ for (const p of msPhases) {
180
+ const label = `Phase ${p.id}: ${p.name}`.replace(/"/g, '#quot;');
181
+ lines.push(` P${p.id}["${label}"]`);
182
+ }
183
+ lines.push(' end');
184
+ }
185
+ } else {
186
+ for (const p of phases) {
187
+ const label = `Phase ${p.id}: ${p.name}`.replace(/"/g, '#quot;');
188
+ lines.push(` P${p.id}["${label}"]`);
189
+ }
190
+ }
191
+
192
+ // Add dependency edges
193
+ for (const [phaseId, deps] of dependencyMap) {
194
+ if (!phaseById.has(phaseId)) continue;
195
+ for (const depId of deps) {
196
+ if (phaseById.has(depId)) {
197
+ lines.push(` P${depId} --> P${phaseId}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Style current phase
203
+ if (currentPhase) {
204
+ lines.push(` style P${currentPhase.id} fill:#4caf50,color:#fff`);
205
+ }
206
+
207
+ // Click handlers
208
+ for (const p of phases) {
209
+ lines.push(` click P${p.id} "/phases/${p.id}"`);
210
+ }
211
+
212
+ return lines.join('\n');
213
+ }
214
+
142
215
  export async function getRoadmapData(projectDir) {
143
216
  const { phases: basePhases } = await parseRoadmapFile(projectDir);
144
217
 
@@ -75,7 +75,7 @@ function sortTodosByPriority(todos) {
75
75
  * @param {string} projectDir - Absolute path to the project root
76
76
  * @returns {Promise<Array<{id: string, title: string, priority: string, phase: string, status: string, created: string, filename: string}>>}
77
77
  */
78
- export async function listPendingTodos(projectDir) {
78
+ export async function listPendingTodos(projectDir, filters = {}) {
79
79
  const pendingDir = join(projectDir, '.planning', 'todos', 'pending');
80
80
 
81
81
  let entries;
@@ -124,7 +124,16 @@ export async function listPendingTodos(projectDir) {
124
124
  });
125
125
  }
126
126
 
127
- return sortTodosByPriority(todos);
127
+ sortTodosByPriority(todos);
128
+
129
+ // Apply filters
130
+ const { priority, status, q } = filters;
131
+ return todos.filter(todo => {
132
+ if (priority && todo.priority !== priority) return false;
133
+ if (status && todo.status !== status) return false;
134
+ if (q && !todo.title.toLowerCase().includes(q.toLowerCase())) return false;
135
+ return true;
136
+ });
128
137
  }
129
138
 
130
139
  /**
@@ -22,7 +22,7 @@ export function createWatcher(watchPath, onChange) {
22
22
  persistent: true,
23
23
  ignoreInitial: true,
24
24
  awaitWriteFinish: {
25
- stabilityThreshold: 2000,
25
+ stabilityThreshold: 500,
26
26
  pollInterval: 100
27
27
  }
28
28
  });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Simple TTL (Time-To-Live) cache backed by a Map.
3
+ *
4
+ * Each entry stores { value, expiry } where expiry is a Unix-ms timestamp.
5
+ */
6
+ export class TTLCache {
7
+ /**
8
+ * @param {number} [defaultTTL=60000] - Default time-to-live in milliseconds
9
+ */
10
+ constructor(defaultTTL = 60_000) {
11
+ this._store = new Map();
12
+ this._defaultTTL = defaultTTL;
13
+ }
14
+
15
+ /**
16
+ * Retrieve a cached value. Returns undefined if missing or expired.
17
+ * @param {string} key
18
+ * @returns {*}
19
+ */
20
+ get(key) {
21
+ const entry = this._store.get(key);
22
+ if (!entry) return undefined;
23
+ if (Date.now() > entry.expiry) {
24
+ this._store.delete(key);
25
+ return undefined;
26
+ }
27
+ return entry.value;
28
+ }
29
+
30
+ /**
31
+ * Store a value with an optional per-key TTL.
32
+ * @param {string} key
33
+ * @param {*} value
34
+ * @param {number} [ttlMs] - Override default TTL for this entry
35
+ */
36
+ set(key, value, ttlMs) {
37
+ const ttl = ttlMs ?? this._defaultTTL;
38
+ this._store.set(key, { value, expiry: Date.now() + ttl });
39
+ }
40
+
41
+ /**
42
+ * Remove a single key from the cache.
43
+ * @param {string} key
44
+ */
45
+ invalidate(key) {
46
+ this._store.delete(key);
47
+ }
48
+
49
+ /**
50
+ * Clear the entire cache.
51
+ */
52
+ invalidateAll() {
53
+ this._store.clear();
54
+ }
55
+ }
@@ -0,0 +1,5 @@
1
+ <%- include('partials/layout-top', { title: 'Analytics', activePage: 'analytics' }) %>
2
+
3
+ <%- include('partials/analytics-content') %>
4
+
5
+ <%- include('partials/layout-bottom') %>
@@ -0,0 +1,5 @@
1
+ <%- include('partials/layout-top', { title: 'Dependencies', activePage: 'dependencies' }) %>
2
+
3
+ <%- include('partials/dependencies-content') %>
4
+
5
+ <%- include('partials/layout-bottom') %>
@@ -1,13 +1,20 @@
1
1
  <%- include('partials/layout-top', { title: typeof title !== 'undefined' ? title : 'Error', activePage: '' }) %>
2
2
 
3
- <h1>Error <%= status %></h1>
4
- <p><%= message %></p>
5
- <% if (typeof stack !== 'undefined' && stack) { %>
6
- <details>
7
- <summary>Stack Trace (Development Only)</summary>
8
- <pre><code><%= stack %></code></pre>
9
- </details>
10
- <% } %>
11
- <p><a href="/">Return to Dashboard</a></p>
3
+ <div class="error-card">
4
+ <div class="error-card__icon">&#x26A0;</div>
5
+ <h2>Error <%= status %></h2>
6
+ <div class="error-card__message">
7
+ <p><%= message %></p>
8
+ </div>
9
+ <% if (typeof stack !== 'undefined' && stack) { %>
10
+ <details>
11
+ <summary>Stack Trace (Development Only)</summary>
12
+ <pre><code><%= stack %></code></pre>
13
+ </details>
14
+ <% } %>
15
+ <div class="error-card__action">
16
+ <a href="javascript:history.back()">Go back</a> | <a href="/">Return to Dashboard</a>
17
+ </div>
18
+ </div>
12
19
 
13
20
  <%- include('partials/layout-bottom') %>
@@ -0,0 +1,71 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
2
+
3
+ <h1>Project Analytics</h1>
4
+
5
+ <% if (typeof analytics !== 'undefined' && analytics.warning) { %>
6
+ <article aria-label="warning" class="pico-background-yellow-100">
7
+ <strong>Warning:</strong> <%= analytics.warning %>
8
+ </article>
9
+ <% } %>
10
+
11
+ <% if (typeof analytics !== 'undefined' && analytics.summary) { %>
12
+ <div class="grid">
13
+ <article>
14
+ <header>Total Commits</header>
15
+ <strong style="font-size: 2rem;"><%= analytics.summary.totalCommits %></strong>
16
+ </article>
17
+ <article>
18
+ <header>Total Phases</header>
19
+ <strong style="font-size: 2rem;"><%= analytics.summary.totalPhases %></strong>
20
+ </article>
21
+ <article>
22
+ <header>Avg Duration</header>
23
+ <strong style="font-size: 2rem;"><%= analytics.summary.avgDuration %></strong>
24
+ </article>
25
+ <article>
26
+ <header>Lines Changed</header>
27
+ <strong style="font-size: 2rem;"><%= analytics.summary.totalLinesChanged.toLocaleString() %></strong>
28
+ </article>
29
+ </div>
30
+ <% } %>
31
+
32
+ <% if (typeof analytics !== 'undefined' && analytics.phases && analytics.phases.length > 0) { %>
33
+ <article>
34
+ <header>Per-Phase Metrics</header>
35
+ <div class="overflow-auto">
36
+ <table>
37
+ <thead>
38
+ <tr>
39
+ <th>Phase</th>
40
+ <th>Commits</th>
41
+ <th>Duration</th>
42
+ <th>Plans</th>
43
+ <th>Lines Changed</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ <% analytics.phases.sort((a, b) => a.phaseId.localeCompare(b.phaseId)).forEach(phase => { %>
48
+ <tr>
49
+ <td>
50
+ <a href="/phases/<%= phase.phaseId %>"
51
+ hx-get="/phases/<%= phase.phaseId %>"
52
+ hx-target="#main-content"
53
+ hx-push-url="true">
54
+ <%= phase.phaseId %> - <%= phase.phaseName %>
55
+ </a>
56
+ </td>
57
+ <td><%= phase.commitCount %></td>
58
+ <td><%= phase.duration || 'N/A' %></td>
59
+ <td><%= phase.planCount %></td>
60
+ <td><%= phase.linesChanged.toLocaleString() %></td>
61
+ </tr>
62
+ <% }) %>
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ </article>
67
+ <% } else if (typeof analytics === 'undefined' || !analytics.summary) { %>
68
+ <%- include('empty-state', { icon: '📊', title: 'No analytics data available', action: '' }) %>
69
+ <% } else { %>
70
+ <p>No phase data available.</p>
71
+ <% } %>
@@ -0,0 +1,14 @@
1
+ <% if (typeof breadcrumbs !== 'undefined' && breadcrumbs.length > 0) { %>
2
+ <nav aria-label="Breadcrumb">
3
+ <ol class="breadcrumbs">
4
+ <li><a href="/" hx-get="/" hx-target="#main-content" hx-push-url="true">&#8962;</a></li>
5
+ <% breadcrumbs.forEach(function(item, index) { %>
6
+ <% if (index < breadcrumbs.length - 1) { %>
7
+ <li><a href="<%= item.url %>" hx-get="<%= item.url %>" hx-target="#main-content" hx-push-url="true"><%= item.label %></a></li>
8
+ <% } else { %>
9
+ <li aria-current="page"><%= item.label %></li>
10
+ <% } %>
11
+ <% }); %>
12
+ </ol>
13
+ </nav>
14
+ <% } %>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1><%= projectName %></h1>
2
3
 
3
4
  <!-- Project Overview Card -->
@@ -0,0 +1,16 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
2
+
3
+ <h1>Phase Dependencies</h1>
4
+
5
+ <% if (typeof mermaidCode !== 'undefined' && mermaidCode && mermaidCode.trim()) { %>
6
+ <article>
7
+ <pre class="mermaid"><%= mermaidCode %></pre>
8
+ </article>
9
+ <% } else { %>
10
+ <%- include('empty-state', { icon: '🔗', title: 'No dependency data available', action: '' }) %>
11
+ <% } %>
12
+
13
+ <script type="module">
14
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
15
+ mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' });
16
+ </script>
@@ -0,0 +1,7 @@
1
+ <article class="empty-state">
2
+ <p style="font-size: 2.5rem; margin-bottom: 0;"><%= typeof icon !== 'undefined' ? icon : '' %></p>
3
+ <h3><%= typeof title !== 'undefined' ? title : 'No data available' %></h3>
4
+ <% if (typeof action !== 'undefined' && action) { %>
5
+ <%- action %>
6
+ <% } %>
7
+ </article>
@@ -2,8 +2,9 @@
2
2
  <meta charset="UTF-8">
3
3
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4
4
  <title><%= typeof title !== 'undefined' ? title : 'Plan-Build-Run' %> - Plan-Build-Run</title>
5
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='80'>P</text></svg>">
5
6
  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
6
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css">
7
8
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.min.css" media="print" onload="this.media='all'">
8
9
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-600-normal.min.css" media="print" onload="this.media='all'">
9
10
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.min.css" media="print" onload="this.media='all'">
@@ -14,6 +15,7 @@
14
15
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.min.css">
15
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/fontsource/fonts/jetbrains-mono@latest/latin-400-normal.min.css">
16
17
  </noscript>
18
+ <link rel="stylesheet" href="/css/tokens.css">
17
19
  <link rel="stylesheet" href="/css/layout.css">
18
20
  <link rel="stylesheet" href="/css/status-colors.css">
19
21
  <script
@@ -24,4 +26,5 @@
24
26
  <script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.js"></script>
25
27
  <script src="/js/htmx-title.js"></script>
26
28
  <script src="/js/sidebar-toggle.js"></script>
29
+ <script src="/js/theme-toggle.js"></script>
27
30
  </head>
@@ -1,9 +1,18 @@
1
+ <style>
2
+ .theme-toggle { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: var(--space-xs); }
3
+ </style>
1
4
  <header>
2
5
  <nav>
3
6
  <ul>
4
7
  <li><strong>PBR Dashboard</strong></li>
5
8
  </ul>
6
9
  <ul>
10
+ <li>
11
+ <span id="sse-status" data-connected="false" aria-label="SSE connection status"></span>
12
+ </li>
13
+ <li>
14
+ <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌙</button>
15
+ </li>
7
16
  <li>
8
17
  <button class="sidebar-toggle" aria-label="Toggle navigation">&#9776;</button>
9
18
  </li>
@@ -1,15 +1,6 @@
1
1
  </main>
2
2
  <%- include('footer') %>
3
3
  </div>
4
- <div hx-ext="sse"
5
- sse-connect="/api/events/stream"
6
- sse-close="close">
7
- <div hx-trigger="sse:file-change"
8
- hx-get="<%= typeof currentPath !== 'undefined' ? currentPath : '/' %>"
9
- hx-target="#main-content"
10
- hx-swap="innerHTML"
11
- style="display:none;">
12
- </div>
13
- </div>
4
+ <script src="/js/sse-client.js"></script>
14
5
  </body>
15
6
  </html>
@@ -1,7 +1,14 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
+ <script>(function(){var t=localStorage.getItem('pbr-theme');if(t)document.documentElement.dataset.theme=t})()</script>
3
4
  <%- include('head', { title: typeof title !== 'undefined' ? title : 'PBR Dashboard' }) %>
4
5
  <body>
6
+ <a href="#main-content" class="skip-link">Skip to main content</a>
7
+ <div class="loading-bar"></div>
8
+ <script>
9
+ document.body.addEventListener('htmx:beforeRequest', function() { document.body.classList.add('htmx-request'); });
10
+ document.body.addEventListener('htmx:afterRequest', function() { document.body.classList.remove('htmx-request'); });
11
+ </script>
5
12
  <div class="page-wrapper">
6
13
  <%- include('header') %>
7
14
  <%- include('sidebar', { activePage: typeof activePage !== 'undefined' ? activePage : '' }) %>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1>Milestone v<%= version %></h1>
2
3
 
3
4
  <p>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1>Milestones</h1>
2
3
 
3
4
  <% if (active.length > 0) { %>
@@ -17,28 +18,63 @@
17
18
 
18
19
  <% if (archived.length > 0) { %>
19
20
  <h2>Archived</h2>
20
- <% archived.forEach(function(m) { %>
21
21
  <article>
22
- <header>
23
- <strong>
24
- <a href="/milestones/<%= m.version %>"
25
- hx-get="/milestones/<%= m.version %>"
26
- hx-target="#main-content"
27
- hx-push-url="true">
28
- v<%= m.version %> &mdash; <%= m.name %>
29
- </a>
30
- </strong>
31
- </header>
32
- <p>
33
- <% if (m.date) { %><small>Completed: <%= m.date %></small><% } %>
34
- <% if (m.duration) { %><small> &bull; Duration: <%= m.duration %></small><% } %>
35
- <br>
36
- <small><%= m.files.length %> archived file<%= m.files.length !== 1 ? 's' : '' %></small>
37
- </p>
22
+ <div class="table-wrap">
23
+ <table>
24
+ <thead>
25
+ <tr>
26
+ <th scope="col">Version</th>
27
+ <th scope="col">Name</th>
28
+ <th scope="col">Date</th>
29
+ <th scope="col">Duration</th>
30
+ <th scope="col">Phases</th>
31
+ <th scope="col">Commits</th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <% archived.forEach(function(m) { %>
36
+ <tr>
37
+ <td colspan="6" style="padding:0">
38
+ <details>
39
+ <summary style="padding:0.5rem 1rem;cursor:pointer">
40
+ <span class="grid" style="display:inline-grid;grid-template-columns:repeat(6,1fr);width:100%;text-align:left">
41
+ <span><a href="/milestones/<%= m.version %>"
42
+ hx-get="/milestones/<%= m.version %>"
43
+ hx-target="#main-content"
44
+ hx-push-url="true">v<%= m.version %></a></span>
45
+ <span><%= m.name %></span>
46
+ <span><%= m.date || '—' %></span>
47
+ <span><%= m.duration || '—' %></span>
48
+ <span><%= m.stats && m.stats.phaseCount ? m.stats.phaseCount : '—' %></span>
49
+ <span><%= m.stats && m.stats.commitCount ? m.stats.commitCount : '—' %></span>
50
+ </span>
51
+ </summary>
52
+ <div style="padding:0.5rem 1rem 1rem">
53
+ <% if (m.stats && m.stats.statsHtml) { %>
54
+ <div><%= m.stats.statsHtml %></div>
55
+ <% } %>
56
+ <% if (m.stats && m.stats.deliverables && m.stats.deliverables.length > 0) { %>
57
+ <h4>Deliverables</h4>
58
+ <ul>
59
+ <% m.stats.deliverables.forEach(function(d) { %>
60
+ <li><a href="/milestones/<%= m.version %>"
61
+ hx-get="/milestones/<%= m.version %>"
62
+ hx-target="#main-content"
63
+ hx-push-url="true"><%= d %></a></li>
64
+ <% }); %>
65
+ </ul>
66
+ <% } %>
67
+ </div>
68
+ </details>
69
+ </td>
70
+ </tr>
71
+ <% }); %>
72
+ </tbody>
73
+ </table>
74
+ </div>
38
75
  </article>
39
- <% }); %>
40
76
  <% } %>
41
77
 
42
78
  <% if (active.length === 0 && archived.length === 0) { %>
43
- <p>No milestones found. Use <code>/pbr:milestone new</code> to create one.</p>
79
+ <%- include('empty-state', { icon: '🚩', title: 'No milestones yet', action: '' }) %>
44
80
  <% } %>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1>Phase <%= phaseId %>: <%= phaseName %></h1>
2
3
 
3
4
  <p><a href="/">&larr; Back to Dashboard</a></p>
@@ -1,7 +1,7 @@
1
1
  <%
2
2
  var docLabel = docType === 'plan' ? 'Plan' : 'Summary';
3
3
  %>
4
-
4
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
5
5
  <h1><%= docLabel %> <%= planId %></h1>
6
6
 
7
7
  <p><a href="/phases/<%= phaseId %>">&larr; Back to Phase <%= phaseId %>: <%= phaseName %></a></p>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1>Phases</h1>
2
3
 
3
4
  <% if (phases.length === 0) { %>
@@ -1,3 +1,4 @@
1
+ <%- include('breadcrumbs', { breadcrumbs: typeof breadcrumbs !== 'undefined' ? breadcrumbs : [] }) %>
1
2
  <h1>Project Roadmap</h1>
2
3
 
3
4
  <% if (phases.length === 0) { %>