trackops 2.0.4 → 2.0.5

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 (90) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -640
  3. package/bin/trackops.js +116 -116
  4. package/lib/config.js +326 -326
  5. package/lib/control.js +208 -208
  6. package/lib/env.js +244 -244
  7. package/lib/init.js +325 -325
  8. package/lib/locale.js +41 -41
  9. package/lib/opera-bootstrap.js +942 -936
  10. package/lib/opera.js +495 -486
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -214
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/skills.js +74 -57
  16. package/lib/workspace.js +260 -260
  17. package/locales/en.json +192 -170
  18. package/locales/es.json +192 -170
  19. package/package.json +61 -58
  20. package/scripts/postinstall-locale.js +21 -21
  21. package/scripts/skills-marketplace-smoke.js +124 -124
  22. package/scripts/smoke-tests.js +558 -554
  23. package/scripts/sync-skill-version.js +21 -21
  24. package/scripts/validate-skill.js +103 -103
  25. package/skills/trackops/SKILL.md +126 -122
  26. package/skills/trackops/agents/openai.yaml +7 -7
  27. package/skills/trackops/locales/en/SKILL.md +126 -122
  28. package/skills/trackops/locales/en/references/activation.md +94 -90
  29. package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
  30. package/skills/trackops/locales/en/references/workflow.md +55 -32
  31. package/skills/trackops/references/activation.md +94 -90
  32. package/skills/trackops/references/troubleshooting.md +73 -67
  33. package/skills/trackops/references/workflow.md +55 -32
  34. package/skills/trackops/skill.json +29 -29
  35. package/templates/hooks/post-checkout +2 -2
  36. package/templates/hooks/post-commit +2 -2
  37. package/templates/hooks/post-merge +2 -2
  38. package/templates/opera/agent.md +28 -27
  39. package/templates/opera/architecture/dependency-graph.md +24 -24
  40. package/templates/opera/architecture/runtime-automation.md +24 -24
  41. package/templates/opera/architecture/runtime-operations.md +34 -34
  42. package/templates/opera/en/agent.md +22 -21
  43. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  44. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  45. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  46. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  47. package/templates/opera/en/reviews/integration-audit.md +18 -18
  48. package/templates/opera/en/router.md +24 -19
  49. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  50. package/templates/opera/references/opera-cycle.md +193 -193
  51. package/templates/opera/registry.md +28 -28
  52. package/templates/opera/reviews/delivery-audit.md +18 -18
  53. package/templates/opera/reviews/integration-audit.md +18 -18
  54. package/templates/opera/router.md +54 -49
  55. package/templates/skills/changelog-updater/SKILL.md +69 -69
  56. package/templates/skills/commiter/SKILL.md +99 -99
  57. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  58. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  59. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  60. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  61. package/templates/skills/opera-skill/SKILL.md +279 -0
  62. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  64. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  65. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  66. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  67. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  68. package/ui/css/base.css +284 -284
  69. package/ui/css/charts.css +425 -425
  70. package/ui/css/components.css +1107 -1107
  71. package/ui/css/onboarding.css +133 -133
  72. package/ui/css/terminal.css +125 -125
  73. package/ui/css/timeline.css +58 -58
  74. package/ui/css/tokens.css +284 -284
  75. package/ui/favicon.svg +5 -5
  76. package/ui/index.html +99 -99
  77. package/ui/js/charts.js +526 -526
  78. package/ui/js/console-logger.js +172 -172
  79. package/ui/js/filters.js +247 -247
  80. package/ui/js/icons.js +129 -129
  81. package/ui/js/keyboard.js +229 -229
  82. package/ui/js/router.js +142 -142
  83. package/ui/js/theme.js +100 -100
  84. package/ui/js/time-tracker.js +248 -248
  85. package/ui/js/views/dashboard.js +870 -870
  86. package/ui/js/views/flash.js +47 -47
  87. package/ui/js/views/projects.js +745 -745
  88. package/ui/js/views/scrum.js +476 -476
  89. package/ui/js/views/settings.js +331 -331
  90. package/ui/js/views/timeline.js +265 -265
package/ui/js/charts.js CHANGED
@@ -1,526 +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
- }
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
+ }