trackops 2.0.3 → 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.
- package/LICENSE +21 -21
- package/README.md +695 -402
- package/bin/trackops.js +116 -116
- package/lib/config.js +326 -326
- package/lib/control.js +208 -208
- package/lib/env.js +244 -244
- package/lib/init.js +325 -325
- package/lib/locale.js +24 -0
- package/lib/opera-bootstrap.js +941 -874
- package/lib/opera.js +494 -477
- package/lib/preferences.js +74 -74
- package/lib/registry.js +214 -196
- package/lib/release.js +56 -56
- package/lib/runtime-state.js +144 -144
- package/lib/server.js +312 -207
- package/lib/skills.js +74 -57
- package/lib/workspace.js +260 -260
- package/locales/en.json +192 -166
- package/locales/es.json +192 -166
- package/package.json +61 -58
- package/scripts/postinstall-locale.js +21 -21
- package/scripts/skills-marketplace-smoke.js +124 -124
- package/scripts/smoke-tests.js +558 -554
- package/scripts/sync-skill-version.js +21 -21
- package/scripts/validate-skill.js +103 -103
- package/skills/trackops/SKILL.md +126 -122
- package/skills/trackops/agents/openai.yaml +7 -7
- package/skills/trackops/locales/en/SKILL.md +126 -122
- package/skills/trackops/locales/en/references/activation.md +94 -75
- package/skills/trackops/locales/en/references/troubleshooting.md +73 -55
- package/skills/trackops/locales/en/references/workflow.md +55 -32
- package/skills/trackops/references/activation.md +94 -75
- package/skills/trackops/references/troubleshooting.md +73 -55
- package/skills/trackops/references/workflow.md +55 -32
- package/skills/trackops/skill.json +29 -29
- package/templates/hooks/post-checkout +2 -2
- package/templates/hooks/post-commit +2 -2
- package/templates/hooks/post-merge +2 -2
- package/templates/opera/agent.md +28 -27
- package/templates/opera/architecture/dependency-graph.md +24 -24
- package/templates/opera/architecture/runtime-automation.md +24 -24
- package/templates/opera/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/agent.md +22 -21
- package/templates/opera/en/architecture/dependency-graph.md +24 -24
- package/templates/opera/en/architecture/runtime-automation.md +24 -24
- package/templates/opera/en/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/reviews/delivery-audit.md +18 -18
- package/templates/opera/en/reviews/integration-audit.md +18 -18
- package/templates/opera/en/router.md +24 -19
- package/templates/opera/references/autonomy-and-recovery.md +117 -117
- package/templates/opera/references/opera-cycle.md +193 -193
- package/templates/opera/registry.md +28 -28
- package/templates/opera/reviews/delivery-audit.md +18 -18
- package/templates/opera/reviews/integration-audit.md +18 -18
- package/templates/opera/router.md +54 -49
- package/templates/skills/changelog-updater/SKILL.md +69 -69
- package/templates/skills/commiter/SKILL.md +99 -99
- package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
- package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
- package/templates/skills/opera-policy-guard/SKILL.md +26 -26
- package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
- package/templates/skills/opera-skill/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
- package/templates/skills/opera-skill/references/phase-dod.md +138 -0
- package/templates/skills/project-starter-skill/SKILL.md +150 -131
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
- package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
- package/ui/css/base.css +284 -266
- package/ui/css/charts.css +425 -327
- package/ui/css/components.css +1107 -570
- package/ui/css/onboarding.css +133 -0
- package/ui/css/panels.css +345 -406
- package/ui/css/terminal.css +125 -0
- package/ui/css/timeline.css +58 -0
- package/ui/css/tokens.css +284 -227
- package/ui/favicon.svg +5 -5
- package/ui/index.html +99 -96
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/console-logger.js +172 -172
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +129 -104
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +142 -125
- package/ui/js/theme.js +100 -100
- package/ui/js/time-tracker.js +248 -248
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/flash.js +47 -47
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +153 -203
- package/ui/js/views/sidebar.js +37 -31
- package/ui/js/views/tasks.js +218 -101
- package/ui/js/views/timeline.js +265 -0
- package/ui/js/views/topbar.js +94 -107
- package/ui/app.js +0 -950
- package/ui/js/views/insights.js +0 -340
- package/ui/js/views/overview.js +0 -369
- package/ui/styles.css +0 -688
package/ui/js/charts.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|