trackops 2.0.2 → 2.0.4

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 (48) hide show
  1. package/README.md +238 -0
  2. package/lib/init.js +2 -2
  3. package/lib/locale.js +41 -17
  4. package/lib/opera-bootstrap.js +68 -7
  5. package/lib/opera.js +10 -2
  6. package/lib/registry.js +18 -0
  7. package/lib/server.js +312 -207
  8. package/locales/en.json +4 -0
  9. package/locales/es.json +4 -0
  10. package/package.json +1 -1
  11. package/skills/trackops/SKILL.md +39 -4
  12. package/skills/trackops/agents/openai.yaml +2 -2
  13. package/skills/trackops/locales/en/SKILL.md +39 -4
  14. package/skills/trackops/locales/en/references/activation.md +15 -0
  15. package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
  16. package/skills/trackops/references/activation.md +15 -0
  17. package/skills/trackops/references/troubleshooting.md +12 -0
  18. package/skills/trackops/skill.json +4 -4
  19. package/ui/css/base.css +19 -1
  20. package/ui/css/charts.css +106 -8
  21. package/ui/css/components.css +554 -17
  22. package/ui/css/onboarding.css +133 -0
  23. package/ui/css/panels.css +345 -406
  24. package/ui/css/terminal.css +125 -0
  25. package/ui/css/timeline.css +58 -0
  26. package/ui/css/tokens.css +170 -113
  27. package/ui/index.html +3 -0
  28. package/ui/js/api.js +49 -13
  29. package/ui/js/app.js +28 -32
  30. package/ui/js/charts.js +526 -0
  31. package/ui/js/filters.js +247 -0
  32. package/ui/js/icons.js +82 -57
  33. package/ui/js/keyboard.js +229 -0
  34. package/ui/js/onboarding.js +33 -42
  35. package/ui/js/router.js +20 -3
  36. package/ui/js/views/board.js +84 -114
  37. package/ui/js/views/dashboard.js +870 -0
  38. package/ui/js/views/projects.js +745 -0
  39. package/ui/js/views/scrum.js +476 -0
  40. package/ui/js/views/settings.js +197 -247
  41. package/ui/js/views/sidebar.js +37 -31
  42. package/ui/js/views/tasks.js +218 -101
  43. package/ui/js/views/timeline.js +265 -0
  44. package/ui/js/views/topbar.js +94 -107
  45. package/ui/app.js +0 -950
  46. package/ui/js/views/insights.js +0 -340
  47. package/ui/js/views/overview.js +0 -369
  48. package/ui/styles.css +0 -688
@@ -0,0 +1,870 @@
1
+ /**
2
+ * dashboard.js — Unified Dashboard view with tabbed Overview + Analytics
3
+ * Merges overview.js and insights.js into a single tabbed interface.
4
+ */
5
+
6
+ import { icon } from '../icons.js';
7
+ import * as state from '../state.js';
8
+ import * as api from '../api.js';
9
+ import { esc, formatDate, formatDurationShort, extractHistory, lastDays } from '../utils.js';
10
+ import { t } from '../i18n.js';
11
+ import { sparkline, lineChart, heatmap } from '../charts.js';
12
+
13
+ /** Map OPERA phase IDs to standard dev labels */
14
+ const PHASE_LABELS = {
15
+ O: { es: 'Diseño', en: 'Design' },
16
+ P: { es: 'QA', en: 'QA' },
17
+ E: { es: 'Dev', en: 'Dev' },
18
+ R: { es: 'Polish', en: 'Polish' },
19
+ A: { es: 'Deploy', en: 'Deploy' },
20
+ };
21
+
22
+ function _phaseLabel(phaseId) {
23
+ const locale = state.get('locale') || 'es';
24
+ const map = PHASE_LABELS[phaseId];
25
+ return map ? map[locale] || map.en : phaseId || '—';
26
+ }
27
+
28
+ // ─── Module state ────────────────────────────────────────────────
29
+ let _activeTab = 'overview'; // 'overview' | 'analytics'
30
+ let _activePeriod = '30d'; // for analytics tab
31
+ let _analytics = null;
32
+
33
+ // ─── Public API ──────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Renders the full Dashboard view with tab pills and the active tab content.
37
+ * @returns {Promise<string>} HTML string
38
+ */
39
+ export async function render() {
40
+ const payload = state.getPayload();
41
+ if (!payload) {
42
+ return `<div class="empty-state" style="margin:3rem auto;max-width:440px">
43
+ ${icon('alertCircle', 32)}
44
+ <p>${t('ui.dashboard.loadError', {}, 'Could not load project state.')}</p>
45
+ <button class="btn btn-primary" onclick="window.dispatchEvent(new CustomEvent('ops:refresh'))">${t('ui.dashboard.retry', {}, 'Retry')}</button>
46
+ </div>`;
47
+ }
48
+
49
+ const content = await _renderTabContent(payload);
50
+
51
+ return `
52
+ <section class="view-section">
53
+ <div class="section-header" style="display:flex;justify-content:space-between;align-items:center">
54
+ <h2 class="section-title">${t('ui.dashboard.title', {}, 'Dashboard')}</h2>
55
+ <div class="tab-pills" role="tablist" aria-label="${t('ui.dashboard.tabs', {}, 'Dashboard tabs')}">
56
+ <button class="tab-pill${_activeTab === 'overview' ? ' is-active' : ''}" data-tab="overview" role="tab" aria-selected="${_activeTab === 'overview'}" aria-controls="dashboard-content">${t('ui.dashboard.tabOverview', {}, 'Overview')}</button>
57
+ <button class="tab-pill${_activeTab === 'analytics' ? ' is-active' : ''}" data-tab="analytics" role="tab" aria-selected="${_activeTab === 'analytics'}" aria-controls="dashboard-content">${t('ui.dashboard.tabAnalytics', {}, 'Analytics')}</button>
58
+ </div>
59
+ </div>
60
+ <div id="dashboard-content" role="tabpanel">
61
+ ${content}
62
+ </div>
63
+ </section>
64
+ `;
65
+ }
66
+
67
+ /**
68
+ * Binds tab switching and all view-specific events for the active tab.
69
+ */
70
+ export function bindEvents() {
71
+ _bindTabSwitching();
72
+ _bindActiveTabEvents();
73
+ }
74
+
75
+ // ─── Tab switching ───────────────────────────────────────────────
76
+
77
+ function _bindTabSwitching() {
78
+ document.querySelectorAll('.tab-pills [data-tab]').forEach(btn => {
79
+ btn.addEventListener('click', async () => {
80
+ const tab = btn.dataset.tab;
81
+ if (tab === _activeTab) return;
82
+ _activeTab = tab;
83
+
84
+ // Toggle is-active on tab pills
85
+ document.querySelectorAll('.tab-pills [data-tab]').forEach(b => {
86
+ b.classList.toggle('is-active', b.dataset.tab === tab);
87
+ b.setAttribute('aria-selected', String(b.dataset.tab === tab));
88
+ });
89
+
90
+ // Re-render only the content area
91
+ const contentEl = document.getElementById('dashboard-content');
92
+ if (contentEl) {
93
+ const payload = state.getPayload();
94
+ if (payload) {
95
+ contentEl.innerHTML = await _renderTabContent(payload);
96
+ _bindActiveTabEvents();
97
+ }
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ function _bindActiveTabEvents() {
104
+ if (_activeTab === 'overview') {
105
+ _bindTaskActionEvents();
106
+ } else {
107
+ _bindPeriodEvents();
108
+ }
109
+ }
110
+
111
+ // ─── Tab content router ──────────────────────────────────────────
112
+
113
+ async function _renderTabContent(payload) {
114
+ if (_activeTab === 'analytics') {
115
+ return _renderAnalyticsTab(payload);
116
+ }
117
+ return _renderOverviewTab(payload);
118
+ }
119
+
120
+ // ═══════════════════════════════════════════════════════════════════
121
+ // OVERVIEW TAB
122
+ // ═══════════════════════════════════════════════════════════════════
123
+
124
+ function _renderOverviewTab(payload) {
125
+ const { derived, control } = payload;
126
+
127
+ return `
128
+ <div class="view-enter">
129
+ <!-- KPI CARDS -->
130
+ <div class="grid-4" style="margin-bottom:var(--space-5)" aria-label="${t('ui.overview.metrics', {}, 'Project metrics')}" role="region">
131
+ ${_renderKPI(t('ui.overview.openWork', {}, 'Open work'), derived.totals.all - derived.totals.completed - derived.totals.cancelled,
132
+ t('ui.overview.openWorkSub', { pending: derived.totals.pending, inProgress: derived.totals.inProgress }, `${derived.totals.pending} pending · ${derived.totals.inProgress} active`), 'tasks', 'accent',
133
+ _kpiSparkline(control.tasks, tsk => tsk.status !== 'completed' && tsk.status !== 'cancelled'))}
134
+ ${_renderKPI(t('ui.overview.completed', {}, 'Completed'), derived.totals.completed,
135
+ t('ui.overview.completedSub', { percent: derived.totals.all ? Math.round(derived.totals.completed/derived.totals.all*100) : 0 }, '0% of total'), 'checkCircle', 'success',
136
+ _kpiSparkline(control.tasks, tsk => tsk.status === 'completed'))}
137
+ ${_renderKPI(t('ui.overview.blocked', {}, 'Blocked'), derived.totals.blocked,
138
+ derived.blockers[0]?.title || t('ui.overview.noBlockers', {}, 'No blockers'), 'shield', 'danger',
139
+ _kpiSparkline(control.tasks, tsk => tsk.status === 'blocked'))}
140
+ ${_renderKPI(t('ui.overview.inReview', {}, 'In review'), derived.totals.inReview,
141
+ derived.reviewTasks[0]?.title || t('ui.overview.noReview', {}, 'No tasks in review'), 'alertCircle', 'warning',
142
+ _kpiSparkline(control.tasks, tsk => tsk.status === 'in_review'))}
143
+ </div>
144
+
145
+ <!-- MAIN ROW: Charts + Sidebar -->
146
+ <div style="display:grid;grid-template-columns:1fr 340px;gap:var(--space-5);margin-bottom:var(--space-5)">
147
+
148
+ <!-- Charts column -->
149
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
150
+
151
+ <!-- Activity Chart -->
152
+ <div class="glass-card chart-card card-hover-lift stagger-1" aria-label="${t('ui.overview.weeklyActivity', {}, 'Last week activity')}">
153
+ <div style="display:flex;align-items:center;justify-content:space-between">
154
+ <div>
155
+ <p class="chart-title">${t('ui.overview.activityTitle', {}, 'Weekly activity')}</p>
156
+ <p class="chart-subtitle">${t('ui.overview.activitySubtitle', {}, 'State changes recorded per day')}</p>
157
+ </div>
158
+ </div>
159
+ ${_renderActivityChart(control.tasks)}
160
+ </div>
161
+
162
+ <!-- Phase Chart -->
163
+ <div class="glass-card chart-card card-hover-lift stagger-2" aria-label="${t('ui.overview.phaseProgress', {}, 'Progress by phase')}">
164
+ <p class="chart-title">${t('ui.overview.phaseProgress', {}, 'Progress by phase')}</p>
165
+ ${_renderPhaseChart(derived.phaseStats)}
166
+ </div>
167
+
168
+ </div>
169
+
170
+ <!-- Right column: Donut + Next task -->
171
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
172
+
173
+ <!-- Donut -->
174
+ <div class="glass-card chart-card card-hover-lift stagger-3" style="align-items:center" aria-label="${t('ui.overview.globalProgress', {}, 'Global project progress')}">
175
+ <p class="chart-title" style="width:100%">${t('ui.overview.globalProgress', {}, 'Global progress')}</p>
176
+ ${_renderDonut(derived.totals)}
177
+ </div>
178
+
179
+ <!-- Next task -->
180
+ <div class="glass-card chart-card card-hover-lift stagger-4" aria-label="${t('ui.overview.nextTask', {}, 'Next task')}">
181
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.overview.nextMove', {}, 'Next move')}</p>
182
+ ${derived.nextTask ? _renderNextTask(derived.nextTask) : `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noOpenTasks', {}, 'No open tasks')}</p>`}
183
+ </div>
184
+
185
+ </div>
186
+ </div>
187
+
188
+ <!-- (Health grid moved to Analytics tab — no duplication) -->
189
+ </div>
190
+ `;
191
+ }
192
+
193
+ // ─────────────────────────────── KPI CARD ───────────────────────────────────
194
+
195
+ function _renderKPI(title, value, sub, iconName, variant, sparklineSvg = '') {
196
+ return `
197
+ <article class="glass-card kpi-card kpi-${variant}" role="figure" aria-label="${title}: ${value}">
198
+ <div class="kpi-header">
199
+ <p class="kpi-title">${esc(title)}</p>
200
+ <div class="kpi-icon ${variant}">${icon(iconName, 16)}</div>
201
+ </div>
202
+ <div style="display:flex;align-items:flex-end;justify-content:space-between;gap:var(--space-2)">
203
+ <p class="kpi-value">${esc(String(value))}</p>
204
+ ${sparklineSvg ? `<div class="kpi-sparkline" aria-hidden="true" style="flex-shrink:0">${sparklineSvg}</div>` : ''}
205
+ </div>
206
+ <p class="kpi-sub truncate">${esc(sub)}</p>
207
+ </article>
208
+ `;
209
+ }
210
+
211
+ /**
212
+ * Builds a 7-day sparkline for a KPI metric.
213
+ * @param {Array} tasks - all tasks from control
214
+ * @param {Function} filterFn - predicate applied to history entries
215
+ * @returns {string} sparkline SVG markup (60x20)
216
+ */
217
+ function _kpiSparkline(tasks, filterFn) {
218
+ const history = extractHistory(tasks);
219
+ const days = lastDays(7);
220
+ const counts = days.map(day => {
221
+ return history.filter(e => {
222
+ if (e.at?.slice(0, 10) !== day) return false;
223
+ try { return filterFn({ status: e.to || e.status || '' }); } catch { return false; }
224
+ }).length;
225
+ });
226
+ if (counts.every(c => c === 0)) return '';
227
+ return sparkline(counts, { width: 60, height: 20, strokeWidth: 1.5 });
228
+ }
229
+
230
+ // ─────────────────────────────── ACTIVITY CHART ─────────────────────────────
231
+
232
+ function _renderActivityChart(tasks) {
233
+ const entries = extractHistory(tasks);
234
+ const days = lastDays(10);
235
+ const counts = new Map(days.map(d => [d, 0]));
236
+ entries.forEach(e => {
237
+ const day = e.at?.slice(0, 10);
238
+ if (counts.has(day)) counts.set(day, counts.get(day) + 1);
239
+ });
240
+ const max = Math.max(...counts.values(), 1);
241
+
242
+ const bars = Array.from(counts.entries()).map(([day, count]) => {
243
+ const pct = Math.max(8, Math.round((count / max) * 100));
244
+ const label = day.slice(5); // MM-DD
245
+ return `
246
+ <div class="activity-bar-col" title="${count} change${count !== 1 ? 's' : ''} on ${day}">
247
+ <div class="activity-bar" style="height:${pct}%" aria-label="${count} changes"></div>
248
+ <span class="activity-bar-label">${esc(label)}</span>
249
+ </div>
250
+ `;
251
+ }).join('');
252
+
253
+ return `<div class="activity-chart" aria-label="${t('ui.overview.activityBars', {}, 'Activity bars')}">${bars}</div>`;
254
+ }
255
+
256
+ // ─────────────────────────────── PHASE CHART ────────────────────────────────
257
+
258
+ function _renderPhaseChart(phaseStats) {
259
+ if (!phaseStats?.length) {
260
+ return `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noPhases', {}, 'No phases configured.')}</p>`;
261
+ }
262
+
263
+ return `
264
+ <div class="phase-chart">
265
+ ${phaseStats.map(p => {
266
+ const pct = p.total ? Math.round((p.completed / p.total) * 100) : 0;
267
+ const cls = pct === 100 ? 'done' : pct > 0 ? 'active' : 'partial';
268
+ return `
269
+ <div class="phase-row">
270
+ <div class="phase-row-header">
271
+ <span class="phase-name">${esc(p.id)} · ${_phaseLabel(p.id)}</span>
272
+ <span class="phase-progress">${p.completed}/${p.total} · ${pct}%</span>
273
+ </div>
274
+ <div class="phase-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${p.label}: ${pct}%">
275
+ <div class="phase-fill ${cls}" style="width:${pct}%"></div>
276
+ </div>
277
+ </div>
278
+ `;
279
+ }).join('')}
280
+ </div>
281
+ `;
282
+ }
283
+
284
+ // ─────────────────────────────── DONUT ──────────────────────────────────────
285
+
286
+ function _renderDonut(totals) {
287
+ const all = totals.all || 1;
288
+ const completed = totals.completed;
289
+ const inProgress = totals.inProgress;
290
+ const blocked = totals.blocked;
291
+ const pending = totals.pending;
292
+ const pct = Math.round((completed / all) * 100);
293
+
294
+ const R = 54;
295
+ const C = 2 * Math.PI * R;
296
+
297
+ function arc(val, total = all) {
298
+ return Math.min(C * (val / total), C);
299
+ }
300
+
301
+ const completedDash = arc(completed);
302
+ const inProgressDash = arc(inProgress);
303
+ const blockedDash = arc(blocked);
304
+
305
+ const o1 = 0;
306
+ const o2 = -(completedDash);
307
+ const o3 = -(completedDash + inProgressDash);
308
+
309
+ return `
310
+ <div class="donut-wrapper" style="position:relative;width:160px;height:160px">
311
+ <svg class="donut-svg" width="160" height="160" viewBox="0 0 160 160" role="img" aria-label="${t('ui.dashboard.progressLabel', { pct }, `Progress: ${pct}%`)}">
312
+ <defs>
313
+ <style>
314
+ .donut-arc { transform-origin: 80px 80px; transform: rotate(-90deg); }
315
+ </style>
316
+ </defs>
317
+ <circle class="donut-track" cx="80" cy="80" r="${R}" />
318
+ <circle class="donut-arc arc-completed"
319
+ cx="80" cy="80" r="${R}"
320
+ stroke-dasharray="${completedDash} ${C}"
321
+ stroke-dashoffset="${o1}" />
322
+ <circle class="donut-arc arc-progress"
323
+ cx="80" cy="80" r="${R}"
324
+ stroke-dasharray="${inProgressDash} ${C}"
325
+ stroke-dashoffset="${o2}" />
326
+ <circle class="donut-arc arc-blocked"
327
+ cx="80" cy="80" r="${R}"
328
+ stroke-dasharray="${blockedDash} ${C}"
329
+ stroke-dashoffset="${o3}" />
330
+ </svg>
331
+ <div class="donut-label">
332
+ <span class="donut-percent">${pct}%</span>
333
+ <span class="donut-sub">${t('ui.overview.completedLower', {}, 'completed')}</span>
334
+ </div>
335
+ </div>
336
+ <div class="donut-legend">
337
+ <div class="donut-legend-item">
338
+ <span class="donut-legend-dot" style="background:var(--success)"></span>
339
+ ${t('ui.overview.completed', {}, 'Completed')} (${completed})
340
+ </div>
341
+ <div class="donut-legend-item">
342
+ <span class="donut-legend-dot" style="background:var(--accent)"></span>
343
+ ${t('status.in_progress', {}, 'In progress')} (${inProgress})
344
+ </div>
345
+ <div class="donut-legend-item">
346
+ <span class="donut-legend-dot" style="background:var(--danger)"></span>
347
+ ${t('status.blocked', {}, 'Blocked')} (${blocked})
348
+ </div>
349
+ <div class="donut-legend-item">
350
+ <span class="donut-legend-dot" style="background:var(--text-muted)"></span>
351
+ ${t('status.pending', {}, 'Pending')} (${pending})
352
+ </div>
353
+ </div>
354
+ `;
355
+ }
356
+
357
+ // ─────────────────────────────── NEXT TASK ──────────────────────────────────
358
+
359
+ function _renderNextTask(task) {
360
+ const priorityColors = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
361
+ const statusLabels = state.getStatusLabels();
362
+ const statusLabel = statusLabels[task.status] || task.status;
363
+
364
+ const actionMap = {
365
+ pending: { action: 'start', label: t('ui.overview.actionStart', {}, 'Start'), toStatus: 'in_progress', variant: 'primary' },
366
+ in_progress: { action: 'review', label: t('ui.overview.actionReview', {}, 'Review'), toStatus: 'in_review', variant: 'accent' },
367
+ in_review: { action: 'complete', label: t('ui.overview.actionComplete', {}, 'Complete'), toStatus: 'completed', variant: 'success' },
368
+ blocked: { action: 'start', label: t('ui.overview.actionUnblock', {}, 'Unblock'), toStatus: 'in_progress', variant: 'warning' },
369
+ };
370
+ const taskAction = actionMap[task.status] || null;
371
+
372
+ return `
373
+ <div style="display:flex;flex-direction:column;gap:var(--space-2)">
374
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
375
+ <span class="badge badge-${priorityColors[task.priority] || 'muted'}">${esc(task.priority)}</span>
376
+ <span class="badge badge-muted">${_phaseLabel(task.phase)}</span>
377
+ <span class="badge status-${task.status}">${esc(statusLabel)}</span>
378
+ </div>
379
+ <p style="font-size:var(--text-sm);font-weight:700;color:var(--text-primary)">${esc(task.title)}</p>
380
+ ${task.summary ? `<p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(task.summary)}</p>` : ''}
381
+ <div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
382
+ ${taskAction ? `<button
383
+ class="btn btn-${taskAction.variant} btn-sm"
384
+ type="button"
385
+ data-task-action="${taskAction.action}"
386
+ data-task-id="${esc(task.id)}"
387
+ data-task-to-status="${taskAction.toStatus}"
388
+ aria-label="${taskAction.label}: ${esc(task.title)}"
389
+ >
390
+ ${icon('checkCircle', 14)} ${taskAction.label}
391
+ </button>` : ''}
392
+ <button
393
+ class="btn btn-ghost btn-sm"
394
+ type="button"
395
+ data-view="board"
396
+ aria-label="${t('ui.overview.openBoardFor', { title: task.title }, `Open board to manage ${task.title}`)}"
397
+ >
398
+ ${icon('arrowRight', 14)} ${t('ui.overview.viewBoard', {}, 'View in board')}
399
+ </button>
400
+ </div>
401
+ </div>
402
+ `;
403
+ }
404
+
405
+ // (Health grid removed from Overview — single source in Analytics tab)
406
+
407
+ // ─────────────────────────────── TASK ACTION EVENTS ─────────────────────────
408
+
409
+ function _bindTaskActionEvents() {
410
+ document.querySelectorAll('[data-task-action]').forEach(btn => {
411
+ btn.addEventListener('click', () => {
412
+ const taskId = btn.dataset.taskId;
413
+ const toStatus = btn.dataset.taskToStatus;
414
+ const action = btn.dataset.taskAction;
415
+ window.dispatchEvent(new CustomEvent('ops:task-action', {
416
+ detail: { taskId, toStatus, action },
417
+ }));
418
+ });
419
+ });
420
+ }
421
+
422
+ // ═══════════════════════════════════════════════════════════════════
423
+ // ANALYTICS TAB
424
+ // ═══════════════════════════════════════════════════════════════════
425
+
426
+ async function _renderAnalyticsTab(payload) {
427
+ const { derived, control, runtime } = payload;
428
+ const statusLabels = state.getStatusLabels();
429
+ const history = extractHistory(control.tasks).slice(0, 20);
430
+
431
+ // Fetch analytics for the current period (non-blocking on failure)
432
+ const periodValue = _activePeriod.replace('d', '');
433
+ try {
434
+ _analytics = await api.getAnalytics(periodValue);
435
+ } catch (err) {
436
+ console.warn('[dashboard/analytics] analytics fetch failed:', err);
437
+ _analytics = null;
438
+ }
439
+
440
+ return `
441
+ <div class="view-enter">
442
+
443
+ <!-- Period selector -->
444
+ <div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-4)">
445
+ ${_renderPeriodSelector()}
446
+ </div>
447
+
448
+ <!-- Row 1: Velocity | Burndown -->
449
+ <div class="grid-2" style="margin-bottom:var(--space-5)">
450
+
451
+ <div class="glass-card chart-card stagger-1">
452
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.insights.velocity', {}, 'Velocity')}</p>
453
+ <div id="dashboard-velocity-chart">
454
+ ${_renderVelocityChart()}
455
+ </div>
456
+ </div>
457
+
458
+ <div class="glass-card chart-card stagger-2">
459
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.insights.burndown', {}, 'Burndown')}</p>
460
+ <div id="dashboard-burndown-chart">
461
+ ${_renderBurndownChart()}
462
+ </div>
463
+ </div>
464
+
465
+ </div>
466
+
467
+ <!-- Row 2: Activity Heatmap (full width) -->
468
+ <div style="margin-bottom:var(--space-5)">
469
+ <div class="glass-card chart-card stagger-3">
470
+ <p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.insights.heatmap', {}, 'Activity')}</p>
471
+ <div id="dashboard-heatmap" style="overflow-x:auto">
472
+ ${_renderHeatmapChart()}
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <!-- Row 3: Status Distribution | Health Grid -->
478
+ <div class="grid-2" style="margin-bottom:var(--space-5)">
479
+
480
+ <div class="glass-card chart-card stagger-4">
481
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.distribution', {}, 'Distribution by status')}</p>
482
+ ${_renderDistribution(derived.totals, statusLabels)}
483
+ </div>
484
+
485
+ <div class="glass-card chart-card stagger-1">
486
+ <div class="section-header" style="margin-bottom:var(--space-3)">
487
+ <p class="chart-title">${t('ui.insights.health', {}, 'Operational health')}</p>
488
+ </div>
489
+ ${_renderAnalyticsHealthGrid(derived, runtime, payload.docsDirty)}
490
+ </div>
491
+
492
+ </div>
493
+
494
+ <!-- Row 4: Time Tracking (full width) — Phase progress is in Overview tab only -->
495
+ <div style="margin-bottom:var(--space-5)">
496
+ <div class="glass-card chart-card stagger-2">
497
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
498
+ <p class="chart-title">${t('ui.insights.time', {}, 'Time tracking')}</p>
499
+ <span class="badge badge-accent">${icon('clock', 12)} ${t('ui.insights.today', {}, 'Today')}</span>
500
+ </div>
501
+ ${_renderTimeTracking()}
502
+ </div>
503
+ </div>
504
+
505
+ <!-- Row 5: Activity Timeline | Findings + Decisions -->
506
+ <div class="grid-2">
507
+
508
+ <div class="glass-card chart-card stagger-4">
509
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.recentActivity', {}, 'Recent activity')}</p>
510
+ ${_renderActivityTimeline(history, statusLabels)}
511
+ </div>
512
+
513
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
514
+
515
+ <div class="glass-card chart-card stagger-1">
516
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
517
+ <p class="chart-title">${t('ui.insights.findings', {}, 'Findings')}</p>
518
+ <span class="badge badge-${derived.openFindings?.length ? 'warning' : 'success'}">
519
+ ${t('ui.insights.openCount', { count: (derived.openFindings || []).length }, `${(derived.openFindings || []).length} open`)}
520
+ </span>
521
+ </div>
522
+ ${_renderFindings(derived.openFindings || [], derived.resolvedFindings || [])}
523
+ </div>
524
+
525
+ ${(control.decisionsPending || []).length > 0 ? `
526
+ <div class="glass-card chart-card stagger-2">
527
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.pendingDecisions', {}, 'Pending decisions')}</p>
528
+ ${_renderDecisions(control.decisionsPending)}
529
+ </div>
530
+ ` : ''}
531
+
532
+ </div>
533
+ </div>
534
+ </div>
535
+ `;
536
+ }
537
+
538
+ // ─── Period selector ─────────────────────────────────────────────
539
+
540
+ function _renderPeriodSelector() {
541
+ const periods = [
542
+ { value: '7d', label: '7d' },
543
+ { value: '30d', label: '30d' },
544
+ { value: '90d', label: '90d' },
545
+ { value: 'all', label: t('ui.insights.periodAll', {}, 'All') },
546
+ ];
547
+
548
+ return `
549
+ <div class="tab-pills" role="group" aria-label="${t('ui.insights.periodLabel', {}, 'Period')}">
550
+ ${periods.map(p => `
551
+ <button class="tab-pill${p.value === _activePeriod ? ' is-active' : ''}" data-period="${p.value}">
552
+ ${p.label}
553
+ </button>
554
+ `).join('')}
555
+ </div>
556
+ `;
557
+ }
558
+
559
+ function _bindPeriodEvents() {
560
+ document.querySelectorAll('[data-period]').forEach(btn => {
561
+ btn.addEventListener('click', async () => {
562
+ const period = btn.dataset.period;
563
+ if (period === _activePeriod) return;
564
+ _activePeriod = period;
565
+
566
+ // Update active state on period pills
567
+ document.querySelectorAll('[data-period]').forEach(b => b.classList.remove('is-active'));
568
+ btn.classList.add('is-active');
569
+
570
+ // Re-fetch analytics and update chart sections
571
+ const periodValue = _activePeriod.replace('d', '');
572
+ try {
573
+ _analytics = await api.getAnalytics(periodValue);
574
+ } catch (err) {
575
+ console.warn('[dashboard/analytics] analytics fetch failed:', err);
576
+ _analytics = null;
577
+ }
578
+
579
+ _updateAnalyticsCharts();
580
+ });
581
+ });
582
+ }
583
+
584
+ // ─── Analytics chart renderers ───────────────────────────────────
585
+
586
+ function _renderVelocityChart() {
587
+ const velocity = _analytics?.velocity;
588
+ if (!Array.isArray(velocity) || velocity.length === 0) {
589
+ return `<div class="empty-state" style="padding:var(--space-6)">${t('ui.insights.noVelocity', {}, 'No velocity data available.')}</div>`;
590
+ }
591
+
592
+ const data = velocity.map(v => ({
593
+ label: v.label || v.week || '',
594
+ value: v.value ?? v.count ?? 0,
595
+ }));
596
+
597
+ return lineChart(data, {
598
+ width: 480,
599
+ height: 200,
600
+ color: 'var(--accent)',
601
+ fill: true,
602
+ showDots: true,
603
+ showGrid: true,
604
+ animate: true,
605
+ yLabel: t('ui.insights.velocityYLabel', {}, 'Tasks / week'),
606
+ });
607
+ }
608
+
609
+ function _renderBurndownChart() {
610
+ const burndown = _analytics?.burndown;
611
+ if (!Array.isArray(burndown) || burndown.length === 0) {
612
+ return `<div class="empty-state" style="padding:var(--space-6)">${t('ui.insights.noBurndown', {}, 'No burndown data available.')}</div>`;
613
+ }
614
+
615
+ const data = burndown.map(b => ({
616
+ label: b.label || b.date || '',
617
+ value: b.value ?? b.remaining ?? 0,
618
+ }));
619
+
620
+ return lineChart(data, {
621
+ width: 480,
622
+ height: 200,
623
+ color: 'var(--warning)',
624
+ fill: true,
625
+ showDots: true,
626
+ showGrid: true,
627
+ animate: true,
628
+ yLabel: t('ui.insights.burndownYLabel', {}, 'Remaining'),
629
+ });
630
+ }
631
+
632
+ function _renderHeatmapChart() {
633
+ const heatmapData = _analytics?.heatmap;
634
+ if (!Array.isArray(heatmapData) || heatmapData.length === 0) {
635
+ return `<div class="empty-state" style="padding:var(--space-6)">${t('ui.insights.noHeatmap', {}, 'No activity data available.')}</div>`;
636
+ }
637
+
638
+ const weeksMap = { '7d': 4, '30d': 12, '90d': 16, 'all': 26 };
639
+ const weeks = weeksMap[_activePeriod] || 12;
640
+
641
+ return heatmap(heatmapData, { weeks });
642
+ }
643
+
644
+ /**
645
+ * Re-render only the analytics chart sections after period change.
646
+ */
647
+ function _updateAnalyticsCharts() {
648
+ const velocityEl = document.getElementById('dashboard-velocity-chart');
649
+ const burndownEl = document.getElementById('dashboard-burndown-chart');
650
+ const heatmapEl = document.getElementById('dashboard-heatmap');
651
+
652
+ if (velocityEl) velocityEl.innerHTML = _renderVelocityChart();
653
+ if (burndownEl) burndownEl.innerHTML = _renderBurndownChart();
654
+ if (heatmapEl) heatmapEl.innerHTML = _renderHeatmapChart();
655
+ }
656
+
657
+ // ─────────────────────────────── HEALTH GRID (Analytics) ────────────────────
658
+
659
+ function _renderAnalyticsHealthGrid(derived, runtime, docsDirty) {
660
+ const totals = derived.totals;
661
+ const completionRate = totals.all ? Math.round((totals.completed / totals.all) * 100) : 0;
662
+ const blockerRate = totals.all ? Math.round((totals.blocked / totals.all) * 100) : 0;
663
+ const openFindings = (derived.openFindings || []).length;
664
+
665
+ const avgCycle = _analytics?.avgCycleTime;
666
+
667
+ const items = [
668
+ { label: t('ui.insights.completionRate', {}, 'Completion rate'), value: `${completionRate}%`, cls: completionRate >= 75 ? 'good' : completionRate >= 40 ? '' : 'bad' },
669
+ { label: t('ui.insights.blockerRate', {}, 'Blocker pressure'), value: `${blockerRate}%`, cls: blockerRate === 0 ? 'good' : blockerRate > 20 ? 'bad' : 'warn' },
670
+ { label: t('ui.insights.inProgress', {}, 'In progress'), value: String(totals.inProgress), cls: totals.inProgress > 0 ? 'good' : '' },
671
+ { label: t('ui.insights.inReview', {}, 'In review'), value: String(totals.inReview), cls: '' },
672
+ { label: t('ui.insights.openFindings', {}, 'Open findings'), value: String(openFindings), cls: openFindings === 0 ? 'good' : 'warn' },
673
+ { label: t('ui.insights.docsDrift', {}, 'Docs drift'), value: (docsDirty || []).length ? `${(docsDirty).length} files` : 'OK', cls: docsDirty?.length ? 'warn' : 'good' },
674
+ { label: t('ui.insights.ahead', {}, 'Ahead of remote'), value: String(runtime?.ahead || 0), cls: runtime?.ahead > 0 ? 'warn' : 'good' },
675
+ { label: t('ui.insights.behind', {}, 'Behind remote'), value: String(runtime?.behind || 0), cls: runtime?.behind > 0 ? 'bad' : 'good' },
676
+ ];
677
+
678
+ if (avgCycle != null) {
679
+ items.push({
680
+ label: t('ui.insights.avgCycleTime', {}, 'Avg cycle time'),
681
+ value: typeof avgCycle === 'number' ? `${avgCycle.toFixed(1)}d` : String(avgCycle),
682
+ cls: '',
683
+ });
684
+ }
685
+
686
+ return `
687
+ <div class="health-grid">
688
+ ${items.map(({ label, value, cls }) => `
689
+ <div class="health-card">
690
+ <p class="health-card-label">${esc(label)}</p>
691
+ <p class="health-card-value ${cls}" title="${esc(value)}">${esc(value)}</p>
692
+ </div>
693
+ `).join('')}
694
+ </div>
695
+ `;
696
+ }
697
+
698
+ // ─────────────────────────────── DISTRIBUTION ────────────────────────────────
699
+
700
+ function _renderDistribution(totals, statusLabels) {
701
+ const all = totals.all || 1;
702
+ const rows = [
703
+ { id: 'completed', label: statusLabels.completed || 'Completed', value: totals.completed, cls: 'fill-success' },
704
+ { id: 'in_progress', label: statusLabels.in_progress || 'In progress', value: totals.inProgress, cls: '' },
705
+ { id: 'in_review', label: statusLabels.in_review || 'In review', value: totals.inReview, cls: '' },
706
+ { id: 'pending', label: statusLabels.pending || 'Pending', value: totals.pending, cls: 'fill-warning' },
707
+ { id: 'blocked', label: statusLabels.blocked || 'Blocked', value: totals.blocked, cls: 'fill-danger' },
708
+ { id: 'cancelled', label: statusLabels.cancelled || 'Cancelled', value: totals.cancelled, cls: '' },
709
+ ].filter(r => r.value > 0);
710
+
711
+ return `
712
+ <div class="bar-chart">
713
+ ${rows.map(r => {
714
+ const pct = Math.max(2, Math.round((r.value / all) * 100));
715
+ return `
716
+ <div class="bar-row" role="group" aria-label="${r.label}: ${r.value} (${pct}%)">
717
+ <span class="bar-label truncate">${esc(r.label)}</span>
718
+ <div class="bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
719
+ <div class="bar-fill ${r.cls}" style="width:${pct}%"></div>
720
+ </div>
721
+ <span class="bar-value">${r.value}</span>
722
+ </div>
723
+ `;
724
+ }).join('')}
725
+ </div>
726
+ `;
727
+ }
728
+
729
+ // ─────────────────────────────── TIME TRACKING ───────────────────────────────
730
+
731
+ function _renderTimeTracking() {
732
+ const entries = state.get('timeEntries');
733
+
734
+ if (!entries.length) {
735
+ return `
736
+ <div class="empty-state" style="padding:var(--space-6)">
737
+ ${icon('clock', 24)}
738
+ <p>${t('ui.insights.noTimeEntries', {}, 'No time entries yet.')}</p>
739
+ <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.insights.noTimeEntriesHint', {}, 'Use time tracking in Overview to log time per task.')}</p>
740
+ </div>
741
+ `;
742
+ }
743
+
744
+ const byTask = new Map();
745
+ for (const e of entries) {
746
+ if (!byTask.has(e.taskId)) byTask.set(e.taskId, { taskId: e.taskId, taskTitle: e.taskTitle, total: 0, count: 0 });
747
+ const rec = byTask.get(e.taskId);
748
+ rec.total += e.durationMs || 0;
749
+ rec.count++;
750
+ }
751
+
752
+ const maxMs = Math.max(...[...byTask.values()].map(r => r.total), 1);
753
+
754
+ return `
755
+ <div class="bar-chart">
756
+ ${[...byTask.values()].slice(0, 10).map(r => {
757
+ const pct = Math.max(4, Math.round((r.total / maxMs) * 100));
758
+ return `
759
+ <div class="bar-row">
760
+ <span class="bar-label truncate" title="${esc(r.taskTitle)}">${esc(r.taskTitle)}</span>
761
+ <div class="bar-track">
762
+ <div class="bar-fill fill-info" style="width:${pct}%"></div>
763
+ </div>
764
+ <span class="bar-value">${formatDurationShort(r.total)}</span>
765
+ </div>
766
+ `;
767
+ }).join('')}
768
+ </div>
769
+ <p class="text-muted" style="font-size:var(--text-xs);margin-top:var(--space-3)">
770
+ ${t('ui.insights.totalEntries', { count: entries.length }, `${entries.length} entries total`)}
771
+ </p>
772
+ `;
773
+ }
774
+
775
+ // (Phase progress removed from Analytics — single source in Overview tab)
776
+
777
+ // ─────────────────────────────── ACTIVITY TIMELINE ───────────────────────────
778
+
779
+ function _renderActivityTimeline(history, statusLabels) {
780
+ if (!history.length) {
781
+ return `<div class="empty-state" style="padding:var(--space-5)">${t('ui.insights.noActivity', {}, 'No recent activity.')}</div>`;
782
+ }
783
+
784
+ const actionIcon = {
785
+ create: 'plus',
786
+ start: 'play',
787
+ review: 'alertCircle',
788
+ complete: 'checkCircle',
789
+ block: 'shield',
790
+ pending: 'clock',
791
+ cancel: 'x',
792
+ note: 'fileText',
793
+ edit: 'edit',
794
+ };
795
+
796
+ const actionColor = {
797
+ create: 'var(--accent)',
798
+ start: 'var(--info)',
799
+ review: 'var(--warning)',
800
+ complete: 'var(--success)',
801
+ block: 'var(--danger)',
802
+ cancel: 'var(--text-muted)',
803
+ note: 'var(--accent)',
804
+ edit: 'var(--text-secondary)',
805
+ pending: 'var(--warning)',
806
+ };
807
+
808
+ return `
809
+ <div class="stack" style="max-height:420px;overflow-y:auto">
810
+ ${history.map(h => {
811
+ const ic = actionIcon[h.action] || 'info';
812
+ const col = actionColor[h.action] || 'var(--accent)';
813
+ return `
814
+ <div class="activity-item">
815
+ <div class="activity-icon" style="color:${col}">
816
+ ${icon(ic, 16)}
817
+ </div>
818
+ <div class="activity-content">
819
+ <p class="activity-action">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
820
+ <p class="activity-task">${esc(h.taskTitle)} (${esc(h.taskId)})</p>
821
+ <p class="activity-time">${formatDate(h.at)}</p>
822
+ </div>
823
+ </div>
824
+ `;
825
+ }).join('')}
826
+ </div>
827
+ `;
828
+ }
829
+
830
+ // ─────────────────────────────── FINDINGS ────────────────────────────────────
831
+
832
+ function _renderFindings(open, resolved) {
833
+ if (!open.length && !resolved.length) {
834
+ return `<div class="empty-state" style="padding:var(--space-4)">${icon('checkCircle', 20)} ${t('ui.insights.noFindings', {}, 'No findings.')}</div>`;
835
+ }
836
+
837
+ return `
838
+ <div class="stack stack-sm">
839
+ ${open.map(f => `
840
+ <div class="finding-item severity-${(f.severity || 'medium').toLowerCase()}">
841
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-1)">
842
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(f.title)}</p>
843
+ <span class="badge badge-warning">${esc(f.severity?.toUpperCase() || 'MEDIUM')}</span>
844
+ </div>
845
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(f.detail || '')}</p>
846
+ ${f.impact ? `<p style="font-size:var(--text-xs);color:var(--text-muted);margin-top:var(--space-1)">${t('ui.insights.impact', {}, 'Impact')}: ${esc(f.impact)}</p>` : ''}
847
+ </div>
848
+ `).join('')}
849
+ ${resolved.length > 0 ? `<p class="label-sm" style="margin-top:var(--space-3)">${t('ui.insights.resolvedCount', { count: resolved.length }, `${resolved.length} resolved`)}</p>` : ''}
850
+ </div>
851
+ `;
852
+ }
853
+
854
+ // ─────────────────────────────── DECISIONS ───────────────────────────────────
855
+
856
+ function _renderDecisions(decisions) {
857
+ return `
858
+ <div class="stack stack-sm">
859
+ ${decisions.map(d => `
860
+ <div class="decision-item">
861
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-1)">
862
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(d.title)}</p>
863
+ <span class="badge badge-muted">${esc(d.owner)}</span>
864
+ </div>
865
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(d.impact || '')}</p>
866
+ </div>
867
+ `).join('')}
868
+ </div>
869
+ `;
870
+ }