trackops 1.0.0 → 1.0.1

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.
@@ -0,0 +1,364 @@
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
+
62
+ </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
+
345
+ 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: '' },
352
+ ];
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
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * settings.js — Vista de configuración del proyecto
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as api from '../api.js';
8
+ import { flash } from './flash.js';
9
+ import { esc, formatDate } from '../utils.js';
10
+
11
+ export async function render() {
12
+ const payload = state.getPayload();
13
+ const projects = state.get('projects');
14
+ const currentId = state.get('currentProjectId');
15
+ const control = payload?.control;
16
+ const runtime = payload?.runtime;
17
+ const docsDirty = payload?.docsDirty || [];
18
+
19
+ return `
20
+ <div class="view-enter">
21
+ <div class="section-header">
22
+ <div class="section-header-left">
23
+ <p class="eyebrow">Configuración</p>
24
+ <h2>Configuración del Proyecto</h2>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="grid-2" style="align-items:start">
29
+
30
+ <!-- Col izquierda -->
31
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
32
+
33
+ <!-- Info del proyecto activo -->
34
+ ${control ? `
35
+ <div class="panel">
36
+ <div class="panel-header">
37
+ <p class="panel-title">Proyecto Activo</p>
38
+ <span class="badge badge-success">Activo</span>
39
+ </div>
40
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
41
+ <div class="info-row">
42
+ <p class="label-sm">Nombre</p>
43
+ <p class="value">${esc(control.meta?.projectName || '—')}</p>
44
+ </div>
45
+ <div class="info-row">
46
+ <p class="label-sm">Ruta</p>
47
+ <p class="value">${esc(payload?.project?.root || '—')}</p>
48
+ </div>
49
+ <div class="info-row">
50
+ <p class="label-sm">Fase activa</p>
51
+ <p class="value">${esc(payload?.derived?.activePhase?.id || '—')} — ${esc(payload?.derived?.activePhase?.label || '')}</p>
52
+ </div>
53
+ <div class="info-row">
54
+ <p class="label-sm">Foco actual</p>
55
+ <p class="value">${esc(control.meta?.currentFocus || '—')}</p>
56
+ </div>
57
+ <div class="info-row">
58
+ <p class="label-sm">Entrega objetivo</p>
59
+ <p class="value">${esc(control.meta?.deliveryTarget || '—')}</p>
60
+ </div>
61
+ <div class="info-row">
62
+ <p class="label-sm">Última actualización</p>
63
+ <p class="value">${formatDate(control.meta?.updatedAt || '', 'date')}</p>
64
+ </div>
65
+ <div class="info-row">
66
+ <p class="label-sm">Idioma</p>
67
+ <p class="value">${esc(payload?.i18n?.locale || 'es')}</p>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ ` : '<p class="text-muted">Sin proyecto cargado.</p>'}
72
+
73
+ <!-- Estado del Repo -->
74
+ ${runtime ? `
75
+ <div class="panel">
76
+ <div class="panel-header">
77
+ <p class="panel-title">Repositorio</p>
78
+ <span class="badge badge-${runtime.clean ? 'success' : 'warning'}">${runtime.clean ? 'Limpio' : 'Con cambios'}</span>
79
+ </div>
80
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
81
+ <div class="info-row">
82
+ <p class="label-sm">Rama</p>
83
+ <p class="value">${esc(runtime.branch || '—')}</p>
84
+ </div>
85
+ <div class="info-row">
86
+ <p class="label-sm">Estado</p>
87
+ <p class="value">${runtime.staged}s ${runtime.unstaged}u ${runtime.untracked}?</p>
88
+ </div>
89
+ ${runtime.lastCommit ? `
90
+ <div class="info-row">
91
+ <p class="label-sm">Último commit</p>
92
+ <p class="value">${esc(runtime.lastCommit.shortHash)} · ${esc(runtime.lastCommit.subject)}</p>
93
+ </div>
94
+ <div class="info-row">
95
+ <p class="label-sm">Fecha commit</p>
96
+ <p class="value">${formatDate(runtime.lastCommit.date, 'date')}</p>
97
+ </div>
98
+ ` : ''}
99
+ <div class="info-row">
100
+ <p class="label-sm">Divergencia</p>
101
+ <p class="value">↑ ${runtime.ahead || 0} por delante · ↓ ${runtime.behind || 0} por detrás</p>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ ` : ''}
106
+
107
+ </div>
108
+
109
+ <!-- Col derecha -->
110
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
111
+
112
+ <!-- Portfolio -->
113
+ <div class="panel">
114
+ <div class="panel-header">
115
+ <p class="panel-title">Portfolio de Proyectos</p>
116
+ <span class="badge badge-muted">${projects.length}</span>
117
+ </div>
118
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
119
+ ${projects.length === 0
120
+ ? '<div class="empty-state">Sin proyectos registrados.</div>'
121
+ : projects.map(p => `
122
+ <div class="project-row ${p.id === currentId ? 'is-active' : ''}">
123
+ <div class="project-row-info">
124
+ <p class="project-name">${esc(p.name)}</p>
125
+ <p class="project-path">${esc(p.root)}</p>
126
+ </div>
127
+ <div class="project-row-actions">
128
+ ${p.available
129
+ ? `<span class="badge badge-success">Disponible</span>`
130
+ : `<span class="badge badge-warning" title="No se puede cargar el control">No disponible</span>`}
131
+ ${p.id === currentId
132
+ ? `<span class="badge badge-accent">Activo</span>`
133
+ : p.available ? `<button class="btn btn-ghost btn-sm" type="button" data-switch="${esc(p.id)}">Abrir</button>` : ''
134
+ }
135
+ </div>
136
+ </div>
137
+ `).join('')
138
+ }
139
+
140
+ <!-- Registrar nuevo -->
141
+ <div style="margin-top:var(--space-2)">
142
+ <div class="field">
143
+ <label for="new-project-path">Registrar proyecto existente</label>
144
+ <input id="new-project-path" type="text" placeholder="/ruta/al/proyecto" />
145
+ </div>
146
+ <div class="form-actions" style="margin-top:var(--space-2)">
147
+ <button class="btn btn-ghost btn-sm" type="button" id="register-project-btn">
148
+ ${icon('plus', 14)} Registrar
149
+ </button>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Docs Drift -->
156
+ <div class="panel">
157
+ <div class="panel-header">
158
+ <p class="panel-title">Documentación</p>
159
+ <span class="badge badge-${docsDirty.length ? 'warning' : 'success'}">${docsDirty.length ? `${docsDirty.length} desfasados` : 'Sincronizados'}</span>
160
+ </div>
161
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
162
+ ${docsDirty.length > 0 ? `
163
+ <p style="font-size:var(--text-sm);color:var(--text-secondary)">Los siguientes archivos de documentación están desactualizados:</p>
164
+ <div class="stack stack-sm">
165
+ ${docsDirty.map(d => `
166
+ <div class="info-row"><p class="value">${esc(d)}</p></div>
167
+ `).join('')}
168
+ </div>
169
+ ` : `
170
+ <p style="font-size:var(--text-sm);color:var(--success)">✓ Todos los archivos de documentación están sincronizados.</p>
171
+ `}
172
+ <button class="btn btn-ghost btn-sm" type="button" id="sync-docs-btn">
173
+ ${icon('sync', 14)} Sincronizar ahora
174
+ </button>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Milestones -->
179
+ ${control?.milestones?.length ? `
180
+ <div class="panel">
181
+ <div class="panel-header">
182
+ <p class="panel-title">Milestones</p>
183
+ <span class="badge badge-accent">${control.milestones.length}</span>
184
+ </div>
185
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
186
+ ${control.milestones.map(m => `
187
+ <div class="finding-item" style="border-left-color:var(--accent)">
188
+ <p style="font-weight:700;font-size:var(--text-sm)">${esc(m.title)}</p>
189
+ <p style="font-size:var(--text-xs);color:var(--text-muted)">${esc(m.date)}</p>
190
+ <ul style="margin-top:var(--space-2);padding-left:1rem">
191
+ ${(m.items || []).map(item => `<li style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(item)}</li>`).join('')}
192
+ </ul>
193
+ </div>
194
+ `).join('')}
195
+ </div>
196
+ </div>
197
+ ` : ''}
198
+
199
+ </div>
200
+ </div>
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ export function bindEvents() {
206
+ // Switch project
207
+ document.querySelectorAll('[data-switch]').forEach(btn => {
208
+ btn.addEventListener('click', () => {
209
+ const id = btn.dataset.switch;
210
+ state.update('currentProjectId', id);
211
+ localStorage.setItem('ops-dashboard-project', id);
212
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
213
+ });
214
+ });
215
+
216
+ // Registrar proyecto
217
+ document.getElementById('register-project-btn')?.addEventListener('click', async () => {
218
+ const input = document.getElementById('new-project-path');
219
+ const root = input?.value.trim();
220
+ if (!root) { flash('Introduce la ruta del proyecto.', 'warning'); return; }
221
+ try {
222
+ await api.registerProject(root);
223
+ flash('Proyecto registrado.', 'success');
224
+ if (input) input.value = '';
225
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
226
+ } catch (err) {
227
+ flash(err.message, 'error');
228
+ }
229
+ });
230
+
231
+ // Sync docs
232
+ document.getElementById('sync-docs-btn')?.addEventListener('click', async () => {
233
+ try {
234
+ await api.syncDocs();
235
+ flash('Documentación sincronizada.', 'success');
236
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
237
+ } catch (err) {
238
+ flash(err.message, 'error');
239
+ }
240
+ });
241
+ }
242
+
243
+ export { bindEvents as bind };