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.
Files changed (83) hide show
  1. package/README.md +292 -272
  2. package/bin/trackops.js +108 -50
  3. package/lib/config.js +267 -38
  4. package/lib/control.js +534 -480
  5. package/lib/env.js +244 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +170 -47
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +1075 -0
  10. package/lib/opera.js +524 -125
  11. package/lib/preferences.js +74 -0
  12. package/lib/registry.js +27 -13
  13. package/lib/release.js +56 -0
  14. package/lib/resources.js +42 -0
  15. package/lib/runtime-state.js +144 -0
  16. package/lib/server.js +1004 -521
  17. package/lib/skills.js +148 -124
  18. package/lib/workspace.js +260 -0
  19. package/locales/en.json +418 -132
  20. package/locales/es.json +418 -132
  21. package/package.json +8 -9
  22. package/scripts/postinstall-locale.js +21 -0
  23. package/scripts/skills-marketplace-smoke.js +124 -0
  24. package/scripts/smoke-tests.js +570 -0
  25. package/scripts/sync-skill-version.js +21 -0
  26. package/scripts/validate-skill.js +89 -0
  27. package/skills/trackops/SKILL.md +89 -0
  28. package/skills/trackops/agents/openai.yaml +3 -0
  29. package/skills/trackops/references/activation.md +73 -0
  30. package/skills/trackops/references/troubleshooting.md +49 -0
  31. package/skills/trackops/references/workflow.md +26 -0
  32. package/skills/trackops/scripts/bootstrap-trackops.js +203 -0
  33. package/skills/trackops/skill.json +29 -0
  34. package/templates/opera/agent.md +10 -9
  35. package/templates/opera/architecture/dependency-graph.md +24 -0
  36. package/templates/opera/architecture/runtime-automation.md +24 -0
  37. package/templates/opera/architecture/runtime-operations.md +34 -0
  38. package/templates/opera/en/agent.md +27 -0
  39. package/templates/opera/en/architecture/dependency-graph.md +24 -0
  40. package/templates/opera/en/architecture/runtime-automation.md +24 -0
  41. package/templates/opera/en/architecture/runtime-operations.md +34 -0
  42. package/templates/opera/en/genesis.md +79 -0
  43. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  44. package/templates/opera/en/references/opera-cycle.md +62 -0
  45. package/templates/opera/en/registry.md +28 -0
  46. package/templates/opera/en/reviews/delivery-audit.md +18 -0
  47. package/templates/opera/en/reviews/integration-audit.md +18 -0
  48. package/templates/opera/en/router.md +49 -0
  49. package/templates/opera/genesis.md +79 -94
  50. package/templates/opera/reviews/delivery-audit.md +18 -0
  51. package/templates/opera/reviews/integration-audit.md +18 -0
  52. package/templates/opera/router.md +15 -5
  53. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  54. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  55. package/templates/skills/opera-contract-auditor/SKILL.md +38 -0
  56. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -0
  57. package/templates/skills/opera-policy-guard/SKILL.md +26 -0
  58. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -0
  59. package/templates/skills/project-starter-skill/SKILL.md +89 -164
  60. package/templates/skills/project-starter-skill/locales/en/SKILL.md +104 -0
  61. package/ui/css/panels.css +956 -953
  62. package/ui/index.html +1 -1
  63. package/ui/js/api.js +211 -194
  64. package/ui/js/app.js +200 -199
  65. package/ui/js/i18n.js +14 -0
  66. package/ui/js/onboarding.js +439 -437
  67. package/ui/js/state.js +130 -129
  68. package/ui/js/utils.js +175 -172
  69. package/ui/js/views/board.js +255 -254
  70. package/ui/js/views/execution.js +256 -256
  71. package/ui/js/views/insights.js +340 -339
  72. package/ui/js/views/overview.js +366 -361
  73. package/ui/js/views/settings.js +340 -202
  74. package/ui/js/views/sidebar.js +131 -132
  75. package/ui/js/views/skills.js +163 -162
  76. package/ui/js/views/tasks.js +406 -405
  77. package/ui/js/views/topbar.js +239 -183
  78. package/templates/etapa/agent.md +0 -26
  79. package/templates/etapa/genesis.md +0 -94
  80. package/templates/etapa/references/autonomy-and-recovery.md +0 -117
  81. package/templates/etapa/references/etapa-cycle.md +0 -193
  82. package/templates/etapa/registry.md +0 -28
  83. package/templates/etapa/router.md +0 -39
@@ -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
- /** Renderiza la vista Overview completa */
13
- export async function render() {
14
- const payload = state.getPayload();
15
- if (!payload) {
16
- return `<div class="empty-state" style="margin:3rem auto;max-width:440px">
17
- ${icon('alertCircle', 32)}
18
- <p>No se pudo cargar el estado del proyecto.</p>
19
- <button class="btn btn-primary" onclick="window.dispatchEvent(new CustomEvent('ops:refresh'))">Reintentar</button>
20
- </div>`;
21
- }
22
-
23
- const { derived, runtime, control, project, docsDirty } = payload;
24
-
25
- const html = `
26
- <div class="view-enter">
27
- <!-- ── KPI CARDS ── -->
28
- <div class="grid-4" style="margin-bottom:var(--space-5)" aria-label="Métricas del proyecto" role="region">
29
- ${_renderKPI('Trabajo abierto', derived.totals.all - derived.totals.completed - derived.totals.cancelled,
30
- `${derived.totals.pending} pendientes · ${derived.totals.inProgress} activas`, 'tasks', 'accent')}
31
- ${_renderKPI('Completado', derived.totals.completed,
32
- `${derived.totals.all ? Math.round(derived.totals.completed/derived.totals.all*100) : 0}% del total`, 'checkCircle', 'success')}
33
- ${_renderKPI('Bloqueado', derived.totals.blocked,
34
- derived.blockers[0]?.title || 'Sin bloqueos', 'shield', 'danger')}
35
- ${_renderKPI('En Revisión', derived.totals.inReview,
36
- derived.reviewTasks[0]?.title || 'Sin tareas en revisión', 'alertCircle', 'warning')}
37
- </div>
38
-
39
- <!-- ── FILA PRINCIPAL ── -->
40
- <div style="display:grid;grid-template-columns:1fr 340px;gap:var(--space-5);margin-bottom:var(--space-5)">
41
-
42
- <!-- Charts (actividad semanal + fase progress) -->
43
- <div style="display:flex;flex-direction:column;gap:var(--space-4)">
44
-
45
- <!-- Activity Chart -->
46
- <div class="chart-card stagger-1" aria-label="Actividad de la última semana">
47
- <div style="display:flex;align-items:center;justify-content:space-between">
48
- <div>
49
- <p class="chart-title">Actividad Semanal</p>
50
- <p class="chart-subtitle">Cambios de estado registrados por día</p>
51
- </div>
52
- </div>
53
- ${_renderActivityChart(control.tasks)}
54
- </div>
55
-
56
- <!-- Phase Chart -->
57
- <div class="chart-card stagger-2" aria-label="Progreso por fase">
58
- <p class="chart-title">Progreso por Fase</p>
59
- ${_renderPhaseChart(derived.phaseStats)}
60
- </div>
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
- <!-- Column derecha: Donut + Project progress -->
65
- <div style="display:flex;flex-direction:column;gap:var(--space-4)">
66
-
67
- <!-- Donut de progreso global -->
68
- <div class="chart-card stagger-3" style="align-items:center" aria-label="Progreso global del proyecto">
69
- <p class="chart-title" style="width:100%">Progreso Global</p>
70
- ${_renderDonut(derived.totals)}
71
- </div>
72
-
73
- <!-- Próxima tarea -->
74
- <div class="chart-card stagger-4" aria-label="Próxima tarea">
75
- <p class="chart-title" style="margin-bottom:var(--space-3)">Próximo Movimiento</p>
76
- ${derived.nextTask ? _renderNextTask(derived.nextTask) : '<p class="text-muted" style="font-size:var(--text-sm)">🎉 No hay tareas abiertas</p>'}
77
- </div>
78
-
79
- </div>
80
- </div>
81
-
82
- <!-- ── FILA INFERIOR: Time Tracker + Portfolio + Repo ── -->
83
- <div class="grid-3" aria-label="Controles y portfolio">
84
-
85
- <!-- Time Tracker -->
86
- <div class="stagger-1">
87
- ${timeTracker.renderWidget(state.get('selectedTaskId'))}
88
- </div>
89
-
90
- <!-- Portfolio de proyectos -->
91
- <div class="chart-card stagger-2" aria-label="Proyectos registrados">
92
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
93
- <p class="chart-title">Portfolio</p>
94
- <span class="badge badge-muted">${state.get('projects').length} proyectos</span>
95
- </div>
96
- <div class="stack stack-sm" id="portfolio-list">
97
- ${_renderPortfolio()}
98
- </div>
99
- </div>
100
-
101
- <!-- Salud operativa -->
102
- <div class="chart-card stagger-3" aria-label="Salud operativa del repositorio">
103
- <p class="chart-title" style="margin-bottom:var(--space-4)">Salud Operativa</p>
104
- ${_renderRepoHealth(runtime, docsDirty, derived)}
105
- </div>
106
-
107
- </div>
108
- </div>
109
- `;
110
-
111
- // Vincular eventos tras insertar en DOM
112
- setTimeout(() => {
113
- _bindPortfolioEvents();
114
- timeTracker.bindWidget(state.get('selectedTaskId'), state.findTask?.(state.get('selectedTaskId'))?.title);
115
- }, 0);
116
-
117
- return html;
118
- }
119
-
120
- // ─────────────────────────────── KPI CARD ───────────────────────────────────
121
-
122
- function _renderKPI(title, value, sub, iconName, variant) {
123
- return `
124
- <article class="kpi-card kpi-${variant}" role="figure" aria-label="${title}: ${value}">
125
- <div class="kpi-header">
126
- <p class="kpi-title">${esc(title)}</p>
127
- <div class="kpi-icon ${variant}">${icon(iconName, 16)}</div>
128
- </div>
129
- <p class="kpi-value">${esc(String(value))}</p>
130
- <p class="kpi-sub truncate">${esc(sub)}</p>
131
- </article>
132
- `;
133
- }
134
-
135
- // ─────────────────────────────── ACTIVITY CHART ─────────────────────────────
136
-
137
- function _renderActivityChart(tasks) {
138
- const entries = extractHistory(tasks);
139
- const days = lastDays(10);
140
- const counts = new Map(days.map(d => [d, 0]));
141
- entries.forEach(e => {
142
- const day = e.at?.slice(0, 10);
143
- if (counts.has(day)) counts.set(day, counts.get(day) + 1);
144
- });
145
- const max = Math.max(...counts.values(), 1);
146
-
147
- const bars = Array.from(counts.entries()).map(([day, count]) => {
148
- const pct = Math.max(8, Math.round((count / max) * 100));
149
- const label = day.slice(5); // MM-DD
150
- return `
151
- <div class="activity-bar-col" title="${count} cambio${count !== 1 ? 's' : ''} el ${day}">
152
- <div class="activity-bar" style="height:${pct}%" aria-label="${count} cambios"></div>
153
- <span class="activity-bar-label">${esc(label)}</span>
154
- </div>
155
- `;
156
- }).join('');
157
-
158
- return `<div class="activity-chart" aria-label="Barras de actividad">${bars}</div>`;
159
- }
160
-
161
- // ─────────────────────────────── PHASE CHART ────────────────────────────────
162
-
163
- function _renderPhaseChart(phaseStats) {
164
- if (!phaseStats?.length) {
165
- return '<p class="text-muted" style="font-size:var(--text-sm)">Sin fases configuradas.</p>';
166
- }
167
-
168
- return `
169
- <div class="phase-chart">
170
- ${phaseStats.map(p => {
171
- const pct = p.total ? Math.round((p.completed / p.total) * 100) : 0;
172
- const cls = pct === 100 ? 'done' : pct > 0 ? 'active' : 'partial';
173
- return `
174
- <div class="phase-row">
175
- <div class="phase-row-header">
176
- <span class="phase-name">${esc(p.id)} · ${esc(p.label)}</span>
177
- <span class="phase-progress">${p.completed}/${p.total} · ${pct}%</span>
178
- </div>
179
- <div class="phase-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${p.label}: ${pct}%">
180
- <div class="phase-fill ${cls}" style="width:${pct}%"></div>
181
- </div>
182
- </div>
183
- `;
184
- }).join('')}
185
- </div>
186
- `;
187
- }
188
-
189
- // ─────────────────────────────── DONUT ──────────────────────────────────────
190
-
191
- function _renderDonut(totals) {
192
- const all = totals.all || 1;
193
- const completed = totals.completed;
194
- const inProgress = totals.inProgress;
195
- const blocked = totals.blocked;
196
- const pending = totals.pending;
197
- const pct = Math.round((completed / all) * 100);
198
-
199
- const R = 54;
200
- const C = 2 * Math.PI * R;
201
-
202
- function arc(val, total = all) {
203
- return Math.min(C * (val / total), C);
204
- }
205
-
206
- // Calcular offsets (apilado)
207
- const completedDash = arc(completed);
208
- const inProgressDash = arc(inProgress);
209
- const blockedDash = arc(blocked);
210
-
211
- const o1 = 0;
212
- const o2 = -(completedDash);
213
- const o3 = -(completedDash + inProgressDash);
214
- const o4 = -(completedDash + inProgressDash + blockedDash);
215
-
216
- return `
217
- <div class="donut-wrapper" style="position:relative;width:160px;height:160px">
218
- <svg class="donut-svg" width="160" height="160" viewBox="0 0 160 160" role="img" aria-label="Progreso: ${pct}%">
219
- <defs>
220
- <style>
221
- .donut-arc { transform-origin: 80px 80px; transform: rotate(-90deg); }
222
- </style>
223
- </defs>
224
- <!-- Track -->
225
- <circle class="donut-track" cx="80" cy="80" r="${R}" />
226
- <!-- Completed (verde) -->
227
- <circle class="donut-arc arc-completed"
228
- cx="80" cy="80" r="${R}"
229
- stroke-dasharray="${completedDash} ${C}"
230
- stroke-dashoffset="${o1}" />
231
- <!-- In Progress (indigo) -->
232
- <circle class="donut-arc arc-progress"
233
- cx="80" cy="80" r="${R}"
234
- stroke-dasharray="${inProgressDash} ${C}"
235
- stroke-dashoffset="${o2}" />
236
- <!-- Blocked (rojo) -->
237
- <circle class="donut-arc arc-blocked"
238
- cx="80" cy="80" r="${R}"
239
- stroke-dasharray="${blockedDash} ${C}"
240
- stroke-dashoffset="${o3}" />
241
- </svg>
242
- <div class="donut-label">
243
- <span class="donut-percent">${pct}%</span>
244
- <span class="donut-sub">completado</span>
245
- </div>
246
- </div>
247
- <div class="donut-legend">
248
- <div class="donut-legend-item">
249
- <span class="donut-legend-dot" style="background:var(--success)"></span>
250
- Completado (${completed})
251
- </div>
252
- <div class="donut-legend-item">
253
- <span class="donut-legend-dot" style="background:var(--accent)"></span>
254
- En progreso (${inProgress})
255
- </div>
256
- <div class="donut-legend-item">
257
- <span class="donut-legend-dot" style="background:var(--danger)"></span>
258
- Bloqueado (${blocked})
259
- </div>
260
- <div class="donut-legend-item">
261
- <span class="donut-legend-dot" style="background:var(--text-muted)"></span>
262
- Pendiente (${pending})
263
- </div>
264
- </div>
265
- `;
266
- }
267
-
268
- // ─────────────────────────────── NEXT TASK ──────────────────────────────────
269
-
270
- function _renderNextTask(task) {
271
- const priorityColors = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
272
- const statusLabels = state.getStatusLabels();
273
- const statusLabel = statusLabels[task.status] || task.status;
274
-
275
- return `
276
- <div style="display:flex;flex-direction:column;gap:var(--space-2)">
277
- <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
278
- <span class="badge badge-${priorityColors[task.priority] || 'muted'}">${esc(task.priority)}</span>
279
- <span class="badge badge-muted">${esc(task.phase)}</span>
280
- <span class="badge status-${task.status}">${esc(statusLabel)}</span>
281
- </div>
282
- <p style="font-size:var(--text-sm);font-weight:700;color:var(--text-primary)">${esc(task.title)}</p>
283
- ${task.summary ? `<p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(task.summary)}</p>` : ''}
284
- <button
285
- class="btn btn-primary btn-sm"
286
- type="button"
287
- data-view="board"
288
- style="margin-top:var(--space-2);width:fit-content"
289
- aria-label="Ir al tablero para gestionar ${esc(task.title)}"
290
- >
291
- ${icon('arrowRight', 14)} Ver en el tablero
292
- </button>
293
- </div>
294
- `;
295
- }
296
-
297
- // ─────────────────────────────── PORTFOLIO ──────────────────────────────────
298
-
299
- function _renderPortfolio() {
300
- const projects = state.get('projects');
301
- const currentId = state.get('currentProjectId');
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: 'Tasa completada', value: `${completionRate}%`, cls: completionRate >= 75 ? 'good' : completionRate >= 40 ? '' : 'bad' },
347
- { label: 'Presión de bloqueos', value: `${blockerRate}%`, cls: blockerRate === 0 ? 'good' : blockerRate > 20 ? 'bad' : 'warn' },
348
- { label: 'Hallazgos abiertos', value: String((derived.openFindings || []).length), cls: (derived.openFindings||[]).length === 0 ? 'good' : 'warn' },
349
- { label: 'Repo', value: runtime?.clean ? 'Limpio' : 'Con cambios', cls: runtime?.clean ? 'good' : 'warn' },
350
- { label: 'Desfase documental', value: docsDirty?.length ? docsDirty.join(', ') : 'OK', cls: docsDirty?.length ? 'warn' : 'good' },
351
- { label: 'Último commit', value: runtime?.lastCommit ? `${runtime.lastCommit.shortHash} · ${formatDate(runtime.lastCommit.date, 'date')}` : '—', cls: '' },
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
+ }