@sienklogic/plan-build-run 2.14.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.
- package/CHANGELOG.md +44 -0
- package/dashboard/package.json +4 -1
- package/dashboard/public/css/layout.css +237 -82
- package/dashboard/public/css/tokens.css +59 -0
- package/dashboard/public/js/sidebar-toggle.js +21 -7
- package/dashboard/public/js/sse-client.js +99 -0
- package/dashboard/public/js/theme-toggle.js +46 -0
- package/dashboard/src/app.js +4 -0
- package/dashboard/src/middleware/current-phase.js +24 -0
- package/dashboard/src/routes/events.routes.js +5 -0
- package/dashboard/src/routes/index.routes.js +2 -1
- package/dashboard/src/routes/pages.routes.js +94 -6
- package/dashboard/src/services/analytics.service.js +143 -0
- package/dashboard/src/services/milestone.service.js +50 -4
- package/dashboard/src/services/roadmap.service.js +73 -0
- package/dashboard/src/services/todo.service.js +11 -2
- package/dashboard/src/services/watcher.service.js +1 -1
- package/dashboard/src/utils/cache.js +55 -0
- package/dashboard/src/views/analytics.ejs +5 -0
- package/dashboard/src/views/dependencies.ejs +5 -0
- package/dashboard/src/views/error.ejs +16 -9
- package/dashboard/src/views/partials/analytics-content.ejs +71 -0
- package/dashboard/src/views/partials/breadcrumbs.ejs +14 -0
- package/dashboard/src/views/partials/dashboard-content.ejs +1 -0
- package/dashboard/src/views/partials/dependencies-content.ejs +16 -0
- package/dashboard/src/views/partials/empty-state.ejs +7 -0
- package/dashboard/src/views/partials/head.ejs +4 -1
- package/dashboard/src/views/partials/header.ejs +9 -0
- package/dashboard/src/views/partials/layout-bottom.ejs +1 -10
- package/dashboard/src/views/partials/layout-top.ejs +7 -0
- package/dashboard/src/views/partials/milestone-detail-content.ejs +1 -0
- package/dashboard/src/views/partials/milestones-content.ejs +55 -19
- package/dashboard/src/views/partials/phase-content.ejs +1 -0
- package/dashboard/src/views/partials/phase-doc-content.ejs +1 -1
- package/dashboard/src/views/partials/phases-content.ejs +1 -0
- package/dashboard/src/views/partials/roadmap-content.ejs +1 -0
- package/dashboard/src/views/partials/sidebar.ejs +88 -43
- package/dashboard/src/views/partials/todo-create-content.ejs +1 -0
- package/dashboard/src/views/partials/todo-detail-content.ejs +5 -1
- package/dashboard/src/views/partials/todos-content.ejs +44 -3
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/skills/build/SKILL.md +5 -0
- package/plugins/copilot-pbr/skills/help/SKILL.md +14 -0
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/skills/build/SKILL.md +5 -0
- package/plugins/cursor-pbr/skills/help/SKILL.md +14 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/skills/build/SKILL.md +5 -0
- package/plugins/pbr/skills/help/SKILL.md +14 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom SSE client with exponential backoff reconnection and state recovery.
|
|
3
|
+
*/
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
class SSEClient {
|
|
8
|
+
constructor(url) {
|
|
9
|
+
this.baseUrl = url;
|
|
10
|
+
this.lastEventId = null;
|
|
11
|
+
this.backoff = 1000;
|
|
12
|
+
this.maxBackoff = 30000;
|
|
13
|
+
this.reconnectTimer = null;
|
|
14
|
+
this.es = null;
|
|
15
|
+
this.connect();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
if (this.es) {
|
|
20
|
+
this.es.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let url = this.baseUrl;
|
|
24
|
+
if (this.lastEventId) {
|
|
25
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
26
|
+
url += sep + 'lastEventId=' + encodeURIComponent(this.lastEventId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.es = new EventSource(url);
|
|
30
|
+
|
|
31
|
+
this.es.onopen = () => {
|
|
32
|
+
this.backoff = 1000;
|
|
33
|
+
this.updateStatus(true);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.es.onerror = () => {
|
|
37
|
+
this.es.close();
|
|
38
|
+
this.updateStatus(false);
|
|
39
|
+
this.scheduleReconnect();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.es.addEventListener('file-change', (e) => {
|
|
43
|
+
if (e.lastEventId) {
|
|
44
|
+
this.lastEventId = e.lastEventId;
|
|
45
|
+
}
|
|
46
|
+
this.refreshContent();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.es.addEventListener('state-recovery', (e) => {
|
|
50
|
+
if (e.lastEventId) {
|
|
51
|
+
this.lastEventId = e.lastEventId;
|
|
52
|
+
}
|
|
53
|
+
this.refreshContent();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
scheduleReconnect() {
|
|
58
|
+
if (this.reconnectTimer) {
|
|
59
|
+
clearTimeout(this.reconnectTimer);
|
|
60
|
+
}
|
|
61
|
+
const jitter = this.backoff * (0.5 + Math.random() * 0.5);
|
|
62
|
+
this.reconnectTimer = setTimeout(() => {
|
|
63
|
+
this.connect();
|
|
64
|
+
}, jitter);
|
|
65
|
+
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
updateStatus(connected) {
|
|
69
|
+
const dot = document.getElementById('sse-status');
|
|
70
|
+
if (dot) {
|
|
71
|
+
dot.setAttribute('data-connected', String(connected));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
refreshContent() {
|
|
76
|
+
const currentPath = window.location.pathname;
|
|
77
|
+
fetch(currentPath, {
|
|
78
|
+
headers: { 'HX-Request': 'true' }
|
|
79
|
+
})
|
|
80
|
+
.then((res) => {
|
|
81
|
+
if (res.ok) return res.text();
|
|
82
|
+
throw new Error('Fetch failed: ' + res.status);
|
|
83
|
+
})
|
|
84
|
+
.then((html) => {
|
|
85
|
+
const target = document.getElementById('main-content');
|
|
86
|
+
if (target) {
|
|
87
|
+
target.innerHTML = html;
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
.catch((err) => {
|
|
91
|
+
console.error('SSE content refresh failed:', err.message);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
97
|
+
new SSEClient('/api/events/stream');
|
|
98
|
+
});
|
|
99
|
+
})();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* theme-toggle.js — Toggle light/dark theme via data-theme attribute + localStorage */
|
|
2
|
+
(function () {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
var STORAGE_KEY = 'pbr-theme';
|
|
6
|
+
|
|
7
|
+
function getEffectiveTheme() {
|
|
8
|
+
var explicit = document.documentElement.dataset.theme;
|
|
9
|
+
if (explicit === 'light' || explicit === 'dark') return explicit;
|
|
10
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function updateIcon(btn, theme) {
|
|
14
|
+
// Show sun when dark (click to go light), moon when light (click to go dark)
|
|
15
|
+
btn.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
|
16
|
+
btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
20
|
+
var btn = document.getElementById('theme-toggle');
|
|
21
|
+
if (!btn) return;
|
|
22
|
+
|
|
23
|
+
// Apply stored theme (also done in layout-top inline script for flash prevention)
|
|
24
|
+
var stored = localStorage.getItem(STORAGE_KEY);
|
|
25
|
+
if (stored) {
|
|
26
|
+
document.documentElement.dataset.theme = stored;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
updateIcon(btn, getEffectiveTheme());
|
|
30
|
+
|
|
31
|
+
btn.addEventListener('click', function () {
|
|
32
|
+
var current = getEffectiveTheme();
|
|
33
|
+
var next = current === 'dark' ? 'light' : 'dark';
|
|
34
|
+
document.documentElement.dataset.theme = next;
|
|
35
|
+
localStorage.setItem(STORAGE_KEY, next);
|
|
36
|
+
updateIcon(btn, next);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Update icon if system preference changes and no explicit preference is stored
|
|
40
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
|
41
|
+
if (!localStorage.getItem(STORAGE_KEY)) {
|
|
42
|
+
updateIcon(btn, getEffectiveTheme());
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
})();
|
package/dashboard/src/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import pagesRouter from './routes/pages.routes.js';
|
|
|
8
8
|
import eventsRouter from './routes/events.routes.js';
|
|
9
9
|
import notFoundHandler from './middleware/notFoundHandler.js';
|
|
10
10
|
import errorHandler from './middleware/errorHandler.js';
|
|
11
|
+
import currentPhaseMiddleware from './middleware/current-phase.js';
|
|
11
12
|
|
|
12
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
14
|
const __dirname = dirname(__filename);
|
|
@@ -63,6 +64,9 @@ export function createApp(config) {
|
|
|
63
64
|
next();
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
// Current phase middleware (populates res.locals.currentPhase for templates)
|
|
68
|
+
app.use(currentPhaseMiddleware);
|
|
69
|
+
|
|
66
70
|
// Routes
|
|
67
71
|
app.use('/', indexRouter);
|
|
68
72
|
app.use('/', pagesRouter);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseStateFile } from '../services/dashboard.service.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Middleware that reads STATE.md and sets res.locals.currentPhase
|
|
5
|
+
* for use in sidebar and other templates.
|
|
6
|
+
*/
|
|
7
|
+
export default async function currentPhaseMiddleware(req, res, next) {
|
|
8
|
+
try {
|
|
9
|
+
const state = await parseStateFile(req.app.locals.projectDir);
|
|
10
|
+
const cp = state.currentPhase;
|
|
11
|
+
if (cp && cp.id > 0) {
|
|
12
|
+
res.locals.currentPhase = {
|
|
13
|
+
number: cp.id,
|
|
14
|
+
name: cp.name,
|
|
15
|
+
status: cp.status
|
|
16
|
+
};
|
|
17
|
+
} else {
|
|
18
|
+
res.locals.currentPhase = null;
|
|
19
|
+
}
|
|
20
|
+
} catch (_err) {
|
|
21
|
+
res.locals.currentPhase = null;
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
}
|
|
@@ -22,6 +22,11 @@ router.get('/stream', (req, res) => {
|
|
|
22
22
|
// Send initial connection confirmation
|
|
23
23
|
res.write(': connected\n\n');
|
|
24
24
|
|
|
25
|
+
// If client reconnected with a lastEventId, send state-recovery event
|
|
26
|
+
if (req.query.lastEventId) {
|
|
27
|
+
res.write(`event: state-recovery\ndata: {"action":"refresh"}\nid: ${Date.now()}\n\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
// Register this client for broadcasts
|
|
26
31
|
addClient(res);
|
|
27
32
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { getPhaseDetail, getPhaseDocument } from '../services/phase.service.js';
|
|
3
|
-
import { getRoadmapData } from '../services/roadmap.service.js';
|
|
3
|
+
import { getRoadmapData, generateDependencyMermaid } from '../services/roadmap.service.js';
|
|
4
4
|
import { parseStateFile, derivePhaseStatuses } from '../services/dashboard.service.js';
|
|
5
5
|
import { listPendingTodos, getTodoDetail, createTodo, completeTodo } from '../services/todo.service.js';
|
|
6
6
|
import { getAllMilestones, getMilestoneDetail } from '../services/milestone.service.js';
|
|
7
|
+
import { getProjectAnalytics } from '../services/analytics.service.js';
|
|
7
8
|
|
|
8
9
|
const router = Router();
|
|
9
10
|
|
|
@@ -19,7 +20,8 @@ router.get('/phases', async (req, res) => {
|
|
|
19
20
|
activePage: 'phases',
|
|
20
21
|
currentPath: '/phases',
|
|
21
22
|
phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
|
|
22
|
-
milestones: roadmapData.milestones
|
|
23
|
+
milestones: roadmapData.milestones,
|
|
24
|
+
breadcrumbs: [{ label: 'Phases' }]
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
res.setHeader('Vary', 'HX-Request');
|
|
@@ -48,6 +50,7 @@ router.get('/phases/:phaseId', async (req, res) => {
|
|
|
48
50
|
title: `Phase ${phaseId}: ${phaseData.phaseName}`,
|
|
49
51
|
activePage: 'phases',
|
|
50
52
|
currentPath: '/phases/' + phaseId,
|
|
53
|
+
breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId }],
|
|
51
54
|
...phaseData
|
|
52
55
|
};
|
|
53
56
|
|
|
@@ -98,6 +101,7 @@ router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
|
|
|
98
101
|
title: `${docLabel} ${planId} — Phase ${phaseId}: ${doc.phaseName}`,
|
|
99
102
|
activePage: 'phases',
|
|
100
103
|
currentPath: `/phases/${phaseId}/${planId}/${docType}`,
|
|
104
|
+
breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId, url: '/phases/' + phaseId }, { label: docLabel + ' ' + planId }],
|
|
101
105
|
...doc
|
|
102
106
|
};
|
|
103
107
|
|
|
@@ -112,13 +116,20 @@ router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
|
|
|
112
116
|
|
|
113
117
|
router.get('/todos', async (req, res) => {
|
|
114
118
|
const projectDir = req.app.locals.projectDir;
|
|
115
|
-
const
|
|
119
|
+
const { priority, status, q } = req.query;
|
|
120
|
+
const filters = {};
|
|
121
|
+
if (priority) filters.priority = priority;
|
|
122
|
+
if (status) filters.status = status;
|
|
123
|
+
if (q) filters.q = q;
|
|
124
|
+
const todos = await listPendingTodos(projectDir, filters);
|
|
116
125
|
|
|
117
126
|
const templateData = {
|
|
118
127
|
title: 'Todos',
|
|
119
128
|
activePage: 'todos',
|
|
120
129
|
currentPath: '/todos',
|
|
121
|
-
|
|
130
|
+
breadcrumbs: [{ label: 'Todos' }],
|
|
131
|
+
todos,
|
|
132
|
+
filters: { priority: priority || '', status: status || '', q: q || '' }
|
|
122
133
|
};
|
|
123
134
|
|
|
124
135
|
res.setHeader('Vary', 'HX-Request');
|
|
@@ -130,11 +141,40 @@ router.get('/todos', async (req, res) => {
|
|
|
130
141
|
}
|
|
131
142
|
});
|
|
132
143
|
|
|
144
|
+
router.post('/todos/bulk-complete', async (req, res) => {
|
|
145
|
+
const projectDir = req.app.locals.projectDir;
|
|
146
|
+
const { priority, status, q } = req.query;
|
|
147
|
+
const filters = {};
|
|
148
|
+
if (priority) filters.priority = priority;
|
|
149
|
+
if (status) filters.status = status;
|
|
150
|
+
if (q) filters.q = q;
|
|
151
|
+
|
|
152
|
+
const todos = await listPendingTodos(projectDir, filters);
|
|
153
|
+
for (const todo of todos) {
|
|
154
|
+
await completeTodo(projectDir, todo.id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (req.get('HX-Request') === 'true') {
|
|
158
|
+
const remaining = await listPendingTodos(projectDir);
|
|
159
|
+
res.render('partials/todos-content', {
|
|
160
|
+
title: 'Todos',
|
|
161
|
+
activePage: 'todos',
|
|
162
|
+
currentPath: '/todos',
|
|
163
|
+
breadcrumbs: [{ label: 'Todos' }],
|
|
164
|
+
todos: remaining,
|
|
165
|
+
filters: { priority: '', status: '', q: '' }
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
res.redirect('/todos');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
133
172
|
router.get('/todos/new', (req, res) => {
|
|
134
173
|
const templateData = {
|
|
135
174
|
title: 'Create Todo',
|
|
136
175
|
activePage: 'todos',
|
|
137
|
-
currentPath: '/todos/new'
|
|
176
|
+
currentPath: '/todos/new',
|
|
177
|
+
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Create' }]
|
|
138
178
|
};
|
|
139
179
|
|
|
140
180
|
res.setHeader('Vary', 'HX-Request');
|
|
@@ -163,6 +203,7 @@ router.get('/todos/:id', async (req, res) => {
|
|
|
163
203
|
title: `Todo ${todo.id}: ${todo.title}`,
|
|
164
204
|
activePage: 'todos',
|
|
165
205
|
currentPath: '/todos/' + id,
|
|
206
|
+
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + id }],
|
|
166
207
|
...todo
|
|
167
208
|
};
|
|
168
209
|
|
|
@@ -193,6 +234,7 @@ router.post('/todos', async (req, res) => {
|
|
|
193
234
|
title: `Todo ${todo.id}: ${todo.title}`,
|
|
194
235
|
activePage: 'todos',
|
|
195
236
|
currentPath: '/todos/' + todoId,
|
|
237
|
+
breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + todoId }],
|
|
196
238
|
...todo
|
|
197
239
|
});
|
|
198
240
|
} else {
|
|
@@ -219,6 +261,7 @@ router.post('/todos/:id/done', async (req, res) => {
|
|
|
219
261
|
title: 'Todos',
|
|
220
262
|
activePage: 'todos',
|
|
221
263
|
currentPath: '/todos',
|
|
264
|
+
breadcrumbs: [{ label: 'Todos' }],
|
|
222
265
|
todos
|
|
223
266
|
});
|
|
224
267
|
} else {
|
|
@@ -234,6 +277,7 @@ router.get('/milestones', async (req, res) => {
|
|
|
234
277
|
title: 'Milestones',
|
|
235
278
|
activePage: 'milestones',
|
|
236
279
|
currentPath: '/milestones',
|
|
280
|
+
breadcrumbs: [{ label: 'Milestones' }],
|
|
237
281
|
...milestoneData
|
|
238
282
|
};
|
|
239
283
|
|
|
@@ -269,6 +313,7 @@ router.get('/milestones/:version', async (req, res) => {
|
|
|
269
313
|
title: `Milestone v${version}`,
|
|
270
314
|
activePage: 'milestones',
|
|
271
315
|
currentPath: '/milestones/' + version,
|
|
316
|
+
breadcrumbs: [{ label: 'Milestones', url: '/milestones' }, { label: 'v' + version }],
|
|
272
317
|
...detail
|
|
273
318
|
};
|
|
274
319
|
|
|
@@ -281,6 +326,48 @@ router.get('/milestones/:version', async (req, res) => {
|
|
|
281
326
|
}
|
|
282
327
|
});
|
|
283
328
|
|
|
329
|
+
router.get('/dependencies', async (req, res) => {
|
|
330
|
+
const projectDir = req.app.locals.projectDir;
|
|
331
|
+
const mermaidCode = await generateDependencyMermaid(projectDir);
|
|
332
|
+
|
|
333
|
+
const templateData = {
|
|
334
|
+
title: 'Dependencies',
|
|
335
|
+
activePage: 'dependencies',
|
|
336
|
+
currentPath: '/dependencies',
|
|
337
|
+
breadcrumbs: [{ label: 'Dependencies' }],
|
|
338
|
+
mermaidCode
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
res.setHeader('Vary', 'HX-Request');
|
|
342
|
+
|
|
343
|
+
if (req.get('HX-Request') === 'true') {
|
|
344
|
+
res.render('partials/dependencies-content', templateData);
|
|
345
|
+
} else {
|
|
346
|
+
res.render('dependencies', templateData);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
router.get('/analytics', async (req, res) => {
|
|
351
|
+
const projectDir = req.app.locals.projectDir;
|
|
352
|
+
const analytics = await getProjectAnalytics(projectDir);
|
|
353
|
+
|
|
354
|
+
const templateData = {
|
|
355
|
+
title: 'Analytics',
|
|
356
|
+
activePage: 'analytics',
|
|
357
|
+
currentPath: '/analytics',
|
|
358
|
+
breadcrumbs: [{ label: 'Analytics' }],
|
|
359
|
+
analytics
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
res.setHeader('Vary', 'HX-Request');
|
|
363
|
+
|
|
364
|
+
if (req.get('HX-Request') === 'true') {
|
|
365
|
+
res.render('partials/analytics-content', templateData);
|
|
366
|
+
} else {
|
|
367
|
+
res.render('analytics', templateData);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
284
371
|
router.get('/roadmap', async (req, res) => {
|
|
285
372
|
const projectDir = req.app.locals.projectDir;
|
|
286
373
|
const [roadmapData, stateData] = await Promise.all([
|
|
@@ -293,7 +380,8 @@ router.get('/roadmap', async (req, res) => {
|
|
|
293
380
|
activePage: 'roadmap',
|
|
294
381
|
currentPath: '/roadmap',
|
|
295
382
|
phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
|
|
296
|
-
milestones: roadmapData.milestones
|
|
383
|
+
milestones: roadmapData.milestones,
|
|
384
|
+
breadcrumbs: [{ label: 'Roadmap' }]
|
|
297
385
|
};
|
|
298
386
|
|
|
299
387
|
res.setHeader('Vary', 'HX-Request');
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { TTLCache } from '../utils/cache.js';
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCb);
|
|
8
|
+
|
|
9
|
+
export const cache = new TTLCache(60_000); // 60s TTL
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a git command in the given directory, returning stdout.
|
|
13
|
+
* Returns empty string on failure.
|
|
14
|
+
*/
|
|
15
|
+
async function git(projectDir, args) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFile('git', args, {
|
|
18
|
+
cwd: projectDir,
|
|
19
|
+
maxBuffer: 10 * 1024 * 1024
|
|
20
|
+
});
|
|
21
|
+
return stdout;
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute project analytics from git history and .planning/ files.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} projectDir - Absolute path to the project root
|
|
31
|
+
* @returns {Promise<{phases: Array, summary: object, warning?: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function getProjectAnalytics(projectDir) {
|
|
34
|
+
const cacheKey = `analytics:${projectDir}`;
|
|
35
|
+
const cached = cache.get(cacheKey);
|
|
36
|
+
if (cached) return cached;
|
|
37
|
+
|
|
38
|
+
const phasesDir = join(projectDir, '.planning', 'phases');
|
|
39
|
+
let phaseDirs = [];
|
|
40
|
+
let warning = null;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
44
|
+
phaseDirs = entries
|
|
45
|
+
.filter(e => e.isDirectory() && /^\d{2}-/.test(e.name))
|
|
46
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.code === 'ENOENT') {
|
|
49
|
+
warning = 'No .planning/phases/ directory found';
|
|
50
|
+
} else {
|
|
51
|
+
warning = `Failed to read phases directory: ${err.message}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get all commit log lines once
|
|
56
|
+
const allLog = await git(projectDir, ['log', '--oneline', '--all']);
|
|
57
|
+
const allLogLines = allLog ? allLog.trim().split('\n').filter(Boolean) : [];
|
|
58
|
+
|
|
59
|
+
// Get numstat for lines changed
|
|
60
|
+
const numstatRaw = await git(projectDir, ['log', '--numstat', '--all', '--format=COMMIT:%s']);
|
|
61
|
+
|
|
62
|
+
const phases = [];
|
|
63
|
+
|
|
64
|
+
for (const dir of phaseDirs) {
|
|
65
|
+
const phaseNum = dir.name.split('-')[0];
|
|
66
|
+
const phaseName = dir.name.split('-').slice(1).map(
|
|
67
|
+
w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
|
68
|
+
).join(' ');
|
|
69
|
+
|
|
70
|
+
// Commit count: match scope pattern (NN-
|
|
71
|
+
const scopePattern = new RegExp(`\\(${phaseNum}-`);
|
|
72
|
+
const commitCount = allLogLines.filter(line => scopePattern.test(line)).length;
|
|
73
|
+
|
|
74
|
+
// Phase duration from git dates
|
|
75
|
+
const dateOutput = await git(projectDir, [
|
|
76
|
+
'log', '--format=%aI', '--', `.planning/phases/${dir.name}/`
|
|
77
|
+
]);
|
|
78
|
+
const dates = dateOutput.trim().split('\n').filter(Boolean).map(d => new Date(d));
|
|
79
|
+
let duration = null;
|
|
80
|
+
if (dates.length >= 2) {
|
|
81
|
+
const earliest = new Date(Math.min(...dates));
|
|
82
|
+
const latest = new Date(Math.max(...dates));
|
|
83
|
+
const days = Math.round((latest - earliest) / (1000 * 60 * 60 * 24));
|
|
84
|
+
duration = `${days}d`;
|
|
85
|
+
} else if (dates.length === 1) {
|
|
86
|
+
duration = '0d';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Plan count
|
|
90
|
+
let planCount = 0;
|
|
91
|
+
try {
|
|
92
|
+
const files = await readdir(join(phasesDir, dir.name));
|
|
93
|
+
planCount = files.filter(f => /^(?:\d{2}-\d{2}-)?PLAN.*\.md$/i.test(f)).length;
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Lines changed: parse numstat output for commits matching this phase scope
|
|
99
|
+
let linesChanged = 0;
|
|
100
|
+
if (numstatRaw) {
|
|
101
|
+
let currentCommitMatches = false;
|
|
102
|
+
for (const line of numstatRaw.split('\n')) {
|
|
103
|
+
if (line.startsWith('COMMIT:')) {
|
|
104
|
+
currentCommitMatches = scopePattern.test(line);
|
|
105
|
+
} else if (currentCommitMatches && line.trim()) {
|
|
106
|
+
const parts = line.split('\t');
|
|
107
|
+
if (parts.length >= 2) {
|
|
108
|
+
const added = parseInt(parts[0], 10) || 0;
|
|
109
|
+
const deleted = parseInt(parts[1], 10) || 0;
|
|
110
|
+
linesChanged += added + deleted;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
phases.push({
|
|
117
|
+
phaseId: phaseNum,
|
|
118
|
+
phaseName,
|
|
119
|
+
commitCount,
|
|
120
|
+
duration,
|
|
121
|
+
planCount,
|
|
122
|
+
linesChanged
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Aggregate summary
|
|
127
|
+
const totalCommits = phases.reduce((s, p) => s + p.commitCount, 0);
|
|
128
|
+
const totalPhases = phases.length;
|
|
129
|
+
const durations = phases.filter(p => p.duration).map(p => parseInt(p.duration, 10));
|
|
130
|
+
const avgDuration = durations.length
|
|
131
|
+
? `${Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)}d`
|
|
132
|
+
: 'N/A';
|
|
133
|
+
const totalLinesChanged = phases.reduce((s, p) => s + p.linesChanged, 0);
|
|
134
|
+
|
|
135
|
+
const result = {
|
|
136
|
+
phases,
|
|
137
|
+
summary: { totalCommits, totalPhases, avgDuration, totalLinesChanged },
|
|
138
|
+
...(warning ? { warning } : {})
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
cache.set(cacheKey, result);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
@@ -2,6 +2,9 @@ import { readdir } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { readMarkdownFile } from '../repositories/planning.repository.js';
|
|
4
4
|
import { getRoadmapData } from './roadmap.service.js';
|
|
5
|
+
import { TTLCache } from '../utils/cache.js';
|
|
6
|
+
|
|
7
|
+
export const cache = new TTLCache(300_000); // 300s TTL
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Scan .planning/milestones/ for archived milestone files.
|
|
@@ -14,6 +17,10 @@ import { getRoadmapData } from './roadmap.service.js';
|
|
|
14
17
|
* @returns {Promise<Array<{version: string, name: string, date: string, duration: string, files: string[]}>>}
|
|
15
18
|
*/
|
|
16
19
|
export async function listArchivedMilestones(projectDir) {
|
|
20
|
+
const cacheKey = `milestones:${projectDir}`;
|
|
21
|
+
const cached = cache.get(cacheKey);
|
|
22
|
+
if (cached) return cached;
|
|
23
|
+
|
|
17
24
|
const milestonesDir = join(projectDir, '.planning', 'milestones');
|
|
18
25
|
|
|
19
26
|
let entries;
|
|
@@ -64,8 +71,9 @@ export async function listArchivedMilestones(projectDir) {
|
|
|
64
71
|
versionMap.get(version).files.push(entry.name);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
// Try to parse STATS.md for each version to get name/date/duration
|
|
74
|
+
// Try to parse STATS.md for each version to get name/date/duration/stats
|
|
68
75
|
for (const [version, milestone] of versionMap) {
|
|
76
|
+
milestone.stats = { phaseCount: 0, commitCount: 0, deliverables: [] };
|
|
69
77
|
let statsPath;
|
|
70
78
|
if (milestone.format === 'directory') {
|
|
71
79
|
if (milestone.files.includes('STATS.md')) {
|
|
@@ -80,22 +88,53 @@ export async function listArchivedMilestones(projectDir) {
|
|
|
80
88
|
|
|
81
89
|
if (statsPath) {
|
|
82
90
|
try {
|
|
83
|
-
const { frontmatter } = await readMarkdownFile(statsPath);
|
|
91
|
+
const { frontmatter, html } = await readMarkdownFile(statsPath);
|
|
84
92
|
milestone.name = frontmatter.milestone || frontmatter.name || `v${version}`;
|
|
85
93
|
milestone.date = frontmatter.completed || frontmatter.date || '';
|
|
86
94
|
milestone.duration = frontmatter.duration || '';
|
|
95
|
+
milestone.stats.phaseCount = frontmatter.phases_completed || frontmatter.phase_count || 0;
|
|
96
|
+
milestone.stats.commitCount = frontmatter.total_commits || frontmatter.commit_count || 0;
|
|
97
|
+
milestone.stats.statsHtml = html || '';
|
|
87
98
|
} catch (_e) {
|
|
88
99
|
milestone.name = `v${version}`;
|
|
89
100
|
}
|
|
90
101
|
} else {
|
|
91
102
|
milestone.name = `v${version}`;
|
|
92
103
|
}
|
|
104
|
+
|
|
105
|
+
// Try to read deliverables from archived ROADMAP.md
|
|
106
|
+
if (milestone.format === 'directory' && milestone.files.includes('ROADMAP.md')) {
|
|
107
|
+
try {
|
|
108
|
+
const { frontmatter: rmFm } = await readMarkdownFile(join(milestonesDir, `v${version}`, 'ROADMAP.md'));
|
|
109
|
+
if (Array.isArray(rmFm.phases)) {
|
|
110
|
+
milestone.stats.deliverables = rmFm.phases.map(p => typeof p === 'string' ? p : (p.name || p.title || ''));
|
|
111
|
+
}
|
|
112
|
+
} catch (_e) { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Try phases/ subdirectory for deliverables if none found yet
|
|
116
|
+
if (milestone.format === 'directory' && milestone.stats.deliverables.length === 0) {
|
|
117
|
+
try {
|
|
118
|
+
const phasesDir = join(milestonesDir, `v${version}`, 'phases');
|
|
119
|
+
const phaseDirs = await readdir(phasesDir, { withFileTypes: true });
|
|
120
|
+
milestone.stats.deliverables = phaseDirs
|
|
121
|
+
.filter(d => d.isDirectory())
|
|
122
|
+
.map(d => d.name)
|
|
123
|
+
.sort();
|
|
124
|
+
if (milestone.stats.phaseCount === 0) {
|
|
125
|
+
milestone.stats.phaseCount = milestone.stats.deliverables.length;
|
|
126
|
+
}
|
|
127
|
+
} catch (_e) { /* no phases dir */ }
|
|
128
|
+
}
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
// Sort by version descending (newest first) — strip internal format field
|
|
96
|
-
|
|
132
|
+
const result = [...versionMap.values()]
|
|
97
133
|
.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
|
|
98
134
|
.map(({ format: _f, ...rest }) => rest);
|
|
135
|
+
|
|
136
|
+
cache.set(cacheKey, result);
|
|
137
|
+
return result;
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
/**
|
|
@@ -105,15 +144,22 @@ export async function listArchivedMilestones(projectDir) {
|
|
|
105
144
|
* @returns {Promise<{active: Array, archived: Array}>}
|
|
106
145
|
*/
|
|
107
146
|
export async function getAllMilestones(projectDir) {
|
|
147
|
+
const allCacheKey = `all-milestones:${projectDir}`;
|
|
148
|
+
const allCached = cache.get(allCacheKey);
|
|
149
|
+
if (allCached) return allCached;
|
|
150
|
+
|
|
108
151
|
const [roadmapData, archived] = await Promise.all([
|
|
109
152
|
getRoadmapData(projectDir),
|
|
110
153
|
listArchivedMilestones(projectDir)
|
|
111
154
|
]);
|
|
112
155
|
|
|
113
|
-
|
|
156
|
+
const allResult = {
|
|
114
157
|
active: roadmapData.milestones || [],
|
|
115
158
|
archived
|
|
116
159
|
};
|
|
160
|
+
|
|
161
|
+
cache.set(allCacheKey, allResult);
|
|
162
|
+
return allResult;
|
|
117
163
|
}
|
|
118
164
|
|
|
119
165
|
/**
|