trackops 2.0.3 → 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 (45) 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/locales/en/references/activation.md +15 -0
  12. package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
  13. package/skills/trackops/references/activation.md +15 -0
  14. package/skills/trackops/references/troubleshooting.md +12 -0
  15. package/skills/trackops/skill.json +2 -2
  16. package/ui/css/base.css +19 -1
  17. package/ui/css/charts.css +106 -8
  18. package/ui/css/components.css +554 -17
  19. package/ui/css/onboarding.css +133 -0
  20. package/ui/css/panels.css +345 -406
  21. package/ui/css/terminal.css +125 -0
  22. package/ui/css/timeline.css +58 -0
  23. package/ui/css/tokens.css +170 -113
  24. package/ui/index.html +3 -0
  25. package/ui/js/api.js +49 -13
  26. package/ui/js/app.js +28 -32
  27. package/ui/js/charts.js +526 -0
  28. package/ui/js/filters.js +247 -0
  29. package/ui/js/icons.js +82 -57
  30. package/ui/js/keyboard.js +229 -0
  31. package/ui/js/onboarding.js +33 -42
  32. package/ui/js/router.js +20 -3
  33. package/ui/js/views/board.js +84 -114
  34. package/ui/js/views/dashboard.js +870 -0
  35. package/ui/js/views/projects.js +745 -0
  36. package/ui/js/views/scrum.js +476 -0
  37. package/ui/js/views/settings.js +197 -247
  38. package/ui/js/views/sidebar.js +37 -31
  39. package/ui/js/views/tasks.js +218 -101
  40. package/ui/js/views/timeline.js +265 -0
  41. package/ui/js/views/topbar.js +94 -107
  42. package/ui/app.js +0 -950
  43. package/ui/js/views/insights.js +0 -340
  44. package/ui/js/views/overview.js +0 -369
  45. package/ui/styles.css +0 -688
package/ui/js/app.js CHANGED
@@ -10,17 +10,17 @@ import * as consoleLogger from './console-logger.js';
10
10
  import * as onboarding from './onboarding.js';
11
11
  import * as timeTracker from './time-tracker.js';
12
12
  import * as theme from './theme.js';
13
+ import * as keyboard from './keyboard.js';
13
14
 
14
15
  // Vistas
15
16
  import { render as renderSidebar } from './views/sidebar.js';
16
17
  import { render as renderTopbar } from './views/topbar.js';
17
- import { render as renderOverview } from './views/overview.js';
18
- import { render as renderBoard } from './views/board.js';
19
- import { render as renderTasks } from './views/tasks.js';
18
+ import * as dashboardView from './views/dashboard.js';
19
+ import * as tasksView from './views/tasks.js';
20
20
  import * as executionView from './views/execution.js';
21
- import { render as renderInsights } from './views/insights.js';
21
+ import * as projectsView from './views/projects.js';
22
+ import { render as renderTimeline } from './views/timeline.js';
22
23
  import * as settingsView from './views/settings.js';
23
- import * as skillsView from './views/skills.js';
24
24
 
25
25
  // ─────────────────────────────── INIT ───────────────────────────────────────
26
26
 
@@ -32,28 +32,32 @@ async function init() {
32
32
  consoleLogger.init();
33
33
 
34
34
  // 2. Registrar rutas en el router
35
- router.register('overview', renderOverview);
36
- router.register('board', renderBoard);
37
- router.register('tasks', renderTasks);
38
- router.register('execution', async () => {
35
+ router.register('dashboard', async () => {
36
+ const html = await dashboardView.render();
37
+ setTimeout(() => dashboardView.bindEvents?.(), 50);
38
+ return html;
39
+ });
40
+ router.register('tasks', async () => {
41
+ const html = await tasksView.render();
42
+ setTimeout(() => tasksView.bindEvents?.(), 50);
43
+ return html;
44
+ });
45
+ router.register('terminal', async () => {
39
46
  const html = await executionView.render();
40
47
  setTimeout(() => executionView.bindEvents(), 50);
41
48
  return html;
42
49
  });
43
- router.register('insights', renderInsights);
50
+ router.register('timeline', renderTimeline);
51
+ router.register('projects', async () => {
52
+ const html = await projectsView.render();
53
+ setTimeout(() => projectsView.bindEvents(), 50);
54
+ return html;
55
+ });
44
56
  router.register('settings', async () => {
45
57
  const html = await settingsView.render();
46
58
  setTimeout(() => settingsView.bindEvents(), 50);
47
59
  return html;
48
60
  });
49
- router.register('skills', async () => {
50
- const html = await skillsView.render();
51
- setTimeout(() => {
52
- skillsView.bindEvents();
53
- skillsView.loadData();
54
- }, 50);
55
- return html;
56
- });
57
61
 
58
62
  // 3. Inicializar el router
59
63
  router.init(document.getElementById('view-container'));
@@ -66,7 +70,7 @@ async function init() {
66
70
  renderTopbar();
67
71
 
68
72
  // 6. Navegar a la vista inicial
69
- await router.start('overview');
73
+ await router.start('dashboard');
70
74
 
71
75
  // 7. Cargar time entries en background
72
76
  timeTracker.loadEntries().catch(err => {
@@ -76,7 +80,10 @@ async function init() {
76
80
  // 8. Inicializar onboarding
77
81
  onboarding.init();
78
82
 
79
- // 9. Suscribir refreshes globales
83
+ // 9. Inicializar atajos de teclado
84
+ keyboard.init();
85
+
86
+ // 10. Suscribir refreshes globales
80
87
  _bindGlobalEvents();
81
88
 
82
89
  // 10. Auto-refresh cada 60s
@@ -161,22 +168,11 @@ function _bindGlobalEvents() {
161
168
  // Búsqueda global → refrescar la vista actual
162
169
  window.addEventListener('ops:search', () => {
163
170
  const active = router.current();
164
- if (active === 'board' || active === 'tasks') {
171
+ if (active === 'tasks') {
165
172
  router.refresh();
166
173
  }
167
174
  });
168
175
 
169
- // Navegación por teclado: Escape cierra modales / deselecciona
170
- document.addEventListener('keydown', e => {
171
- if (e.key === 'Escape') {
172
- // Deseleccionar tarea si no hay modal abierto
173
- const modalEl = document.querySelector('.modal-overlay:not(.is-hidden)');
174
- if (!modalEl) {
175
- // No deseleccionar: permite a las vistas manejar escape internamente
176
- }
177
- }
178
- });
179
-
180
176
  // Actualizar sidebar badges cuando cambia el payload
181
177
  state.subscribe('payload', () => {
182
178
  import('./views/sidebar.js').then(m => m.updateBadges?.());
@@ -0,0 +1,526 @@
1
+ /**
2
+ * charts.js — Motor de gráficos SVG para TrackOps Dashboard
3
+ * Vanilla JS, zero dependencies. Usa CSS custom properties de tokens.css.
4
+ */
5
+
6
+ // ─── Helpers ────────────────────────────────────────────────────
7
+
8
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
9
+
10
+ function scale(value, inMin, inMax, outMin, outMax) {
11
+ if (inMax === inMin) return (outMin + outMax) / 2;
12
+ return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
13
+ }
14
+
15
+ function esc(str) {
16
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
17
+ }
18
+
19
+ function niceTicksCount(range) {
20
+ if (range <= 0) return [0, 1, 2, 3, 4];
21
+ const rough = range / 4;
22
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
23
+ const res = rough / mag;
24
+ let step;
25
+ if (res <= 1.5) step = mag;
26
+ else if (res <= 3) step = 2 * mag;
27
+ else if (res <= 7) step = 5 * mag;
28
+ else step = 10 * mag;
29
+ return step;
30
+ }
31
+
32
+ function niceTicks(min, max, count = 5) {
33
+ if (min === max) {
34
+ const v = min;
35
+ return [v - 2, v - 1, v, v + 1, v + 2];
36
+ }
37
+ const step = niceTicksCount(max - min);
38
+ const lo = Math.floor(min / step) * step;
39
+ const hi = Math.ceil(max / step) * step;
40
+ const ticks = [];
41
+ for (let v = lo; v <= hi + step * 0.001; v += step) {
42
+ ticks.push(Math.round(v * 1e6) / 1e6);
43
+ }
44
+ // trim to reasonable count
45
+ while (ticks.length > count + 2) {
46
+ const newTicks = [];
47
+ for (let i = 0; i < ticks.length; i += 2) newTicks.push(ticks[i]);
48
+ ticks.length = 0;
49
+ ticks.push(...newTicks);
50
+ }
51
+ return ticks;
52
+ }
53
+
54
+ function fmtNum(n) {
55
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + 'M';
56
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k';
57
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
58
+ }
59
+
60
+ function uid() {
61
+ return 'to_' + Math.random().toString(36).slice(2, 9);
62
+ }
63
+
64
+ // ─── Topological sort ───────────────────────────────────────────
65
+
66
+ function topoSort(tasks) {
67
+ const map = new Map();
68
+ tasks.forEach(t => map.set(t.id, t));
69
+
70
+ const levels = new Map();
71
+ const visited = new Set();
72
+
73
+ function depth(id) {
74
+ if (levels.has(id)) return levels.get(id);
75
+ if (visited.has(id)) return 0; // cycle guard
76
+ visited.add(id);
77
+ const t = map.get(id);
78
+ if (!t || !t.dependsOn || t.dependsOn.length === 0) {
79
+ levels.set(id, 0);
80
+ return 0;
81
+ }
82
+ let d = 0;
83
+ for (const dep of t.dependsOn) {
84
+ if (map.has(dep)) d = Math.max(d, depth(dep) + 1);
85
+ }
86
+ levels.set(id, d);
87
+ return d;
88
+ }
89
+
90
+ tasks.forEach(t => depth(t.id));
91
+ return levels;
92
+ }
93
+
94
+ // ─── 1. sparkline ───────────────────────────────────────────────
95
+
96
+ export function sparkline(values, options = {}) {
97
+ if (!Array.isArray(values) || values.length < 2) return '';
98
+
99
+ const {
100
+ width = 80,
101
+ height = 24,
102
+ color: userColor,
103
+ strokeWidth = 2,
104
+ } = options;
105
+
106
+ const first = values[0];
107
+ const last = values[values.length - 1];
108
+ let color = userColor;
109
+ if (!color) {
110
+ if (last > first) color = 'var(--success)';
111
+ else if (last < first) color = 'var(--danger)';
112
+ else color = 'var(--accent)';
113
+ }
114
+
115
+ const min = Math.min(...values);
116
+ const max = Math.max(...values);
117
+ const padY = strokeWidth;
118
+
119
+ const points = values.map((v, i) => {
120
+ const x = scale(i, 0, values.length - 1, strokeWidth, width - strokeWidth);
121
+ const y = scale(v, min, max, height - padY, padY);
122
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
123
+ }).join(' ');
124
+
125
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Sparkline: ${first} to ${last}"><polyline fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" points="${points}"/></svg>`;
126
+ }
127
+
128
+ // ─── 2. lineChart ───────────────────────────────────────────────
129
+
130
+ export function lineChart(data, options = {}) {
131
+ if (!Array.isArray(data) || data.length === 0) return '';
132
+
133
+ const {
134
+ width = 480,
135
+ height = 200,
136
+ color = 'var(--accent)',
137
+ fill = true,
138
+ showDots = true,
139
+ showGrid = true,
140
+ animate = true,
141
+ yLabel = '',
142
+ } = options;
143
+
144
+ const values = data.map(d => d.value);
145
+ const labels = data.map(d => d.label);
146
+ const rawMin = Math.min(...values);
147
+ const rawMax = Math.max(...values);
148
+ const ticks = niceTicks(rawMin, rawMax, 5);
149
+ const yMin = ticks[0];
150
+ const yMax = ticks[ticks.length - 1];
151
+
152
+ // Layout constants
153
+ const padLeft = 48;
154
+ const padRight = 12;
155
+ const padTop = 12;
156
+ const padBottom = 36;
157
+ const plotW = width - padLeft - padRight;
158
+ const plotH = height - padTop - padBottom;
159
+
160
+ const gradId = uid();
161
+ const animId = uid();
162
+
163
+ // Map data to plot coords
164
+ const pts = data.map((d, i) => {
165
+ const x = padLeft + scale(i, 0, Math.max(data.length - 1, 1), 0, plotW);
166
+ const y = padTop + scale(d.value, yMin, yMax, plotH, 0);
167
+ return { x, y, v: d.value, l: d.label };
168
+ });
169
+
170
+ const polyPoints = pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
171
+ const areaPath = pts.length
172
+ ? `M${pts[0].x.toFixed(1)},${(padTop + plotH).toFixed(1)} ` +
173
+ pts.map(p => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') +
174
+ ` L${pts[pts.length - 1].x.toFixed(1)},${(padTop + plotH).toFixed(1)} Z`
175
+ : '';
176
+
177
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="Line chart${yLabel ? ': ' + esc(yLabel) : ''} — ${data.length} points, min ${fmtNum(rawMin)}, max ${fmtNum(rawMax)}">`;
178
+
179
+ // Defs: gradient for area fill + optional clip animation
180
+ svg += `<defs>`;
181
+ if (fill) {
182
+ svg += `<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">` +
183
+ `<stop offset="0%" stop-color="${color}" stop-opacity="0.35"/>` +
184
+ `<stop offset="100%" stop-color="${color}" stop-opacity="0"/>` +
185
+ `</linearGradient>`;
186
+ }
187
+ if (animate) {
188
+ svg += `<clipPath id="${animId}"><rect x="${padLeft}" y="0" width="0" height="${height}">` +
189
+ `<animate attributeName="width" from="0" to="${plotW + padRight}" dur="0.8s" fill="freeze" calcMode="spline" keySplines="0.16 1 0.3 1"/></rect></clipPath>`;
190
+ }
191
+ svg += `</defs>`;
192
+
193
+ const clipAttr = animate ? ` clip-path="url(#${animId})"` : '';
194
+
195
+ // Grid lines
196
+ if (showGrid) {
197
+ for (const t of ticks) {
198
+ const gy = padTop + scale(t, yMin, yMax, plotH, 0);
199
+ svg += `<line class="chart-grid-line" x1="${padLeft}" y1="${gy.toFixed(1)}" x2="${width - padRight}" y2="${gy.toFixed(1)}" stroke="var(--border)" stroke-dasharray="4 3" stroke-width="1"/>`;
200
+ }
201
+ }
202
+
203
+ // Y-axis labels
204
+ for (const t of ticks) {
205
+ const gy = padTop + scale(t, yMin, yMax, plotH, 0);
206
+ svg += `<text class="chart-axis-label" x="${padLeft - 6}" y="${(gy + 3).toFixed(1)}" text-anchor="end" fill="var(--text-muted)" font-size="10" font-family="var(--font-ui)">${fmtNum(t)}</text>`;
207
+ }
208
+
209
+ // X-axis labels
210
+ const maxLabels = Math.floor(plotW / 40);
211
+ const step = Math.max(1, Math.ceil(data.length / maxLabels));
212
+ const rotateLabels = data.length > 10;
213
+ for (let i = 0; i < data.length; i += step) {
214
+ const lx = pts[i].x;
215
+ const ly = height - padBottom + 16;
216
+ if (rotateLabels) {
217
+ svg += `<text class="chart-axis-label" x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="end" fill="var(--text-muted)" font-size="10" font-family="var(--font-ui)" transform="rotate(-45 ${lx.toFixed(1)} ${ly.toFixed(1)})">${esc(labels[i])}</text>`;
218
+ } else {
219
+ svg += `<text class="chart-axis-label" x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" fill="var(--text-muted)" font-size="10" font-family="var(--font-ui)">${esc(labels[i])}</text>`;
220
+ }
221
+ }
222
+
223
+ // Area fill
224
+ if (fill && areaPath) {
225
+ svg += `<path class="chart-area" d="${areaPath}" fill="url(#${gradId})"${clipAttr}/>`;
226
+ }
227
+
228
+ // Line
229
+ svg += `<polyline class="chart-line" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${polyPoints}"${clipAttr}/>`;
230
+
231
+ // Dots
232
+ if (showDots) {
233
+ for (const p of pts) {
234
+ svg += `<circle class="chart-dot" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" stroke="var(--surface-2)" stroke-width="1.5"${clipAttr}><title>${esc(p.l)}: ${fmtNum(p.v)}</title></circle>`;
235
+ }
236
+ }
237
+
238
+ // Y-label
239
+ if (yLabel) {
240
+ svg += `<text class="chart-axis-label" x="4" y="${(padTop + plotH / 2).toFixed(1)}" transform="rotate(-90 4 ${(padTop + plotH / 2).toFixed(1)})" text-anchor="middle" fill="var(--text-secondary)" font-size="10" font-family="var(--font-ui)">${esc(yLabel)}</text>`;
241
+ }
242
+
243
+ svg += `</svg>`;
244
+ return svg;
245
+ }
246
+
247
+ // ─── 3. barChart ────────────────────────────────────────────────
248
+
249
+ export function barChart(data, options = {}) {
250
+ if (!Array.isArray(data) || data.length === 0) return '';
251
+
252
+ const {
253
+ maxWidth = 480,
254
+ barHeight = 28,
255
+ gap = 8,
256
+ showValues = true,
257
+ animate = true,
258
+ } = options;
259
+
260
+ const maxVal = Math.max(...data.map(d => d.value), 1);
261
+
262
+ const rows = data.map((d, i) => {
263
+ const pct = clamp((d.value / maxVal) * 100, 0, 100);
264
+ const barColor = d.color || 'var(--accent)';
265
+ const transition = animate ? `transition: width 0.6s var(--ease-out) ${i * 0.06}s;` : '';
266
+
267
+ return `<div class="bar-row" style="display:flex;align-items:center;gap:8px;height:${barHeight}px;margin-bottom:${gap}px;">` +
268
+ `<span class="bar-label" style="min-width:80px;font-size:var(--text-sm);color:var(--text-secondary);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-ui);" title="${esc(d.label)}">${esc(d.label)}</span>` +
269
+ `<div class="bar-track" role="progressbar" aria-valuenow="${d.value}" aria-valuemin="0" aria-valuemax="${maxVal}" aria-label="${esc(d.label)}: ${d.value}" style="flex:1;height:100%;background:var(--surface-4);border-radius:var(--radius-xs);overflow:hidden;position:relative;">` +
270
+ `<div class="bar-fill" style="width:${pct.toFixed(1)}%;height:100%;background:${barColor};border-radius:var(--radius-xs);${transition}"></div>` +
271
+ `</div>` +
272
+ (showValues
273
+ ? `<span class="bar-value" style="min-width:40px;font-size:var(--text-sm);color:var(--text-primary);font-variant-numeric:tabular-nums;font-family:var(--font-mono);text-align:right;">${fmtNum(d.value)}</span>`
274
+ : '') +
275
+ `</div>`;
276
+ }).join('');
277
+
278
+ return `<div class="bar-chart" style="max-width:${maxWidth}px;width:100%;" role="img" aria-label="Bar chart — ${data.length} items">${rows}</div>`;
279
+ }
280
+
281
+ // ─── 4. donutChart ──────────────────────────────────────────────
282
+
283
+ export function donutChart(segments, options = {}) {
284
+ if (!Array.isArray(segments) || segments.length === 0) return '';
285
+
286
+ const {
287
+ size = 160,
288
+ strokeWidth = 14,
289
+ centerLabel = '',
290
+ centerSub = '',
291
+ animate = true,
292
+ } = options;
293
+
294
+ const total = segments.reduce((s, d) => s + d.value, 0);
295
+ if (total <= 0) return '';
296
+
297
+ const r = (size - strokeWidth) / 2;
298
+ const cx = size / 2;
299
+ const cy = size / 2;
300
+ const circumference = 2 * Math.PI * r;
301
+
302
+ // Build arcs
303
+ let offsetSoFar = 0;
304
+ const arcs = segments.map((seg, i) => {
305
+ const frac = seg.value / total;
306
+ const dash = frac * circumference;
307
+ const gap = circumference - dash;
308
+ const offset = -offsetSoFar + circumference * 0.25; // start at top
309
+
310
+ const animAttr = animate
311
+ ? `<animate attributeName="stroke-dashoffset" from="${circumference + circumference * 0.25}" to="${offset.toFixed(2)}" dur="0.7s" fill="freeze" calcMode="spline" keySplines="0.16 1 0.3 1" begin="${(i * 0.08).toFixed(2)}s"/>`
312
+ : '';
313
+
314
+ const arc = `<circle class="donut-arc" cx="${cx}" cy="${cy}" r="${r.toFixed(1)}" fill="none" stroke="${seg.color || 'var(--accent)'}" stroke-width="${strokeWidth}" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" stroke-linecap="round"><title>${esc(seg.label)}: ${seg.value} (${(frac * 100).toFixed(1)}%)</title>${animAttr}</circle>`;
315
+
316
+ offsetSoFar += dash;
317
+ return arc;
318
+ });
319
+
320
+ let svg = `<svg class="donut-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="Donut chart — ${segments.map(s => esc(s.label) + ': ' + s.value).join(', ')}">`;
321
+
322
+ // Track background
323
+ svg += `<circle class="donut-track" cx="${cx}" cy="${cy}" r="${r.toFixed(1)}" fill="none" stroke="var(--surface-4)" stroke-width="${strokeWidth}"/>`;
324
+
325
+ // Arcs
326
+ svg += arcs.join('');
327
+
328
+ // Center label
329
+ if (centerLabel) {
330
+ svg += `<text class="donut-label" x="${cx}" y="${centerSub ? cy - 2 : cy}" text-anchor="middle" dominant-baseline="central" fill="var(--text-primary)" font-size="22" font-weight="700" font-family="var(--font-heading)">${esc(centerLabel)}</text>`;
331
+ }
332
+ if (centerSub) {
333
+ svg += `<text class="donut-label" x="${cx}" y="${cy + 16}" text-anchor="middle" dominant-baseline="central" fill="var(--text-muted)" font-size="10" font-family="var(--font-ui)">${esc(centerSub)}</text>`;
334
+ }
335
+
336
+ svg += `</svg>`;
337
+
338
+ // Legend
339
+ const legend = segments.map(seg => {
340
+ const pct = ((seg.value / total) * 100).toFixed(0);
341
+ return `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;font-size:var(--text-xs);color:var(--text-secondary);font-family:var(--font-ui);">` +
342
+ `<span style="width:8px;height:8px;border-radius:50%;background:${seg.color || 'var(--accent)'};flex-shrink:0;"></span>` +
343
+ `${esc(seg.label)} ${pct}%</span>`;
344
+ }).join('');
345
+
346
+ return `<div style="display:inline-flex;flex-direction:column;align-items:center;gap:8px;">${svg}<div style="display:flex;flex-wrap:wrap;justify-content:center;">${legend}</div></div>`;
347
+ }
348
+
349
+ // ─── 5. heatmap ─────────────────────────────────────────────────
350
+
351
+ export function heatmap(data, options = {}) {
352
+ if (!Array.isArray(data)) return '';
353
+
354
+ const {
355
+ weeks = 12,
356
+ cellSize = 14,
357
+ gap = 3,
358
+ colors = null,
359
+ } = options;
360
+
361
+ const intensityColors = colors || [
362
+ 'transparent',
363
+ 'rgba(99,102,241,0.15)',
364
+ 'rgba(99,102,241,0.30)',
365
+ 'rgba(99,102,241,0.55)',
366
+ 'rgba(99,102,241,0.85)',
367
+ ];
368
+
369
+ // Build lookup
370
+ const lookup = new Map();
371
+ let maxCount = 0;
372
+ data.forEach(d => {
373
+ lookup.set(d.date, d.count);
374
+ if (d.count > maxCount) maxCount = d.count;
375
+ });
376
+
377
+ function intensity(count) {
378
+ if (!count || count <= 0) return 0;
379
+ if (maxCount === 0) return 0;
380
+ const ratio = count / maxCount;
381
+ if (ratio <= 0.25) return 1;
382
+ if (ratio <= 0.50) return 2;
383
+ if (ratio <= 0.75) return 3;
384
+ return 4;
385
+ }
386
+
387
+ // Build grid: work backwards from today
388
+ const today = new Date();
389
+ // Find the most recent Sunday (start-of-week for grid alignment)
390
+ const endDate = new Date(today);
391
+ endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); // end on Saturday
392
+
393
+ const startDate = new Date(endDate);
394
+ startDate.setDate(startDate.getDate() - (weeks * 7 - 1));
395
+ // Align to Monday
396
+ startDate.setDate(startDate.getDate() - ((startDate.getDay() + 6) % 7));
397
+
398
+ const dayLabels = ['L', '', 'X', '', 'V', '', 'D'];
399
+ const labelW = 18;
400
+ const svgW = labelW + weeks * (cellSize + gap);
401
+ const svgH = 7 * (cellSize + gap);
402
+
403
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" width="${svgW}" height="${svgH}" role="img" aria-label="Activity heatmap — ${weeks} weeks">`;
404
+
405
+ // Day labels
406
+ for (let row = 0; row < 7; row++) {
407
+ if (dayLabels[row]) {
408
+ svg += `<text x="0" y="${row * (cellSize + gap) + cellSize - 2}" fill="var(--text-muted)" font-size="9" font-family="var(--font-ui)">${dayLabels[row]}</text>`;
409
+ }
410
+ }
411
+
412
+ // Cells
413
+ const cursor = new Date(startDate);
414
+ for (let w = 0; w < weeks; w++) {
415
+ for (let d = 0; d < 7; d++) {
416
+ const dateStr = cursor.toISOString().slice(0, 10);
417
+ const count = lookup.get(dateStr) || 0;
418
+ const level = intensity(count);
419
+ const x = labelW + w * (cellSize + gap);
420
+ const y = d * (cellSize + gap);
421
+ const fillColor = intensityColors[level];
422
+ const border = level === 0 ? ' stroke="var(--border)" stroke-width="1"' : '';
423
+ svg += `<rect class="heatmap-cell" x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="2" fill="${fillColor}"${border}><title>${dateStr}: ${count}</title></rect>`;
424
+ cursor.setDate(cursor.getDate() + 1);
425
+ }
426
+ }
427
+
428
+ svg += `</svg>`;
429
+ return svg;
430
+ }
431
+
432
+ // ─── 6. dependencyGraph ─────────────────────────────────────────
433
+
434
+ export function dependencyGraph(tasks, options = {}) {
435
+ if (!Array.isArray(tasks) || tasks.length === 0) return '';
436
+
437
+ // Only render if at least one task has dependencies
438
+ const hasDeps = tasks.some(t => t.dependsOn && t.dependsOn.length > 0);
439
+ if (!hasDeps) return '';
440
+
441
+ const {
442
+ nodeWidth = 140,
443
+ nodeHeight = 36,
444
+ levelGap = 60,
445
+ rowGap = 16,
446
+ } = options;
447
+
448
+ const statusColors = {
449
+ completed: 'var(--success)',
450
+ in_progress: 'var(--accent)',
451
+ blocked: 'var(--danger)',
452
+ pending: 'var(--text-muted)',
453
+ };
454
+
455
+ // Topological levels
456
+ const levels = topoSort(tasks);
457
+ const taskMap = new Map();
458
+ tasks.forEach(t => taskMap.set(t.id, t));
459
+
460
+ // Group tasks by level
461
+ const byLevel = new Map();
462
+ tasks.forEach(t => {
463
+ const lv = levels.get(t.id) || 0;
464
+ if (!byLevel.has(lv)) byLevel.set(lv, []);
465
+ byLevel.get(lv).push(t);
466
+ });
467
+
468
+ const maxLevel = Math.max(...byLevel.keys(), 0);
469
+ const maxRowCount = Math.max(...[...byLevel.values()].map(a => a.length), 1);
470
+
471
+ const colWidth = nodeWidth + levelGap;
472
+ const rowHeight = nodeHeight + rowGap;
473
+ const svgW = (maxLevel + 1) * colWidth + levelGap;
474
+ const svgH = maxRowCount * rowHeight + rowGap;
475
+
476
+ // Compute positions
477
+ const positions = new Map();
478
+ for (let lv = 0; lv <= maxLevel; lv++) {
479
+ const group = byLevel.get(lv) || [];
480
+ const totalH = group.length * rowHeight;
481
+ const startY = (svgH - totalH) / 2;
482
+ group.forEach((t, idx) => {
483
+ positions.set(t.id, {
484
+ x: levelGap / 2 + lv * colWidth,
485
+ y: startY + idx * rowHeight,
486
+ });
487
+ });
488
+ }
489
+
490
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" width="${svgW}" height="${svgH}" role="img" aria-label="Dependency graph — ${tasks.length} tasks">`;
491
+
492
+ // Draw edges first (behind nodes)
493
+ tasks.forEach(t => {
494
+ if (!t.dependsOn) return;
495
+ const target = positions.get(t.id);
496
+ if (!target) return;
497
+ t.dependsOn.forEach(depId => {
498
+ const source = positions.get(depId);
499
+ if (!source) return;
500
+ const x1 = source.x + nodeWidth;
501
+ const y1 = source.y + nodeHeight / 2;
502
+ const x2 = target.x;
503
+ const y2 = target.y + nodeHeight / 2;
504
+ const cpx = (x1 + x2) / 2;
505
+ svg += `<path d="M${x1},${y1} Q${cpx},${y1} ${cpx},${(y1 + y2) / 2} Q${cpx},${y2} ${x2},${y2}" fill="none" stroke="var(--border-strong)" stroke-width="1.5" stroke-linecap="round"/>`;
506
+ // Arrow
507
+ svg += `<polygon points="${x2},${y2} ${x2 - 5},${y2 - 3} ${x2 - 5},${y2 + 3}" fill="var(--border-strong)"/>`;
508
+ });
509
+ });
510
+
511
+ // Draw nodes
512
+ tasks.forEach(t => {
513
+ const pos = positions.get(t.id);
514
+ if (!pos) return;
515
+ const col = statusColors[t.status] || 'var(--text-muted)';
516
+ const truncTitle = t.title.length > 16 ? t.title.slice(0, 15) + '\u2026' : t.title;
517
+
518
+ svg += `<rect x="${pos.x}" y="${pos.y}" width="${nodeWidth}" height="${nodeHeight}" rx="6" fill="var(--surface-2)" stroke="${col}" stroke-width="1.5"/>`;
519
+ svg += `<text x="${pos.x + nodeWidth / 2}" y="${pos.y + nodeHeight / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="var(--text-primary)" font-size="11" font-family="var(--font-ui)"><title>${esc(t.title)}</title>${esc(truncTitle)}</text>`;
520
+ // Status dot
521
+ svg += `<circle cx="${pos.x + 10}" cy="${pos.y + nodeHeight / 2}" r="3" fill="${col}"/>`;
522
+ });
523
+
524
+ svg += `</svg>`;
525
+ return svg;
526
+ }