trackops 1.0.1 → 2.0.0
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 +292 -272
- package/bin/trackops.js +108 -50
- package/lib/config.js +267 -38
- package/lib/control.js +534 -480
- package/lib/env.js +244 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +170 -47
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +1075 -0
- package/lib/opera.js +524 -125
- package/lib/preferences.js +74 -0
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/runtime-state.js +144 -0
- package/lib/server.js +1004 -521
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +418 -132
- package/locales/es.json +418 -132
- package/package.json +8 -9
- package/scripts/postinstall-locale.js +21 -0
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +570 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +89 -0
- package/skills/trackops/SKILL.md +89 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +73 -0
- package/skills/trackops/references/troubleshooting.md +49 -0
- package/skills/trackops/references/workflow.md +26 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +203 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/opera/agent.md +10 -9
- package/templates/opera/architecture/dependency-graph.md +24 -0
- package/templates/opera/architecture/runtime-automation.md +24 -0
- package/templates/opera/architecture/runtime-operations.md +34 -0
- package/templates/opera/en/agent.md +27 -0
- package/templates/opera/en/architecture/dependency-graph.md +24 -0
- package/templates/opera/en/architecture/runtime-automation.md +24 -0
- package/templates/opera/en/architecture/runtime-operations.md +34 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/reviews/delivery-audit.md +18 -0
- package/templates/opera/en/reviews/integration-audit.md +18 -0
- package/templates/opera/en/router.md +49 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/opera/reviews/delivery-audit.md +18 -0
- package/templates/opera/reviews/integration-audit.md +18 -0
- package/templates/opera/router.md +15 -5
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/opera-contract-auditor/SKILL.md +38 -0
- package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -0
- package/templates/skills/opera-policy-guard/SKILL.md +26 -0
- package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/project-starter-skill/SKILL.md +89 -164
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +104 -0
- package/ui/css/panels.css +956 -953
- package/ui/index.html +1 -1
- package/ui/js/api.js +211 -194
- package/ui/js/app.js +200 -199
- package/ui/js/i18n.js +14 -0
- package/ui/js/onboarding.js +439 -437
- package/ui/js/state.js +130 -129
- package/ui/js/utils.js +175 -172
- package/ui/js/views/board.js +255 -254
- package/ui/js/views/execution.js +256 -256
- package/ui/js/views/insights.js +340 -339
- package/ui/js/views/overview.js +366 -361
- package/ui/js/views/settings.js +340 -202
- package/ui/js/views/sidebar.js +131 -132
- package/ui/js/views/skills.js +163 -162
- package/ui/js/views/tasks.js +406 -405
- package/ui/js/views/topbar.js +239 -183
- package/templates/etapa/agent.md +0 -26
- package/templates/etapa/genesis.md +0 -94
- package/templates/etapa/references/autonomy-and-recovery.md +0 -117
- package/templates/etapa/references/etapa-cycle.md +0 -193
- package/templates/etapa/registry.md +0 -28
- package/templates/etapa/router.md +0 -39
package/ui/js/views/overview.js
CHANGED
|
@@ -1,364 +1,369 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* overview.js — Vista principal del Dashboard
|
|
3
|
-
* KPI cards + charts + next task + portfolio + time tracker
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { icon } from '../icons.js';
|
|
7
|
-
import * as state from '../state.js';
|
|
8
|
-
import * as router from '../router.js';
|
|
9
|
-
import * as timeTracker from '../time-tracker.js';
|
|
10
|
-
import { esc, formatDate, lastDays, extractHistory } from '../utils.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<p class="chart-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
/**
|
|
2
|
+
* overview.js — Vista principal del Dashboard
|
|
3
|
+
* KPI cards + charts + next task + portfolio + time tracker
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { icon } from '../icons.js';
|
|
7
|
+
import * as state from '../state.js';
|
|
8
|
+
import * as router from '../router.js';
|
|
9
|
+
import * as timeTracker from '../time-tracker.js';
|
|
10
|
+
import { esc, formatDate, lastDays, extractHistory } from '../utils.js';
|
|
11
|
+
import { t } from '../i18n.js';
|
|
12
|
+
|
|
13
|
+
/** Renderiza la vista Overview completa */
|
|
14
|
+
export async function render() {
|
|
15
|
+
const payload = state.getPayload();
|
|
16
|
+
if (!payload) {
|
|
17
|
+
return `<div class="empty-state" style="margin:3rem auto;max-width:440px">
|
|
18
|
+
${icon('alertCircle', 32)}
|
|
19
|
+
<p>${t('ui.overview.loadError', {}, 'Could not load project state.')}</p>
|
|
20
|
+
<button class="btn btn-primary" onclick="window.dispatchEvent(new CustomEvent('ops:refresh'))">${t('ui.overview.retry', {}, 'Retry')}</button>
|
|
21
|
+
</div>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { derived, runtime, control, project, docsDirty, opera } = payload;
|
|
25
|
+
|
|
26
|
+
const html = `
|
|
27
|
+
<div class="view-enter">
|
|
28
|
+
<!-- ── KPI CARDS ── -->
|
|
29
|
+
<div class="grid-4" style="margin-bottom:var(--space-5)" aria-label="${t('ui.overview.metrics', {}, 'Project metrics')}" role="region">
|
|
30
|
+
${_renderKPI(t('ui.overview.openWork', {}, 'Open work'), derived.totals.all - derived.totals.completed - derived.totals.cancelled,
|
|
31
|
+
t('ui.overview.openWorkSub', { pending: derived.totals.pending, inProgress: derived.totals.inProgress }, `${derived.totals.pending} pending · ${derived.totals.inProgress} active`), 'tasks', 'accent')}
|
|
32
|
+
${_renderKPI(t('ui.overview.completed', {}, 'Completed'), derived.totals.completed,
|
|
33
|
+
t('ui.overview.completedSub', { percent: derived.totals.all ? Math.round(derived.totals.completed/derived.totals.all*100) : 0 }, '0% of total'), 'checkCircle', 'success')}
|
|
34
|
+
${_renderKPI(t('ui.overview.blocked', {}, 'Blocked'), derived.totals.blocked,
|
|
35
|
+
derived.blockers[0]?.title || t('ui.overview.noBlockers', {}, 'No blockers'), 'shield', 'danger')}
|
|
36
|
+
${_renderKPI(t('ui.overview.inReview', {}, 'In review'), derived.totals.inReview,
|
|
37
|
+
derived.reviewTasks[0]?.title || t('ui.overview.noReview', {}, 'No tasks in review'), 'alertCircle', 'warning')}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- ── FILA PRINCIPAL ── -->
|
|
41
|
+
<div style="display:grid;grid-template-columns:1fr 340px;gap:var(--space-5);margin-bottom:var(--space-5)">
|
|
42
|
+
|
|
43
|
+
<!-- Charts (actividad semanal + fase progress) -->
|
|
44
|
+
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
|
45
|
+
|
|
46
|
+
<!-- Activity Chart -->
|
|
47
|
+
<div class="chart-card stagger-1" aria-label="${t('ui.overview.weeklyActivity', {}, 'Last week activity')}">
|
|
48
|
+
<div style="display:flex;align-items:center;justify-content:space-between">
|
|
49
|
+
<div>
|
|
50
|
+
<p class="chart-title">${t('ui.overview.activityTitle', {}, 'Weekly activity')}</p>
|
|
51
|
+
<p class="chart-subtitle">${t('ui.overview.activitySubtitle', {}, 'State changes recorded per day')}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
${_renderActivityChart(control.tasks)}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- Phase Chart -->
|
|
58
|
+
<div class="chart-card stagger-2" aria-label="${t('ui.overview.phaseProgress', {}, 'Progress by phase')}">
|
|
59
|
+
<p class="chart-title">${t('ui.overview.phaseProgress', {}, 'Progress by phase')}</p>
|
|
60
|
+
${_renderPhaseChart(derived.phaseStats)}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- Column derecha: Donut + Project progress -->
|
|
66
|
+
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
|
67
|
+
|
|
68
|
+
<!-- Donut de progreso global -->
|
|
69
|
+
<div class="chart-card stagger-3" style="align-items:center" aria-label="${t('ui.overview.globalProgress', {}, 'Global project progress')}">
|
|
70
|
+
<p class="chart-title" style="width:100%">${t('ui.overview.globalProgress', {}, 'Global progress')}</p>
|
|
71
|
+
${_renderDonut(derived.totals)}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Próxima tarea -->
|
|
75
|
+
<div class="chart-card stagger-4" aria-label="${t('ui.overview.nextTask', {}, 'Next task')}">
|
|
76
|
+
<p class="chart-title" style="margin-bottom:var(--space-3)">${t('ui.overview.nextMove', {}, 'Next move')}</p>
|
|
77
|
+
${derived.nextTask ? _renderNextTask(derived.nextTask) : `<p class="text-muted" style="font-size:var(--text-sm)">🎉 ${t('ui.overview.noOpenTasks', {}, 'No open tasks')}</p>`}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- ── FILA INFERIOR: Time Tracker + Portfolio + Repo ── -->
|
|
84
|
+
<div class="grid-3" aria-label="${t('ui.overview.controlsPortfolio', {}, 'Controls and portfolio')}">
|
|
85
|
+
|
|
86
|
+
<!-- Time Tracker -->
|
|
87
|
+
<div class="stagger-1">
|
|
88
|
+
${timeTracker.renderWidget(state.get('selectedTaskId'))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Portfolio de proyectos -->
|
|
92
|
+
<div class="chart-card stagger-2" aria-label="${t('ui.overview.registeredProjects', {}, 'Registered projects')}">
|
|
93
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
|
|
94
|
+
<p class="chart-title">${t('ui.overview.portfolio', {}, 'Portfolio')}</p>
|
|
95
|
+
<span class="badge badge-muted">${t('ui.overview.projectsCount', { count: state.get('projects').length }, `${state.get('projects').length} projects`)}</span>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="stack stack-sm" id="portfolio-list">
|
|
98
|
+
${_renderPortfolio()}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Salud operativa -->
|
|
103
|
+
<div class="chart-card stagger-3" aria-label="${t('ui.overview.operationalHealth', {}, 'Operational health')}">
|
|
104
|
+
<p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.overview.operationalHealth', {}, 'Operational health')}</p>
|
|
105
|
+
${_renderRepoHealth(runtime, docsDirty, derived, opera)}
|
|
62
106
|
</div>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!projects.length) {
|
|
304
|
-
return '<div class="empty-state">No hay proyectos registrados.</div>';
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return projects.map(p => `
|
|
308
|
-
<div class="project-row ${p.id === currentId ? 'is-active' : ''}">
|
|
309
|
-
<div class="project-row-info">
|
|
310
|
-
<p class="project-name truncate">${esc(p.name)}</p>
|
|
311
|
-
<p class="project-path">${esc(p.root)}</p>
|
|
312
|
-
</div>
|
|
313
|
-
<div class="project-row-actions">
|
|
314
|
-
<span class="badge badge-${p.available ? 'success' : 'warning'}">${p.available ? 'Activo' : 'No disponible'}</span>
|
|
315
|
-
${p.available && p.id !== currentId
|
|
316
|
-
? `<button class="btn btn-ghost btn-sm" type="button" data-switch-project="${esc(p.id)}">Abrir</button>`
|
|
317
|
-
: ''}
|
|
318
|
-
</div>
|
|
319
|
-
</div>
|
|
320
|
-
`).join('');
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function _bindPortfolioEvents() {
|
|
324
|
-
document.querySelectorAll('[data-switch-project]').forEach(btn => {
|
|
325
|
-
btn.addEventListener('click', () => {
|
|
326
|
-
const id = btn.dataset.switchProject;
|
|
327
|
-
state.update('currentProjectId', id);
|
|
328
|
-
localStorage.setItem('ops-dashboard-project', id);
|
|
329
|
-
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
330
|
-
});
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ─────────────────────────────── REPO HEALTH ────────────────────────────────
|
|
335
|
-
|
|
336
|
-
function _renderRepoHealth(runtime, docsDirty, derived) {
|
|
337
|
-
const completionRate = derived.totals.all
|
|
338
|
-
? Math.round((derived.totals.completed / derived.totals.all) * 100)
|
|
339
|
-
: 0;
|
|
340
|
-
|
|
341
|
-
const blockerRate = derived.totals.all
|
|
342
|
-
? Math.round((derived.totals.blocked / derived.totals.all) * 100)
|
|
343
|
-
: 0;
|
|
344
|
-
|
|
107
|
+
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
// Vincular eventos tras insertar en DOM
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
_bindPortfolioEvents();
|
|
115
|
+
timeTracker.bindWidget(state.get('selectedTaskId'), state.findTask?.(state.get('selectedTaskId'))?.title);
|
|
116
|
+
}, 0);
|
|
117
|
+
|
|
118
|
+
return html;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────── KPI CARD ───────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function _renderKPI(title, value, sub, iconName, variant) {
|
|
124
|
+
return `
|
|
125
|
+
<article class="kpi-card kpi-${variant}" role="figure" aria-label="${title}: ${value}">
|
|
126
|
+
<div class="kpi-header">
|
|
127
|
+
<p class="kpi-title">${esc(title)}</p>
|
|
128
|
+
<div class="kpi-icon ${variant}">${icon(iconName, 16)}</div>
|
|
129
|
+
</div>
|
|
130
|
+
<p class="kpi-value">${esc(String(value))}</p>
|
|
131
|
+
<p class="kpi-sub truncate">${esc(sub)}</p>
|
|
132
|
+
</article>
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────── ACTIVITY CHART ─────────────────────────────
|
|
137
|
+
|
|
138
|
+
function _renderActivityChart(tasks) {
|
|
139
|
+
const entries = extractHistory(tasks);
|
|
140
|
+
const days = lastDays(10);
|
|
141
|
+
const counts = new Map(days.map(d => [d, 0]));
|
|
142
|
+
entries.forEach(e => {
|
|
143
|
+
const day = e.at?.slice(0, 10);
|
|
144
|
+
if (counts.has(day)) counts.set(day, counts.get(day) + 1);
|
|
145
|
+
});
|
|
146
|
+
const max = Math.max(...counts.values(), 1);
|
|
147
|
+
|
|
148
|
+
const bars = Array.from(counts.entries()).map(([day, count]) => {
|
|
149
|
+
const pct = Math.max(8, Math.round((count / max) * 100));
|
|
150
|
+
const label = day.slice(5); // MM-DD
|
|
151
|
+
return `
|
|
152
|
+
<div class="activity-bar-col" title="${count} cambio${count !== 1 ? 's' : ''} el ${day}">
|
|
153
|
+
<div class="activity-bar" style="height:${pct}%" aria-label="${count} cambios"></div>
|
|
154
|
+
<span class="activity-bar-label">${esc(label)}</span>
|
|
155
|
+
</div>
|
|
156
|
+
`;
|
|
157
|
+
}).join('');
|
|
158
|
+
|
|
159
|
+
return `<div class="activity-chart" aria-label="Barras de actividad">${bars}</div>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────── PHASE CHART ────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function _renderPhaseChart(phaseStats) {
|
|
165
|
+
if (!phaseStats?.length) {
|
|
166
|
+
return `<p class="text-muted" style="font-size:var(--text-sm)">${t('ui.overview.noPhases', {}, 'No phases configured.')}</p>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return `
|
|
170
|
+
<div class="phase-chart">
|
|
171
|
+
${phaseStats.map(p => {
|
|
172
|
+
const pct = p.total ? Math.round((p.completed / p.total) * 100) : 0;
|
|
173
|
+
const cls = pct === 100 ? 'done' : pct > 0 ? 'active' : 'partial';
|
|
174
|
+
return `
|
|
175
|
+
<div class="phase-row">
|
|
176
|
+
<div class="phase-row-header">
|
|
177
|
+
<span class="phase-name">${esc(p.id)} · ${esc(p.label)}</span>
|
|
178
|
+
<span class="phase-progress">${p.completed}/${p.total} · ${pct}%</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="phase-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${p.label}: ${pct}%">
|
|
181
|
+
<div class="phase-fill ${cls}" style="width:${pct}%"></div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
`;
|
|
185
|
+
}).join('')}
|
|
186
|
+
</div>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────── DONUT ──────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function _renderDonut(totals) {
|
|
193
|
+
const all = totals.all || 1;
|
|
194
|
+
const completed = totals.completed;
|
|
195
|
+
const inProgress = totals.inProgress;
|
|
196
|
+
const blocked = totals.blocked;
|
|
197
|
+
const pending = totals.pending;
|
|
198
|
+
const pct = Math.round((completed / all) * 100);
|
|
199
|
+
|
|
200
|
+
const R = 54;
|
|
201
|
+
const C = 2 * Math.PI * R;
|
|
202
|
+
|
|
203
|
+
function arc(val, total = all) {
|
|
204
|
+
return Math.min(C * (val / total), C);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Calcular offsets (apilado)
|
|
208
|
+
const completedDash = arc(completed);
|
|
209
|
+
const inProgressDash = arc(inProgress);
|
|
210
|
+
const blockedDash = arc(blocked);
|
|
211
|
+
|
|
212
|
+
const o1 = 0;
|
|
213
|
+
const o2 = -(completedDash);
|
|
214
|
+
const o3 = -(completedDash + inProgressDash);
|
|
215
|
+
const o4 = -(completedDash + inProgressDash + blockedDash);
|
|
216
|
+
|
|
217
|
+
return `
|
|
218
|
+
<div class="donut-wrapper" style="position:relative;width:160px;height:160px">
|
|
219
|
+
<svg class="donut-svg" width="160" height="160" viewBox="0 0 160 160" role="img" aria-label="Progreso: ${pct}%">
|
|
220
|
+
<defs>
|
|
221
|
+
<style>
|
|
222
|
+
.donut-arc { transform-origin: 80px 80px; transform: rotate(-90deg); }
|
|
223
|
+
</style>
|
|
224
|
+
</defs>
|
|
225
|
+
<!-- Track -->
|
|
226
|
+
<circle class="donut-track" cx="80" cy="80" r="${R}" />
|
|
227
|
+
<!-- Completed (verde) -->
|
|
228
|
+
<circle class="donut-arc arc-completed"
|
|
229
|
+
cx="80" cy="80" r="${R}"
|
|
230
|
+
stroke-dasharray="${completedDash} ${C}"
|
|
231
|
+
stroke-dashoffset="${o1}" />
|
|
232
|
+
<!-- In Progress (indigo) -->
|
|
233
|
+
<circle class="donut-arc arc-progress"
|
|
234
|
+
cx="80" cy="80" r="${R}"
|
|
235
|
+
stroke-dasharray="${inProgressDash} ${C}"
|
|
236
|
+
stroke-dashoffset="${o2}" />
|
|
237
|
+
<!-- Blocked (rojo) -->
|
|
238
|
+
<circle class="donut-arc arc-blocked"
|
|
239
|
+
cx="80" cy="80" r="${R}"
|
|
240
|
+
stroke-dasharray="${blockedDash} ${C}"
|
|
241
|
+
stroke-dashoffset="${o3}" />
|
|
242
|
+
</svg>
|
|
243
|
+
<div class="donut-label">
|
|
244
|
+
<span class="donut-percent">${pct}%</span>
|
|
245
|
+
<span class="donut-sub">${t('ui.overview.completedLower', {}, 'completed')}</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="donut-legend">
|
|
249
|
+
<div class="donut-legend-item">
|
|
250
|
+
<span class="donut-legend-dot" style="background:var(--success)"></span>
|
|
251
|
+
${t('ui.overview.completed', {}, 'Completed')} (${completed})
|
|
252
|
+
</div>
|
|
253
|
+
<div class="donut-legend-item">
|
|
254
|
+
<span class="donut-legend-dot" style="background:var(--accent)"></span>
|
|
255
|
+
${t('status.in_progress', {}, 'In progress')} (${inProgress})
|
|
256
|
+
</div>
|
|
257
|
+
<div class="donut-legend-item">
|
|
258
|
+
<span class="donut-legend-dot" style="background:var(--danger)"></span>
|
|
259
|
+
${t('status.blocked', {}, 'Blocked')} (${blocked})
|
|
260
|
+
</div>
|
|
261
|
+
<div class="donut-legend-item">
|
|
262
|
+
<span class="donut-legend-dot" style="background:var(--text-muted)"></span>
|
|
263
|
+
${t('status.pending', {}, 'Pending')} (${pending})
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─────────────────────────────── NEXT TASK ──────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function _renderNextTask(task) {
|
|
272
|
+
const priorityColors = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
273
|
+
const statusLabels = state.getStatusLabels();
|
|
274
|
+
const statusLabel = statusLabels[task.status] || task.status;
|
|
275
|
+
|
|
276
|
+
return `
|
|
277
|
+
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
|
278
|
+
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
|
279
|
+
<span class="badge badge-${priorityColors[task.priority] || 'muted'}">${esc(task.priority)}</span>
|
|
280
|
+
<span class="badge badge-muted">${esc(task.phase)}</span>
|
|
281
|
+
<span class="badge status-${task.status}">${esc(statusLabel)}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<p style="font-size:var(--text-sm);font-weight:700;color:var(--text-primary)">${esc(task.title)}</p>
|
|
284
|
+
${task.summary ? `<p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(task.summary)}</p>` : ''}
|
|
285
|
+
<button
|
|
286
|
+
class="btn btn-primary btn-sm"
|
|
287
|
+
type="button"
|
|
288
|
+
data-view="board"
|
|
289
|
+
style="margin-top:var(--space-2);width:fit-content"
|
|
290
|
+
aria-label="${t('ui.overview.openBoardFor', { title: task.title }, `Open board to manage ${task.title}`)}"
|
|
291
|
+
>
|
|
292
|
+
${icon('arrowRight', 14)} ${t('ui.overview.viewBoard', {}, 'View in board')}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─────────────────────────────── PORTFOLIO ──────────────────────────────────
|
|
299
|
+
|
|
300
|
+
function _renderPortfolio() {
|
|
301
|
+
const projects = state.get('projects');
|
|
302
|
+
const currentId = state.get('currentProjectId');
|
|
303
|
+
|
|
304
|
+
if (!projects.length) {
|
|
305
|
+
return `<div class="empty-state">${t('ui.overview.noProjects', {}, 'No registered projects.')}</div>`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return projects.map(p => `
|
|
309
|
+
<div class="project-row ${p.id === currentId ? 'is-active' : ''}">
|
|
310
|
+
<div class="project-row-info">
|
|
311
|
+
<p class="project-name truncate">${esc(p.name)}</p>
|
|
312
|
+
<p class="project-path">${esc(p.root)}</p>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="project-row-actions">
|
|
315
|
+
<span class="badge badge-${p.available ? 'success' : 'warning'}">${p.available ? t('ui.overview.active', {}, 'Active') : t('ui.overview.unavailable', {}, 'Unavailable')}</span>
|
|
316
|
+
${p.available && p.id !== currentId
|
|
317
|
+
? `<button class="btn btn-ghost btn-sm" type="button" data-switch-project="${esc(p.id)}">${t('ui.overview.open', {}, 'Open')}</button>`
|
|
318
|
+
: ''}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
`).join('');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function _bindPortfolioEvents() {
|
|
325
|
+
document.querySelectorAll('[data-switch-project]').forEach(btn => {
|
|
326
|
+
btn.addEventListener('click', () => {
|
|
327
|
+
const id = btn.dataset.switchProject;
|
|
328
|
+
state.update('currentProjectId', id);
|
|
329
|
+
localStorage.setItem('ops-dashboard-project', id);
|
|
330
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─────────────────────────────── REPO HEALTH ────────────────────────────────
|
|
336
|
+
|
|
337
|
+
function _renderRepoHealth(runtime, docsDirty, derived, opera) {
|
|
338
|
+
const completionRate = derived.totals.all
|
|
339
|
+
? Math.round((derived.totals.completed / derived.totals.all) * 100)
|
|
340
|
+
: 0;
|
|
341
|
+
|
|
342
|
+
const blockerRate = derived.totals.all
|
|
343
|
+
? Math.round((derived.totals.blocked / derived.totals.all) * 100)
|
|
344
|
+
: 0;
|
|
345
|
+
|
|
345
346
|
const items = [
|
|
346
|
-
{ label: '
|
|
347
|
-
{ label: '
|
|
348
|
-
{ label: '
|
|
349
|
-
{ label: 'Repo',
|
|
350
|
-
{ label: '
|
|
351
|
-
{ label: '
|
|
347
|
+
{ label: t('ui.overview.metric.completion', {}, 'Completion rate'), value: `${completionRate}%`, cls: completionRate >= 75 ? 'good' : completionRate >= 40 ? '' : 'bad' },
|
|
348
|
+
{ label: t('ui.overview.metric.blockerPressure', {}, 'Blocker pressure'), value: `${blockerRate}%`, cls: blockerRate === 0 ? 'good' : blockerRate > 20 ? 'bad' : 'warn' },
|
|
349
|
+
{ label: t('ui.overview.metric.findings', {}, 'Open findings'), value: String((derived.openFindings || []).length), cls: (derived.openFindings||[]).length === 0 ? 'good' : 'warn' },
|
|
350
|
+
{ label: t('ui.overview.metric.repo', {}, 'Repo'), value: runtime?.clean ? t('ui.overview.repoClean', {}, 'Clean') : t('ui.overview.repoDirty', {}, 'Changes present'), cls: runtime?.clean ? 'good' : 'warn' },
|
|
351
|
+
{ label: t('ui.overview.metric.docDrift', {}, 'Documentation drift'), value: docsDirty?.length ? docsDirty.join(', ') : 'OK', cls: docsDirty?.length ? 'warn' : 'good' },
|
|
352
|
+
{ label: t('ui.overview.metric.lastCommit', {}, 'Last commit'), value: runtime?.lastCommit ? `${runtime.lastCommit.shortHash} · ${formatDate(runtime.lastCommit.date, 'date')}` : '—', cls: '' },
|
|
353
|
+
{ label: t('ui.overview.metric.contract', {}, 'Contract readiness'), value: opera?.contractReadiness || 'hypothesis', cls: opera?.contractReadiness === 'locked' || opera?.contractReadiness === 'verified' ? 'good' : 'warn' },
|
|
354
|
+
{ label: t('ui.overview.metric.bootstrap', {}, 'OPERA bootstrap'), value: opera?.bootstrap?.status || 'awaiting_intake', cls: opera?.bootstrap?.status === 'completed' ? 'good' : opera?.bootstrap?.status === 'legacy_unsupported' ? 'bad' : 'warn' },
|
|
355
|
+
{ label: t('ui.overview.metric.legacy', {}, 'Legacy status'), value: opera?.legacyStatus || 'supported', cls: opera?.legacyStatus === 'supported' ? 'good' : 'bad' },
|
|
356
|
+
{ label: t('ui.overview.metric.localeSource', {}, 'Language source'), value: opera?.localeSource || 'project', cls: '' },
|
|
352
357
|
];
|
|
353
|
-
|
|
354
|
-
return `
|
|
355
|
-
<div class="health-grid">
|
|
356
|
-
${items.map(({ label, value, cls }) => `
|
|
357
|
-
<div class="health-card">
|
|
358
|
-
<p class="health-card-label">${esc(label)}</p>
|
|
359
|
-
<p class="health-card-value ${cls} truncate" title="${esc(value)}">${esc(value)}</p>
|
|
360
|
-
</div>
|
|
361
|
-
`).join('')}
|
|
362
|
-
</div>
|
|
363
|
-
`;
|
|
364
|
-
}
|
|
358
|
+
|
|
359
|
+
return `
|
|
360
|
+
<div class="health-grid">
|
|
361
|
+
${items.map(({ label, value, cls }) => `
|
|
362
|
+
<div class="health-card">
|
|
363
|
+
<p class="health-card-label">${esc(label)}</p>
|
|
364
|
+
<p class="health-card-value ${cls} truncate" title="${esc(value)}">${esc(value)}</p>
|
|
365
|
+
</div>
|
|
366
|
+
`).join('')}
|
|
367
|
+
</div>
|
|
368
|
+
`;
|
|
369
|
+
}
|