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,339 +1,340 @@
1
- /**
2
- * insights.js — Vista de Analytics: progreso, actividad, hallazgos, KPI de salud
3
- */
4
-
5
- import { icon } from '../icons.js';
6
- import * as state from '../state.js';
7
- import * as timeTracker from '../time-tracker.js';
8
- import { esc, formatDate, formatDurationShort, extractHistory } from '../utils.js';
9
-
10
- export async function render() {
11
- const payload = state.getPayload();
12
- if (!payload) return '<div class="empty-state" style="margin:3rem">Sin datos del proyecto.</div>';
13
-
14
- const { derived, control, runtime } = payload;
15
- const statusLabels = state.getStatusLabels();
16
- const history = extractHistory(control.tasks).slice(0, 20);
17
-
18
- return `
19
- <div class="view-enter">
20
- <div class="section-header">
21
- <div class="section-header-left">
22
- <p class="eyebrow">Analíticas</p>
23
- <h2>Analíticas del Proyecto</h2>
24
- </div>
25
- </div>
26
-
27
- <!-- Fila 1: Health Grid + Distribution -->
28
- <div class="grid-2" style="margin-bottom:var(--space-5)">
29
-
30
- <!-- KPI de salud -->
31
- <div class="chart-card stagger-1">
32
- <div class="section-header" style="margin-bottom:var(--space-3)">
33
- <p class="chart-title">Salud Operativa</p>
34
- </div>
35
- ${_renderHealthGrid(derived, runtime, payload.docsDirty)}
36
- </div>
37
-
38
- <!-- Distribución por estado -->
39
- <div class="chart-card stagger-2">
40
- <p class="chart-title" style="margin-bottom:var(--space-4)">Distribución por Estado</p>
41
- ${_renderDistribution(derived.totals, statusLabels)}
42
- </div>
43
-
44
- </div>
45
-
46
- <!-- Fila 2: Time entries + Phase progress -->
47
- <div class="grid-2" style="margin-bottom:var(--space-5)">
48
-
49
- <!-- Time tracking summary -->
50
- <div class="chart-card stagger-3">
51
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
52
- <p class="chart-title">Seguimiento de tiempo</p>
53
- <span class="badge badge-accent">${icon('clock', 12)} Hoy</span>
54
- </div>
55
- ${_renderTimeTracking()}
56
- </div>
57
-
58
- <!-- Phase progress detallado -->
59
- <div class="chart-card stagger-4">
60
- <p class="chart-title" style="margin-bottom:var(--space-4)">Progreso por Fase</p>
61
- ${_renderPhaseProgress(derived.phaseStats)}
62
- </div>
63
-
64
- </div>
65
-
66
- <!-- Fila 3: Activity + Findings -->
67
- <div class="grid-2">
68
-
69
- <!-- Activity timeline -->
70
- <div class="chart-card stagger-1">
71
- <p class="chart-title" style="margin-bottom:var(--space-4)">Actividad Reciente</p>
72
- ${_renderActivityTimeline(history, statusLabels)}
73
- </div>
74
-
75
- <!-- Hallazgos + Decisiones -->
76
- <div style="display:flex;flex-direction:column;gap:var(--space-4)">
77
-
78
- <!-- Findings -->
79
- <div class="chart-card stagger-2">
80
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
81
- <p class="chart-title">Hallazgos</p>
82
- <span class="badge badge-${derived.openFindings?.length ? 'warning' : 'success'}">
83
- ${(derived.openFindings || []).length} abiertos
84
- </span>
85
- </div>
86
- ${_renderFindings(derived.openFindings || [], derived.resolvedFindings || [])}
87
- </div>
88
-
89
- <!-- Decisiones pendientes -->
90
- ${(control.decisionsPending || []).length > 0 ? `
91
- <div class="chart-card stagger-3">
92
- <p class="chart-title" style="margin-bottom:var(--space-4)">Decisiones Pendientes</p>
93
- ${_renderDecisions(control.decisionsPending)}
94
- </div>
95
- ` : ''}
96
-
97
- </div>
98
- </div>
99
- </div>
100
- `;
101
- }
102
-
103
- // ─────────────────────────────── HEALTH ─────────────────────────────────────
104
-
105
- function _renderHealthGrid(derived, runtime, docsDirty) {
106
- const totals = derived.totals;
107
- const completionRate = totals.all ? Math.round((totals.completed / totals.all) * 100) : 0;
108
- const blockerRate = totals.all ? Math.round((totals.blocked / totals.all) * 100) : 0;
109
- const openFindings = (derived.openFindings || []).length;
110
-
111
- const items = [
112
- { label: 'Tasa completada', value: `${completionRate}%`, cls: completionRate >= 75 ? 'good' : completionRate >= 40 ? '' : 'bad' },
113
- { label: 'Presión de bloqueos',value: `${blockerRate}%`, cls: blockerRate === 0 ? 'good' : blockerRate > 20 ? 'bad' : 'warn' },
114
- { label: 'En Progreso', value: String(totals.inProgress),cls: totals.inProgress > 0 ? 'good' : '' },
115
- { label: 'En Revisión', value: String(totals.inReview), cls: '' },
116
- { label: 'Hallazgos abiertos', value: String(openFindings), cls: openFindings === 0 ? 'good' : 'warn' },
117
- { label: 'Desfase documental', value: (docsDirty || []).length ? `${(docsDirty).length} archivos` : 'OK', cls: docsDirty?.length ? 'warn' : 'good' },
118
- { label: 'Por delante remoto', value: String(runtime?.ahead || 0), cls: runtime?.ahead > 0 ? 'warn' : 'good' },
119
- { label: 'Por detrás remoto', value: String(runtime?.behind || 0), cls: runtime?.behind > 0 ? 'bad' : 'good' },
120
- ];
121
-
122
- return `
123
- <div class="health-grid">
124
- ${items.map(({ label, value, cls }) => `
125
- <div class="health-card">
126
- <p class="health-card-label">${esc(label)}</p>
127
- <p class="health-card-value ${cls}" title="${esc(value)}">${esc(value)}</p>
128
- </div>
129
- `).join('')}
130
- </div>
131
- `;
132
- }
133
-
134
- // ─────────────────────────────── DISTRIBUCIÓN ───────────────────────────────
135
-
136
- function _renderDistribution(totals, statusLabels) {
137
- const all = totals.all || 1;
138
- const rows = [
139
- { id: 'completed', label: statusLabels.completed || 'Completado', value: totals.completed, cls: 'fill-success' },
140
- { id: 'in_progress',label: statusLabels.in_progress|| 'En progreso', value: totals.inProgress, cls: '' },
141
- { id: 'in_review', label: statusLabels.in_review || 'En revisión', value: totals.inReview, cls: '' },
142
- { id: 'pending', label: statusLabels.pending || 'Pendiente', value: totals.pending, cls: 'fill-warning' },
143
- { id: 'blocked', label: statusLabels.blocked || 'Bloqueado', value: totals.blocked, cls: 'fill-danger' },
144
- { id: 'cancelled', label: statusLabels.cancelled || 'Cancelado', value: totals.cancelled, cls: '' },
145
- ].filter(r => r.value > 0);
146
-
147
- return `
148
- <div class="bar-chart">
149
- ${rows.map(r => {
150
- const pct = Math.max(2, Math.round((r.value / all) * 100));
151
- return `
152
- <div class="bar-row" role="group" aria-label="${r.label}: ${r.value} (${pct}%)">
153
- <span class="bar-label truncate">${esc(r.label)}</span>
154
- <div class="bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
155
- <div class="bar-fill ${r.cls}" style="width:${pct}%"></div>
156
- </div>
157
- <span class="bar-value">${r.value}</span>
158
- </div>
159
- `;
160
- }).join('')}
161
- </div>
162
- `;
163
- }
164
-
165
- // ─────────────────────────────── TIME TRACKING ──────────────────────────────
166
-
167
- function _renderTimeTracking() {
168
- const entries = state.get('timeEntries');
169
-
170
- if (!entries.length) {
171
- return `
172
- <div class="empty-state" style="padding:var(--space-6)">
173
- ${icon('clock', 24)}
174
- <p>No hay registros de tiempo aún.</p>
175
- <p class="text-muted" style="font-size:var(--text-xs)">Usa el seguimiento de tiempo en Resumen para registrar tiempo por tarea.</p>
176
- </div>
177
- `;
178
- }
179
-
180
- // Agrupar por tarea
181
- const byTask = new Map();
182
- for (const e of entries) {
183
- if (!byTask.has(e.taskId)) byTask.set(e.taskId, { taskId: e.taskId, taskTitle: e.taskTitle, total: 0, count: 0 });
184
- const rec = byTask.get(e.taskId);
185
- rec.total += e.durationMs || 0;
186
- rec.count++;
187
- }
188
-
189
- const maxMs = Math.max(...[...byTask.values()].map(r => r.total), 1);
190
-
191
- return `
192
- <div class="bar-chart">
193
- ${[...byTask.values()].slice(0, 10).map(r => {
194
- const pct = Math.max(4, Math.round((r.total / maxMs) * 100));
195
- return `
196
- <div class="bar-row">
197
- <span class="bar-label truncate" title="${esc(r.taskTitle)}">${esc(r.taskTitle)}</span>
198
- <div class="bar-track">
199
- <div class="bar-fill fill-info" style="width:${pct}%"></div>
200
- </div>
201
- <span class="bar-value">${formatDurationShort(r.total)}</span>
202
- </div>
203
- `;
204
- }).join('')}
205
- </div>
206
- <p class="text-muted" style="font-size:var(--text-xs);margin-top:var(--space-3)">
207
- ${entries.length} registros en total
208
- </p>
209
- `;
210
- }
211
-
212
- // ─────────────────────────────── PHASE PROGRESS ─────────────────────────────
213
-
214
- function _renderPhaseProgress(phaseStats) {
215
- if (!phaseStats?.length) {
216
- return '<p class="text-muted" style="font-size:var(--text-sm)">Sin fases configuradas.</p>';
217
- }
218
- return `
219
- <div class="phase-chart">
220
- ${phaseStats.map(p => {
221
- const pct = p.total ? Math.round((p.completed / p.total) * 100) : 0;
222
- const cls = pct === 100 ? 'done' : pct > 0 ? 'active' : 'partial';
223
- return `
224
- <div class="phase-row">
225
- <div class="phase-row-header">
226
- <div>
227
- <span class="phase-name">${esc(p.id)} · ${esc(p.label)}</span>
228
- </div>
229
- <span class="phase-progress">${p.completed}/${p.total} (${pct}%)</span>
230
- </div>
231
- <div class="phase-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
232
- <div class="phase-fill ${cls}" style="width:${pct}%"></div>
233
- </div>
234
- <div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
235
- ${p.remaining === 0
236
- ? `<span class="badge badge-success">Completada</span>`
237
- : `<span class="badge badge-muted">${p.remaining} pendientes</span>`}
238
- </div>
239
- </div>
240
- `;
241
- }).join('')}
242
- </div>
243
- `;
244
- }
245
-
246
- // ─────────────────────────────── ACTIVITY ───────────────────────────────────
247
-
248
- function _renderActivityTimeline(history, statusLabels) {
249
- if (!history.length) {
250
- return '<div class="empty-state" style="padding:var(--space-5)">Sin actividad reciente.</div>';
251
- }
252
-
253
- const actionIcon = {
254
- create: 'plus',
255
- start: 'play',
256
- review: 'alertCircle',
257
- complete: 'checkCircle',
258
- block: 'shield',
259
- pending: 'clock',
260
- cancel: 'x',
261
- note: 'fileText',
262
- edit: 'edit',
263
- };
264
-
265
- const actionColor = {
266
- create: 'var(--accent)',
267
- start: 'var(--info)',
268
- review: 'var(--warning)',
269
- complete: 'var(--success)',
270
- block: 'var(--danger)',
271
- cancel: 'var(--text-muted)',
272
- note: 'var(--accent)',
273
- edit: 'var(--text-secondary)',
274
- pending: 'var(--warning)',
275
- };
276
-
277
- return `
278
- <div class="stack" style="max-height:420px;overflow-y:auto">
279
- ${history.map(h => {
280
- const ic = actionIcon[h.action] || 'info';
281
- const col = actionColor[h.action] || 'var(--accent)';
282
- return `
283
- <div class="activity-item">
284
- <div class="activity-icon" style="color:${col}">
285
- ${icon(ic, 16)}
286
- </div>
287
- <div class="activity-content">
288
- <p class="activity-action">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
289
- <p class="activity-task">${esc(h.taskTitle)} (${esc(h.taskId)})</p>
290
- <p class="activity-time">${formatDate(h.at)}</p>
291
- </div>
292
- </div>
293
- `;
294
- }).join('')}
295
- </div>
296
- `;
297
- }
298
-
299
- // ─────────────────────────────── FINDINGS ───────────────────────────────────
300
-
301
- function _renderFindings(open, resolved) {
302
- if (!open.length && !resolved.length) {
303
- return `<div class="empty-state" style="padding:var(--space-4)">${icon('checkCircle', 20)} Sin hallazgos.</div>`;
304
- }
305
-
306
- return `
307
- <div class="stack stack-sm">
308
- ${open.map(f => `
309
- <div class="finding-item severity-${(f.severity || 'medium').toLowerCase()}">
310
- <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-1)">
311
- <p style="font-size:var(--text-sm);font-weight:700">${esc(f.title)}</p>
312
- <span class="badge badge-warning">${esc(f.severity?.toUpperCase() || 'MEDIUM')}</span>
313
- </div>
314
- <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(f.detail || '')}</p>
315
- ${f.impact ? `<p style="font-size:var(--text-xs);color:var(--text-muted);margin-top:var(--space-1)">Impacto: ${esc(f.impact)}</p>` : ''}
316
- </div>
317
- `).join('')}
318
- ${resolved.length > 0 ? `<p class="label-sm" style="margin-top:var(--space-3)">${resolved.length} resueltos</p>` : ''}
319
- </div>
320
- `;
321
- }
322
-
323
- // ─────────────────────────────── DECISIONES ─────────────────────────────────
324
-
325
- function _renderDecisions(decisions) {
326
- return `
327
- <div class="stack stack-sm">
328
- ${decisions.map(d => `
329
- <div class="decision-item">
330
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-1)">
331
- <p style="font-size:var(--text-sm);font-weight:700">${esc(d.title)}</p>
332
- <span class="badge badge-muted">${esc(d.owner)}</span>
333
- </div>
334
- <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(d.impact || '')}</p>
335
- </div>
336
- `).join('')}
337
- </div>
338
- `;
339
- }
1
+ /**
2
+ * insights.js — Vista de Analytics: progreso, actividad, hallazgos, KPI de salud
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as timeTracker from '../time-tracker.js';
8
+ import { esc, formatDate, formatDurationShort, extractHistory } from '../utils.js';
9
+ import { t } from '../i18n.js';
10
+
11
+ export async function render() {
12
+ const payload = state.getPayload();
13
+ if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.insights.noData', {}, 'No project data.')}</div>`;
14
+
15
+ const { derived, control, runtime } = payload;
16
+ const statusLabels = state.getStatusLabels();
17
+ const history = extractHistory(control.tasks).slice(0, 20);
18
+
19
+ return `
20
+ <div class="view-enter">
21
+ <div class="section-header">
22
+ <div class="section-header-left">
23
+ <p class="eyebrow">${t('ui.insights.eyebrow', {}, 'Insights')}</p>
24
+ <h2>${t('ui.insights.title', {}, 'Project insights')}</h2>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Fila 1: Health Grid + Distribution -->
29
+ <div class="grid-2" style="margin-bottom:var(--space-5)">
30
+
31
+ <!-- KPI de salud -->
32
+ <div class="chart-card stagger-1">
33
+ <div class="section-header" style="margin-bottom:var(--space-3)">
34
+ <p class="chart-title">${t('ui.insights.health', {}, 'Operational health')}</p>
35
+ </div>
36
+ ${_renderHealthGrid(derived, runtime, payload.docsDirty)}
37
+ </div>
38
+
39
+ <!-- Distribución por estado -->
40
+ <div class="chart-card stagger-2">
41
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.distribution', {}, 'Distribution by status')}</p>
42
+ ${_renderDistribution(derived.totals, statusLabels)}
43
+ </div>
44
+
45
+ </div>
46
+
47
+ <!-- Fila 2: Time entries + Phase progress -->
48
+ <div class="grid-2" style="margin-bottom:var(--space-5)">
49
+
50
+ <!-- Time tracking summary -->
51
+ <div class="chart-card stagger-3">
52
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
53
+ <p class="chart-title">${t('ui.insights.time', {}, 'Time tracking')}</p>
54
+ <span class="badge badge-accent">${icon('clock', 12)} ${t('ui.insights.today', {}, 'Today')}</span>
55
+ </div>
56
+ ${_renderTimeTracking()}
57
+ </div>
58
+
59
+ <!-- Phase progress detallado -->
60
+ <div class="chart-card stagger-4">
61
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.phaseProgress', {}, 'Progress by phase')}</p>
62
+ ${_renderPhaseProgress(derived.phaseStats)}
63
+ </div>
64
+
65
+ </div>
66
+
67
+ <!-- Fila 3: Activity + Findings -->
68
+ <div class="grid-2">
69
+
70
+ <!-- Activity timeline -->
71
+ <div class="chart-card stagger-1">
72
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.recentActivity', {}, 'Recent activity')}</p>
73
+ ${_renderActivityTimeline(history, statusLabels)}
74
+ </div>
75
+
76
+ <!-- Hallazgos + Decisiones -->
77
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
78
+
79
+ <!-- Findings -->
80
+ <div class="chart-card stagger-2">
81
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
82
+ <p class="chart-title">${t('ui.insights.findings', {}, 'Findings')}</p>
83
+ <span class="badge badge-${derived.openFindings?.length ? 'warning' : 'success'}">
84
+ ${t('ui.insights.openCount', { count: (derived.openFindings || []).length }, `${(derived.openFindings || []).length} open`)}
85
+ </span>
86
+ </div>
87
+ ${_renderFindings(derived.openFindings || [], derived.resolvedFindings || [])}
88
+ </div>
89
+
90
+ <!-- Decisiones pendientes -->
91
+ ${(control.decisionsPending || []).length > 0 ? `
92
+ <div class="chart-card stagger-3">
93
+ <p class="chart-title" style="margin-bottom:var(--space-4)">${t('ui.insights.pendingDecisions', {}, 'Pending decisions')}</p>
94
+ ${_renderDecisions(control.decisionsPending)}
95
+ </div>
96
+ ` : ''}
97
+
98
+ </div>
99
+ </div>
100
+ </div>
101
+ `;
102
+ }
103
+
104
+ // ─────────────────────────────── HEALTH ─────────────────────────────────────
105
+
106
+ function _renderHealthGrid(derived, runtime, docsDirty) {
107
+ const totals = derived.totals;
108
+ const completionRate = totals.all ? Math.round((totals.completed / totals.all) * 100) : 0;
109
+ const blockerRate = totals.all ? Math.round((totals.blocked / totals.all) * 100) : 0;
110
+ const openFindings = (derived.openFindings || []).length;
111
+
112
+ const items = [
113
+ { label: 'Tasa completada', value: `${completionRate}%`, cls: completionRate >= 75 ? 'good' : completionRate >= 40 ? '' : 'bad' },
114
+ { label: 'Presión de bloqueos',value: `${blockerRate}%`, cls: blockerRate === 0 ? 'good' : blockerRate > 20 ? 'bad' : 'warn' },
115
+ { label: 'En Progreso', value: String(totals.inProgress),cls: totals.inProgress > 0 ? 'good' : '' },
116
+ { label: 'En Revisión', value: String(totals.inReview), cls: '' },
117
+ { label: 'Hallazgos abiertos', value: String(openFindings), cls: openFindings === 0 ? 'good' : 'warn' },
118
+ { label: 'Desfase documental', value: (docsDirty || []).length ? `${(docsDirty).length} archivos` : 'OK', cls: docsDirty?.length ? 'warn' : 'good' },
119
+ { label: 'Por delante remoto', value: String(runtime?.ahead || 0), cls: runtime?.ahead > 0 ? 'warn' : 'good' },
120
+ { label: 'Por detrás remoto', value: String(runtime?.behind || 0), cls: runtime?.behind > 0 ? 'bad' : 'good' },
121
+ ];
122
+
123
+ return `
124
+ <div class="health-grid">
125
+ ${items.map(({ label, value, cls }) => `
126
+ <div class="health-card">
127
+ <p class="health-card-label">${esc(label)}</p>
128
+ <p class="health-card-value ${cls}" title="${esc(value)}">${esc(value)}</p>
129
+ </div>
130
+ `).join('')}
131
+ </div>
132
+ `;
133
+ }
134
+
135
+ // ─────────────────────────────── DISTRIBUCIÓN ───────────────────────────────
136
+
137
+ function _renderDistribution(totals, statusLabels) {
138
+ const all = totals.all || 1;
139
+ const rows = [
140
+ { id: 'completed', label: statusLabels.completed || 'Completado', value: totals.completed, cls: 'fill-success' },
141
+ { id: 'in_progress',label: statusLabels.in_progress|| 'En progreso', value: totals.inProgress, cls: '' },
142
+ { id: 'in_review', label: statusLabels.in_review || 'En revisión', value: totals.inReview, cls: '' },
143
+ { id: 'pending', label: statusLabels.pending || 'Pendiente', value: totals.pending, cls: 'fill-warning' },
144
+ { id: 'blocked', label: statusLabels.blocked || 'Bloqueado', value: totals.blocked, cls: 'fill-danger' },
145
+ { id: 'cancelled', label: statusLabels.cancelled || 'Cancelado', value: totals.cancelled, cls: '' },
146
+ ].filter(r => r.value > 0);
147
+
148
+ return `
149
+ <div class="bar-chart">
150
+ ${rows.map(r => {
151
+ const pct = Math.max(2, Math.round((r.value / all) * 100));
152
+ return `
153
+ <div class="bar-row" role="group" aria-label="${r.label}: ${r.value} (${pct}%)">
154
+ <span class="bar-label truncate">${esc(r.label)}</span>
155
+ <div class="bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
156
+ <div class="bar-fill ${r.cls}" style="width:${pct}%"></div>
157
+ </div>
158
+ <span class="bar-value">${r.value}</span>
159
+ </div>
160
+ `;
161
+ }).join('')}
162
+ </div>
163
+ `;
164
+ }
165
+
166
+ // ─────────────────────────────── TIME TRACKING ──────────────────────────────
167
+
168
+ function _renderTimeTracking() {
169
+ const entries = state.get('timeEntries');
170
+
171
+ if (!entries.length) {
172
+ return `
173
+ <div class="empty-state" style="padding:var(--space-6)">
174
+ ${icon('clock', 24)}
175
+ <p>No hay registros de tiempo aún.</p>
176
+ <p class="text-muted" style="font-size:var(--text-xs)">Usa el seguimiento de tiempo en Resumen para registrar tiempo por tarea.</p>
177
+ </div>
178
+ `;
179
+ }
180
+
181
+ // Agrupar por tarea
182
+ const byTask = new Map();
183
+ for (const e of entries) {
184
+ if (!byTask.has(e.taskId)) byTask.set(e.taskId, { taskId: e.taskId, taskTitle: e.taskTitle, total: 0, count: 0 });
185
+ const rec = byTask.get(e.taskId);
186
+ rec.total += e.durationMs || 0;
187
+ rec.count++;
188
+ }
189
+
190
+ const maxMs = Math.max(...[...byTask.values()].map(r => r.total), 1);
191
+
192
+ return `
193
+ <div class="bar-chart">
194
+ ${[...byTask.values()].slice(0, 10).map(r => {
195
+ const pct = Math.max(4, Math.round((r.total / maxMs) * 100));
196
+ return `
197
+ <div class="bar-row">
198
+ <span class="bar-label truncate" title="${esc(r.taskTitle)}">${esc(r.taskTitle)}</span>
199
+ <div class="bar-track">
200
+ <div class="bar-fill fill-info" style="width:${pct}%"></div>
201
+ </div>
202
+ <span class="bar-value">${formatDurationShort(r.total)}</span>
203
+ </div>
204
+ `;
205
+ }).join('')}
206
+ </div>
207
+ <p class="text-muted" style="font-size:var(--text-xs);margin-top:var(--space-3)">
208
+ ${entries.length} registros en total
209
+ </p>
210
+ `;
211
+ }
212
+
213
+ // ─────────────────────────────── PHASE PROGRESS ─────────────────────────────
214
+
215
+ function _renderPhaseProgress(phaseStats) {
216
+ if (!phaseStats?.length) {
217
+ return '<p class="text-muted" style="font-size:var(--text-sm)">Sin fases configuradas.</p>';
218
+ }
219
+ return `
220
+ <div class="phase-chart">
221
+ ${phaseStats.map(p => {
222
+ const pct = p.total ? Math.round((p.completed / p.total) * 100) : 0;
223
+ const cls = pct === 100 ? 'done' : pct > 0 ? 'active' : 'partial';
224
+ return `
225
+ <div class="phase-row">
226
+ <div class="phase-row-header">
227
+ <div>
228
+ <span class="phase-name">${esc(p.id)} · ${esc(p.label)}</span>
229
+ </div>
230
+ <span class="phase-progress">${p.completed}/${p.total} (${pct}%)</span>
231
+ </div>
232
+ <div class="phase-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
233
+ <div class="phase-fill ${cls}" style="width:${pct}%"></div>
234
+ </div>
235
+ <div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
236
+ ${p.remaining === 0
237
+ ? `<span class="badge badge-success">Completada</span>`
238
+ : `<span class="badge badge-muted">${p.remaining} pendientes</span>`}
239
+ </div>
240
+ </div>
241
+ `;
242
+ }).join('')}
243
+ </div>
244
+ `;
245
+ }
246
+
247
+ // ─────────────────────────────── ACTIVITY ───────────────────────────────────
248
+
249
+ function _renderActivityTimeline(history, statusLabels) {
250
+ if (!history.length) {
251
+ return '<div class="empty-state" style="padding:var(--space-5)">Sin actividad reciente.</div>';
252
+ }
253
+
254
+ const actionIcon = {
255
+ create: 'plus',
256
+ start: 'play',
257
+ review: 'alertCircle',
258
+ complete: 'checkCircle',
259
+ block: 'shield',
260
+ pending: 'clock',
261
+ cancel: 'x',
262
+ note: 'fileText',
263
+ edit: 'edit',
264
+ };
265
+
266
+ const actionColor = {
267
+ create: 'var(--accent)',
268
+ start: 'var(--info)',
269
+ review: 'var(--warning)',
270
+ complete: 'var(--success)',
271
+ block: 'var(--danger)',
272
+ cancel: 'var(--text-muted)',
273
+ note: 'var(--accent)',
274
+ edit: 'var(--text-secondary)',
275
+ pending: 'var(--warning)',
276
+ };
277
+
278
+ return `
279
+ <div class="stack" style="max-height:420px;overflow-y:auto">
280
+ ${history.map(h => {
281
+ const ic = actionIcon[h.action] || 'info';
282
+ const col = actionColor[h.action] || 'var(--accent)';
283
+ return `
284
+ <div class="activity-item">
285
+ <div class="activity-icon" style="color:${col}">
286
+ ${icon(ic, 16)}
287
+ </div>
288
+ <div class="activity-content">
289
+ <p class="activity-action">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
290
+ <p class="activity-task">${esc(h.taskTitle)} (${esc(h.taskId)})</p>
291
+ <p class="activity-time">${formatDate(h.at)}</p>
292
+ </div>
293
+ </div>
294
+ `;
295
+ }).join('')}
296
+ </div>
297
+ `;
298
+ }
299
+
300
+ // ─────────────────────────────── FINDINGS ───────────────────────────────────
301
+
302
+ function _renderFindings(open, resolved) {
303
+ if (!open.length && !resolved.length) {
304
+ return `<div class="empty-state" style="padding:var(--space-4)">${icon('checkCircle', 20)} Sin hallazgos.</div>`;
305
+ }
306
+
307
+ return `
308
+ <div class="stack stack-sm">
309
+ ${open.map(f => `
310
+ <div class="finding-item severity-${(f.severity || 'medium').toLowerCase()}">
311
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:var(--space-1)">
312
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(f.title)}</p>
313
+ <span class="badge badge-warning">${esc(f.severity?.toUpperCase() || 'MEDIUM')}</span>
314
+ </div>
315
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(f.detail || '')}</p>
316
+ ${f.impact ? `<p style="font-size:var(--text-xs);color:var(--text-muted);margin-top:var(--space-1)">Impacto: ${esc(f.impact)}</p>` : ''}
317
+ </div>
318
+ `).join('')}
319
+ ${resolved.length > 0 ? `<p class="label-sm" style="margin-top:var(--space-3)">${resolved.length} resueltos</p>` : ''}
320
+ </div>
321
+ `;
322
+ }
323
+
324
+ // ─────────────────────────────── DECISIONES ─────────────────────────────────
325
+
326
+ function _renderDecisions(decisions) {
327
+ return `
328
+ <div class="stack stack-sm">
329
+ ${decisions.map(d => `
330
+ <div class="decision-item">
331
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-1)">
332
+ <p style="font-size:var(--text-sm);font-weight:700">${esc(d.title)}</p>
333
+ <span class="badge badge-muted">${esc(d.owner)}</span>
334
+ </div>
335
+ <p style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(d.impact || '')}</p>
336
+ </div>
337
+ `).join('')}
338
+ </div>
339
+ `;
340
+ }