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.
- package/README.md +238 -0
- package/lib/init.js +2 -2
- package/lib/locale.js +41 -17
- package/lib/opera-bootstrap.js +68 -7
- package/lib/opera.js +10 -2
- package/lib/registry.js +18 -0
- package/lib/server.js +312 -207
- package/locales/en.json +4 -0
- package/locales/es.json +4 -0
- package/package.json +1 -1
- package/skills/trackops/locales/en/references/activation.md +15 -0
- package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
- package/skills/trackops/references/activation.md +15 -0
- package/skills/trackops/references/troubleshooting.md +12 -0
- package/skills/trackops/skill.json +2 -2
- package/ui/css/base.css +19 -1
- package/ui/css/charts.css +106 -8
- package/ui/css/components.css +554 -17
- 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 +170 -113
- package/ui/index.html +3 -0
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +82 -57
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +20 -3
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +197 -247
- 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/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
|
|
18
|
-
import
|
|
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
|
|
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('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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('
|
|
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('
|
|
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.
|
|
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 === '
|
|
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?.());
|
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
|
+
}
|