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.
- package/README.md +59 -6
- package/lib/control.js +1 -0
- package/lib/init.js +12 -10
- package/lib/opera.js +3 -3
- package/lib/server.js +141 -0
- package/lib/skills.js +1 -1
- package/package.json +16 -3
- package/templates/etapa/agent.md +2 -2
- package/templates/etapa/references/etapa-cycle.md +1 -1
- package/templates/opera/agent.md +1 -1
- package/templates/skills/project-starter-skill/SKILL.md +5 -3
- package/ui/css/base.css +266 -0
- package/ui/css/charts.css +327 -0
- package/ui/css/components.css +570 -0
- package/ui/css/panels.css +953 -0
- package/ui/css/tokens.css +227 -0
- package/ui/favicon.svg +5 -0
- package/ui/index.html +91 -351
- package/ui/js/api.js +203 -0
- package/ui/js/app.js +199 -0
- package/ui/js/console-logger.js +172 -0
- package/ui/js/icons.js +104 -0
- package/ui/js/onboarding.js +437 -0
- package/ui/js/router.js +125 -0
- package/ui/js/state.js +129 -0
- package/ui/js/theme.js +100 -0
- package/ui/js/time-tracker.js +248 -0
- package/ui/js/utils.js +172 -0
- package/ui/js/views/board.js +254 -0
- package/ui/js/views/execution.js +256 -0
- package/ui/js/views/flash.js +47 -0
- package/ui/js/views/insights.js +339 -0
- package/ui/js/views/overview.js +364 -0
- package/ui/js/views/settings.js +243 -0
- package/ui/js/views/sidebar.js +132 -0
- package/ui/js/views/skills.js +162 -0
- package/ui/js/views/tasks.js +405 -0
- package/ui/js/views/topbar.js +183 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* onboarding.js — Tour interactivo tipo spotlight
|
|
3
|
+
*
|
|
4
|
+
* Técnica: un div con box-shadow gigante rodea el target,
|
|
5
|
+
* dejando solo ese elemento visible. El tooltip-bocadillo
|
|
6
|
+
* tiene una flecha CSS que apunta directamente al target.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as state from './state.js';
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'trackops-onboarding-v2';
|
|
12
|
+
|
|
13
|
+
/* ── PASOS ──────────────────────────────────────────────────────────────── */
|
|
14
|
+
|
|
15
|
+
const STEPS = [
|
|
16
|
+
// 0 — Bienvenida (sin target)
|
|
17
|
+
{
|
|
18
|
+
title: '¡Bienvenido a TrackOps!',
|
|
19
|
+
desc: 'Tu motor operativo local para desarrolladores. Vamos a hacer un tour rápido — solo tarda 2 minutos.',
|
|
20
|
+
target: null,
|
|
21
|
+
view: null,
|
|
22
|
+
pos: 'center',
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// 1 — Sidebar / navegación
|
|
26
|
+
{
|
|
27
|
+
title: 'Navegación principal',
|
|
28
|
+
desc: 'La barra lateral te lleva entre las vistas principales: resumen, tareas, tablero, ejecución y analíticas. El indicador naranja marca tareas pendientes.',
|
|
29
|
+
target: '#sidebar',
|
|
30
|
+
view: 'overview',
|
|
31
|
+
pos: 'right',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// 2 — KPI cards
|
|
35
|
+
{
|
|
36
|
+
title: 'Métricas de un vistazo',
|
|
37
|
+
desc: 'Cuatro KPI cards muestran en tiempo real: trabajo abierto, completado, bloqueado y en revisión. Los colores del borde superior indican el estado.',
|
|
38
|
+
target: '.kpi-grid',
|
|
39
|
+
view: 'overview',
|
|
40
|
+
pos: 'bottom',
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// 3 — Gráfico de actividad
|
|
44
|
+
{
|
|
45
|
+
title: 'Actividad semanal',
|
|
46
|
+
desc: 'El gráfico de barras muestra los cambios de estado de los últimos 10 días. Identifica picos de trabajo y periodos de inactividad.',
|
|
47
|
+
target: '.chart-card',
|
|
48
|
+
view: 'overview',
|
|
49
|
+
pos: 'bottom',
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// 4 — Donut de progreso
|
|
53
|
+
{
|
|
54
|
+
title: 'Progreso Global',
|
|
55
|
+
desc: 'El donut SVG desglosa el estado de todas las tareas: completadas (verde), en progreso (azul), bloqueadas (rojo) y pendientes (naranja).',
|
|
56
|
+
target: '.donut-wrapper',
|
|
57
|
+
view: 'overview',
|
|
58
|
+
pos: 'left',
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// 5 — Time Tracker
|
|
62
|
+
{
|
|
63
|
+
title: 'Seguimiento de tiempo',
|
|
64
|
+
desc: 'Registra el tiempo dedicado a cada tarea. Pulsa iniciar, trabaja y luego detén el contador. La duración queda guardada para análisis posterior.',
|
|
65
|
+
target: '.time-tracker-card',
|
|
66
|
+
view: 'overview',
|
|
67
|
+
pos: 'top',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// 6 — Topbar: búsqueda
|
|
71
|
+
{
|
|
72
|
+
title: 'Búsqueda global',
|
|
73
|
+
desc: 'Filtra tareas en tiempo real desde cualquier vista con ⌘/Ctrl+F. El selector de proyecto te permite cambiar entre proyectos registrados.',
|
|
74
|
+
target: '.topbar-search',
|
|
75
|
+
view: 'overview',
|
|
76
|
+
pos: 'bottom',
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// 7 — Board (Kanban)
|
|
80
|
+
{
|
|
81
|
+
title: 'Tablero Kanban',
|
|
82
|
+
desc: 'Arrastra las tarjetas entre columnas para cambiar el estado de una tarea. Cada movimiento actualiza el historial y regenera los docs Markdown.',
|
|
83
|
+
target: '.board-grid',
|
|
84
|
+
view: 'board',
|
|
85
|
+
pos: 'top',
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// 8 — Editor de tareas
|
|
89
|
+
{
|
|
90
|
+
title: 'Editor de Tareas',
|
|
91
|
+
desc: 'Selecciona cualquier tarea para editarla en el panel derecho. Puedes cambiar prioridad, fase, criterios de aceptación y usar el action strip para transicionar estados.',
|
|
92
|
+
target: '.task-list',
|
|
93
|
+
view: 'tasks',
|
|
94
|
+
pos: 'right',
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// 9 — Ejecución / Consola
|
|
98
|
+
{
|
|
99
|
+
title: 'Consola Integrada',
|
|
100
|
+
desc: 'Ejecuta comandos de terminal directamente desde el dashboard. Los Quick Commands incluyen los flujos más comunes: status, sync, git log…',
|
|
101
|
+
target: '.terminal-surface',
|
|
102
|
+
view: 'execution',
|
|
103
|
+
pos: 'top',
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// 10 — Analytics
|
|
107
|
+
{
|
|
108
|
+
title: 'Analíticas del proyecto',
|
|
109
|
+
desc: 'Esta vista reúne salud operativa, distribución por estado, tiempo por tarea, progreso por fase y la actividad reciente.',
|
|
110
|
+
target: '.health-grid',
|
|
111
|
+
view: 'insights',
|
|
112
|
+
pos: 'bottom',
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// 11 — AI Skill Hub
|
|
116
|
+
{
|
|
117
|
+
title: 'Centro de habilidades',
|
|
118
|
+
desc: 'Tu copiloto evoluciona. Descubre e instala nuevas habilidades específicas para tu contexto a través del repositorio integrado de la comunidad.',
|
|
119
|
+
target: '#view-skills',
|
|
120
|
+
view: 'skills',
|
|
121
|
+
pos: 'right',
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// 12 — Theme toggle
|
|
125
|
+
{
|
|
126
|
+
title: 'Tema claro / oscuro',
|
|
127
|
+
desc: 'Alterna entre tema claro y oscuro con un clic. Tu preferencia se guarda automáticamente y respeta la configuración del sistema operativo.',
|
|
128
|
+
target: '#theme-toggle-btn',
|
|
129
|
+
view: 'overview',
|
|
130
|
+
pos: 'bottom',
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// 13 — Fin
|
|
134
|
+
{
|
|
135
|
+
title: '¡Todo listo!',
|
|
136
|
+
desc: 'Puedes relanzar este tour en cualquier momento desde "Ayuda & Tour" en la parte inferior del sidebar.',
|
|
137
|
+
target: null,
|
|
138
|
+
view: null,
|
|
139
|
+
pos: 'center',
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/* ── ESTADO ──────────────────────────────────────────────────────────────── */
|
|
144
|
+
|
|
145
|
+
let _step = 0;
|
|
146
|
+
let _spotlight = null;
|
|
147
|
+
let _tooltip = null;
|
|
148
|
+
let _active = false;
|
|
149
|
+
let _ring = null;
|
|
150
|
+
let _escHandler = null;
|
|
151
|
+
|
|
152
|
+
/* ── API PÚBLICA ─────────────────────────────────────────────────────────── */
|
|
153
|
+
|
|
154
|
+
export function init() {
|
|
155
|
+
_spotlight = document.getElementById('onboarding-spotlight');
|
|
156
|
+
_tooltip = document.getElementById('onboarding-tooltip');
|
|
157
|
+
if (!_spotlight || !_tooltip) return;
|
|
158
|
+
|
|
159
|
+
_bindStaticEvents();
|
|
160
|
+
|
|
161
|
+
if (!localStorage.getItem(STORAGE_KEY)) {
|
|
162
|
+
setTimeout(show, 900);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function show() {
|
|
167
|
+
_step = 0;
|
|
168
|
+
_active = true;
|
|
169
|
+
_spotlight.classList.remove('is-hidden');
|
|
170
|
+
_tooltip.classList.remove('is-hidden');
|
|
171
|
+
_renderStep();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function finish() {
|
|
175
|
+
_active = false;
|
|
176
|
+
localStorage.setItem(STORAGE_KEY, '1');
|
|
177
|
+
_spotlight.classList.add('is-hidden');
|
|
178
|
+
_tooltip.classList.add('is-hidden');
|
|
179
|
+
_clearRing();
|
|
180
|
+
_clearSpotlight();
|
|
181
|
+
if (_escHandler) {
|
|
182
|
+
document.removeEventListener('keydown', _escHandler);
|
|
183
|
+
_escHandler = null;
|
|
184
|
+
}
|
|
185
|
+
state.update('onboardingDone', true);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function reset() {
|
|
189
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ── RENDERIZADO ─────────────────────────────────────────────────────────── */
|
|
193
|
+
|
|
194
|
+
async function _renderStep() {
|
|
195
|
+
if (!_active) return;
|
|
196
|
+
const step = STEPS[_step];
|
|
197
|
+
const total = STEPS.length;
|
|
198
|
+
const isLast = _step === total - 1;
|
|
199
|
+
const isFirst = _step === 0;
|
|
200
|
+
|
|
201
|
+
// Navegar a la vista correcta antes de medir el target
|
|
202
|
+
if (step.view) {
|
|
203
|
+
const router = await import('./router.js');
|
|
204
|
+
if (router.current() !== step.view) {
|
|
205
|
+
await router.navigate(step.view);
|
|
206
|
+
// Esperar al siguiente frame para que el DOM esté pintado
|
|
207
|
+
await _nextFrame(120);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Actualizar contenido del tooltip
|
|
212
|
+
document.getElementById('ob-step-label').textContent = `Paso ${_step + 1} de ${total}`;
|
|
213
|
+
document.getElementById('ob-title').textContent = step.title;
|
|
214
|
+
document.getElementById('ob-desc').textContent = step.desc;
|
|
215
|
+
document.getElementById('ob-next').textContent = isLast ? '¡Empezar!' : 'Siguiente →';
|
|
216
|
+
document.getElementById('ob-prev').style.visibility = isFirst ? 'hidden' : 'visible';
|
|
217
|
+
|
|
218
|
+
// Dots
|
|
219
|
+
const dotsEl = document.getElementById('ob-dots');
|
|
220
|
+
if (dotsEl) {
|
|
221
|
+
dotsEl.innerHTML = STEPS.map((_, i) =>
|
|
222
|
+
`<span class="ob-dot ${i === _step ? 'is-active' : ''}" aria-hidden="true"></span>`
|
|
223
|
+
).join('');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Mostrar tooltip con animación de entrada oculta temporalmente (para no saltar)
|
|
227
|
+
_tooltip.classList.remove('ob-enter');
|
|
228
|
+
_clearRing();
|
|
229
|
+
|
|
230
|
+
// Posicionar
|
|
231
|
+
if (step.target) {
|
|
232
|
+
const targetEl = _findTarget(step.target, step.view);
|
|
233
|
+
if (targetEl) {
|
|
234
|
+
// Hacer scroll suave para centrar el elemento en el viewport
|
|
235
|
+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
236
|
+
await _nextFrame(400); // Esperar que termine el scroll
|
|
237
|
+
_applySpotlight(targetEl);
|
|
238
|
+
_applyRing(targetEl);
|
|
239
|
+
_positionTooltip(targetEl, step.pos);
|
|
240
|
+
_mostrarTooltip();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Sin target → centrado
|
|
246
|
+
_clearSpotlight();
|
|
247
|
+
_positionCenter();
|
|
248
|
+
_mostrarTooltip();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _mostrarTooltip() {
|
|
252
|
+
void _tooltip.offsetWidth; // reflow
|
|
253
|
+
_tooltip.classList.add('ob-enter');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ── SPOTLIGHT ───────────────────────────────────────────────────────────── */
|
|
257
|
+
|
|
258
|
+
function _applySpotlight(el) {
|
|
259
|
+
const PADDING = 10;
|
|
260
|
+
const rect = el.getBoundingClientRect();
|
|
261
|
+
|
|
262
|
+
// El spotlight es el propio elemento; usamos box-shadow inmenso para oscurecer el resto
|
|
263
|
+
_spotlight.style.cssText = `
|
|
264
|
+
position: fixed;
|
|
265
|
+
left: ${rect.left - PADDING}px;
|
|
266
|
+
top: ${rect.top - PADDING}px;
|
|
267
|
+
width: ${rect.width + PADDING * 2}px;
|
|
268
|
+
height: ${rect.height + PADDING * 2}px;
|
|
269
|
+
border-radius: 12px;
|
|
270
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
|
|
271
|
+
z-index: var(--z-onboard);
|
|
272
|
+
pointer-events: none;
|
|
273
|
+
transition: all 280ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
274
|
+
`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function _clearSpotlight() {
|
|
278
|
+
_spotlight.style.cssText = `
|
|
279
|
+
position: fixed;
|
|
280
|
+
inset: 0;
|
|
281
|
+
box-shadow: 0 0 0 9999px rgba(0,0,0,0.72);
|
|
282
|
+
z-index: var(--z-onboard);
|
|
283
|
+
pointer-events: none;
|
|
284
|
+
background: transparent;
|
|
285
|
+
border-radius: 0;
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── RING ANIMADO alrededor del target ───────────────────────────────────── */
|
|
290
|
+
|
|
291
|
+
function _applyRing(el) {
|
|
292
|
+
_clearRing();
|
|
293
|
+
const PADDING = 10;
|
|
294
|
+
const rect = el.getBoundingClientRect();
|
|
295
|
+
|
|
296
|
+
_ring = document.createElement('div');
|
|
297
|
+
_ring.className = 'onboarding-ring';
|
|
298
|
+
_ring.setAttribute('aria-hidden', 'true');
|
|
299
|
+
_ring.style.cssText = `
|
|
300
|
+
position: fixed;
|
|
301
|
+
left: ${rect.left - PADDING}px;
|
|
302
|
+
top: ${rect.top - PADDING}px;
|
|
303
|
+
width: ${rect.width + PADDING * 2}px;
|
|
304
|
+
height: ${rect.height + PADDING * 2}px;
|
|
305
|
+
border-radius: 14px;
|
|
306
|
+
border: 2px solid var(--accent);
|
|
307
|
+
z-index: calc(var(--z-onboard) + 1);
|
|
308
|
+
pointer-events: none;
|
|
309
|
+
animation: ob-ring-pulse 1.8s ease-in-out infinite;
|
|
310
|
+
`;
|
|
311
|
+
document.body.appendChild(_ring);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function _clearRing() {
|
|
315
|
+
if (_ring) {
|
|
316
|
+
_ring.remove();
|
|
317
|
+
_ring = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ── POSICIONAMIENTO DEL TOOLTIP ─────────────────────────────────────────── */
|
|
322
|
+
|
|
323
|
+
function _positionCenter() {
|
|
324
|
+
_tooltip.removeAttribute('data-pos');
|
|
325
|
+
_tooltip.style.cssText = `
|
|
326
|
+
position: fixed;
|
|
327
|
+
left: 50%;
|
|
328
|
+
top: 50%;
|
|
329
|
+
transform: translate(-50%, -50%);
|
|
330
|
+
z-index: calc(var(--z-onboard) + 2);
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function _positionTooltip(el, preferredPos) {
|
|
335
|
+
const MARGIN = 18;
|
|
336
|
+
const TW = 420; // Más ancho para acomodar navegación + botones
|
|
337
|
+
const TH = 220;
|
|
338
|
+
const PADDING = 10;
|
|
339
|
+
const rect = el.getBoundingClientRect();
|
|
340
|
+
const vw = window.innerWidth;
|
|
341
|
+
const vh = window.innerHeight;
|
|
342
|
+
|
|
343
|
+
// Determinar la mejor posición disponible
|
|
344
|
+
const available = {
|
|
345
|
+
right: vw - rect.right - PADDING >= TW + MARGIN,
|
|
346
|
+
left: rect.left - PADDING >= TW + MARGIN,
|
|
347
|
+
bottom: vh - rect.bottom - PADDING >= TH + MARGIN,
|
|
348
|
+
top: rect.top - PADDING >= TH + MARGIN,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
let pos = preferredPos;
|
|
352
|
+
if (!available[pos]) {
|
|
353
|
+
// Fallback en orden de preferencia
|
|
354
|
+
pos = ['right', 'left', 'bottom', 'top'].find(p => available[p]) || 'bottom';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let left, top;
|
|
358
|
+
|
|
359
|
+
switch (pos) {
|
|
360
|
+
case 'right':
|
|
361
|
+
left = rect.right + PADDING + MARGIN;
|
|
362
|
+
top = rect.top + rect.height / 2 - TH / 2;
|
|
363
|
+
break;
|
|
364
|
+
case 'left':
|
|
365
|
+
left = rect.left - PADDING - MARGIN - TW;
|
|
366
|
+
top = rect.top + rect.height / 2 - TH / 2;
|
|
367
|
+
break;
|
|
368
|
+
case 'bottom':
|
|
369
|
+
left = rect.left + rect.width / 2 - TW / 2;
|
|
370
|
+
top = rect.bottom + PADDING + MARGIN;
|
|
371
|
+
break;
|
|
372
|
+
case 'top':
|
|
373
|
+
left = rect.left + rect.width / 2 - TW / 2;
|
|
374
|
+
top = rect.top - PADDING - MARGIN - TH;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Clamp dentro del viewport
|
|
379
|
+
left = Math.max(MARGIN, Math.min(left, vw - TW - MARGIN));
|
|
380
|
+
top = Math.max(MARGIN, Math.min(top, vh - TH - MARGIN));
|
|
381
|
+
|
|
382
|
+
_tooltip.setAttribute('data-pos', pos);
|
|
383
|
+
_tooltip.style.cssText = `
|
|
384
|
+
position: fixed;
|
|
385
|
+
left: ${left}px;
|
|
386
|
+
top: ${top}px;
|
|
387
|
+
width: ${TW}px;
|
|
388
|
+
z-index: calc(var(--z-onboard) + 2);
|
|
389
|
+
transform: none;
|
|
390
|
+
`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ── EVENTOS ─────────────────────────────────────────────────────────────── */
|
|
394
|
+
|
|
395
|
+
function _bindStaticEvents() {
|
|
396
|
+
const tooltip = _tooltip;
|
|
397
|
+
if (!tooltip) return;
|
|
398
|
+
|
|
399
|
+
tooltip.addEventListener('click', e => {
|
|
400
|
+
const btn = e.target.closest('button[id]');
|
|
401
|
+
if (!btn) return;
|
|
402
|
+
if (btn.id === 'ob-next') _advance(1);
|
|
403
|
+
if (btn.id === 'ob-prev') _advance(-1);
|
|
404
|
+
if (btn.id === 'ob-skip') finish();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Escape para cerrar
|
|
408
|
+
_escHandler = e => {
|
|
409
|
+
if (e.key === 'Escape' && _active) finish();
|
|
410
|
+
};
|
|
411
|
+
document.addEventListener('keydown', _escHandler);
|
|
412
|
+
|
|
413
|
+
// Clic en el backdrop (fuera del tooltip) → cerrar
|
|
414
|
+
_spotlight.addEventListener('click', finish);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function _advance(dir) {
|
|
418
|
+
if (dir > 0 && _step === STEPS.length - 1) {
|
|
419
|
+
finish();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
_step = Math.max(0, Math.min(_step + dir, STEPS.length - 1));
|
|
423
|
+
_renderStep();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ── UTILS ───────────────────────────────────────────────────────────────── */
|
|
427
|
+
|
|
428
|
+
function _findTarget(selector, _view) {
|
|
429
|
+
// Intentar el selector tal cual
|
|
430
|
+
let el = document.querySelector(selector);
|
|
431
|
+
if (el) return el;
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function _nextFrame(ms = 60) {
|
|
436
|
+
return new Promise(res => setTimeout(res, ms));
|
|
437
|
+
}
|
package/ui/js/router.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router.js — Navegación entre vistas
|
|
3
|
+
* Gestiona qué vista está activa en #view-container
|
|
4
|
+
* y sincroniza el estado activo en el sidebar.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as state from './state.js';
|
|
8
|
+
|
|
9
|
+
/** @type {Map<string, function(): HTMLElement|string>} */
|
|
10
|
+
const _registry = new Map();
|
|
11
|
+
let _currentView = null;
|
|
12
|
+
let _container = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registra una vista
|
|
16
|
+
* @param {string} id - identificador único de la vista
|
|
17
|
+
* @param {function(): Promise<HTMLElement|string>} factory - función que devuelve el contenido
|
|
18
|
+
*/
|
|
19
|
+
export function register(id, factory) {
|
|
20
|
+
_registry.set(id, factory);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Inicializa el router
|
|
25
|
+
* @param {HTMLElement} container - #view-container
|
|
26
|
+
*/
|
|
27
|
+
export function init(container) {
|
|
28
|
+
_container = container;
|
|
29
|
+
|
|
30
|
+
// Navegación por hash
|
|
31
|
+
window.addEventListener('hashchange', _handleHash);
|
|
32
|
+
|
|
33
|
+
// Clic en nav-items del sidebar (delegación)
|
|
34
|
+
document.addEventListener('click', e => {
|
|
35
|
+
const item = e.target.closest('[data-view]');
|
|
36
|
+
if (!item || !item.dataset.view) return;
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
navigate(item.dataset.view);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Navega a una vista
|
|
44
|
+
* @param {string} viewId
|
|
45
|
+
* @param {boolean} [updateHash=true]
|
|
46
|
+
*/
|
|
47
|
+
export async function navigate(viewId, updateHash = true) {
|
|
48
|
+
if (!_registry.has(viewId)) {
|
|
49
|
+
console.warn(`[router] Vista desconocida: "${viewId}"`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_currentView = viewId;
|
|
54
|
+
state.update('activeView', viewId);
|
|
55
|
+
|
|
56
|
+
// Actualizar hash sin trigger de hashchange
|
|
57
|
+
if (updateHash && window.location.hash !== `#${viewId}`) {
|
|
58
|
+
history.replaceState(null, '', `#${viewId}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Actualizar clases activas en sidebar
|
|
62
|
+
document.querySelectorAll('[data-view]').forEach(item => {
|
|
63
|
+
item.classList.toggle('is-active', item.dataset.view === viewId);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Renderizar contenido
|
|
67
|
+
if (_container) {
|
|
68
|
+
_container.innerHTML = '<div style="padding:2rem"><div class="spinner"></div></div>';
|
|
69
|
+
try {
|
|
70
|
+
const factory = _registry.get(viewId);
|
|
71
|
+
const content = await factory();
|
|
72
|
+
if (typeof content === 'string') {
|
|
73
|
+
_container.innerHTML = content;
|
|
74
|
+
} else if (content instanceof HTMLElement) {
|
|
75
|
+
_container.innerHTML = '';
|
|
76
|
+
_container.appendChild(content);
|
|
77
|
+
}
|
|
78
|
+
// Scroll to top
|
|
79
|
+
_container.scrollTop = 0;
|
|
80
|
+
// Focus para a11y (skip-link target)
|
|
81
|
+
_container.focus({ preventScroll: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[router] Error cargando vista:', err);
|
|
84
|
+
_container.innerHTML = `
|
|
85
|
+
<div class="empty-state" style="margin:2rem">
|
|
86
|
+
<p>Error al cargar la vista <strong>${viewId}</strong></p>
|
|
87
|
+
<p style="font-size:0.8rem;font-family:var(--font-mono)">${err.message}</p>
|
|
88
|
+
</div>`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Refresca la vista actual (re-renderizado)
|
|
95
|
+
*/
|
|
96
|
+
export async function refresh() {
|
|
97
|
+
if (_currentView) {
|
|
98
|
+
await navigate(_currentView, false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Obtiene la vista activa
|
|
104
|
+
*/
|
|
105
|
+
export function current() { return _currentView; }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handler de hashchange
|
|
109
|
+
*/
|
|
110
|
+
function _handleHash() {
|
|
111
|
+
const hash = window.location.hash.slice(1);
|
|
112
|
+
if (hash && _registry.has(hash) && hash !== _currentView) {
|
|
113
|
+
navigate(hash, false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Carga la vista inicial desde el hash o usa la vista por defecto
|
|
119
|
+
* @param {string} [defaultView='overview']
|
|
120
|
+
*/
|
|
121
|
+
export async function start(defaultView = 'overview') {
|
|
122
|
+
const hash = window.location.hash.slice(1);
|
|
123
|
+
const initial = hash && _registry.has(hash) ? hash : defaultView;
|
|
124
|
+
await navigate(initial, true);
|
|
125
|
+
}
|
package/ui/js/state.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state.js — Store centralizado con patrón pub/sub
|
|
3
|
+
* Reemplaza el objeto state simple del app.js anterior.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const _state = {
|
|
7
|
+
// Proyectos
|
|
8
|
+
projects: [],
|
|
9
|
+
registryFile: '',
|
|
10
|
+
currentProjectId: null,
|
|
11
|
+
|
|
12
|
+
// Payload del backend
|
|
13
|
+
payload: null,
|
|
14
|
+
|
|
15
|
+
// Selecciones UI
|
|
16
|
+
selectedTaskId: null,
|
|
17
|
+
activeView: 'overview',
|
|
18
|
+
|
|
19
|
+
// Sesiones de terminal
|
|
20
|
+
sessions: [],
|
|
21
|
+
selectedSessionId: null,
|
|
22
|
+
stream: null,
|
|
23
|
+
|
|
24
|
+
// Time tracker
|
|
25
|
+
timeEntries: [],
|
|
26
|
+
activeEntry: null, // { taskId, taskTitle, startedAt }
|
|
27
|
+
timerInterval: null,
|
|
28
|
+
|
|
29
|
+
// i18n
|
|
30
|
+
locale: 'es',
|
|
31
|
+
statusLabels: {},
|
|
32
|
+
phases: [],
|
|
33
|
+
|
|
34
|
+
// Búsqueda
|
|
35
|
+
searchQuery: '',
|
|
36
|
+
|
|
37
|
+
// Onboarding
|
|
38
|
+
onboardingDone: false,
|
|
39
|
+
|
|
40
|
+
// Console logs
|
|
41
|
+
consoleLogs: [],
|
|
42
|
+
consolePanelOpen: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const _listeners = {};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Obtiene un valor del estado
|
|
49
|
+
* @param {string} key
|
|
50
|
+
* @returns {*}
|
|
51
|
+
*/
|
|
52
|
+
export function get(key) {
|
|
53
|
+
return _state[key];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Actualiza una o más claves del estado y notifica a los suscriptores
|
|
58
|
+
* @param {string|Object} keyOrObject - clave o mapa de {clave: valor}
|
|
59
|
+
* @param {*} [value]
|
|
60
|
+
*/
|
|
61
|
+
export function update(keyOrObject, value) {
|
|
62
|
+
if (typeof keyOrObject === 'string') {
|
|
63
|
+
_state[keyOrObject] = value;
|
|
64
|
+
_notify(keyOrObject, value);
|
|
65
|
+
} else {
|
|
66
|
+
for (const [k, v] of Object.entries(keyOrObject)) {
|
|
67
|
+
_state[k] = v;
|
|
68
|
+
_notify(k, v);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Suscribe una función a cambios de una clave específica
|
|
75
|
+
* @param {string} key
|
|
76
|
+
* @param {Function} callback - recibe (newValue, key)
|
|
77
|
+
* @returns {Function} función de desuscripción
|
|
78
|
+
*/
|
|
79
|
+
export function subscribe(key, callback) {
|
|
80
|
+
if (!_listeners[key]) _listeners[key] = new Set();
|
|
81
|
+
_listeners[key].add(callback);
|
|
82
|
+
return () => _listeners[key]?.delete(callback);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Obtiene el payload completo (shortcut)
|
|
87
|
+
*/
|
|
88
|
+
export function getPayload() { return _state.payload; }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Obtiene el proyecto activo de la lista de proyectos
|
|
92
|
+
*/
|
|
93
|
+
export function getCurrentProject() {
|
|
94
|
+
return _state.projects.find(p => p.id === _state.currentProjectId) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Obtiene las fases según el i18n cargado desde el backend
|
|
99
|
+
*/
|
|
100
|
+
export function getPhases() { return _state.phases; }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Obtiene el mapa de labels de estado
|
|
104
|
+
*/
|
|
105
|
+
export function getStatusLabels() { return _state.statusLabels; }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Encuentra una tarea por id en el payload actual
|
|
109
|
+
* @param {string} id
|
|
110
|
+
* @returns {Object|null}
|
|
111
|
+
*/
|
|
112
|
+
export function findTask(id) {
|
|
113
|
+
return _state.payload?.derived?.tasks?.find(t => t.id === id) || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Obtiene el estado completo (solo para debug)
|
|
118
|
+
*/
|
|
119
|
+
export function snapshot() { return { ..._state }; }
|
|
120
|
+
|
|
121
|
+
function _notify(key, value) {
|
|
122
|
+
(_listeners[key] || []).forEach(cb => {
|
|
123
|
+
try { cb(value, key); } catch (e) { console.error('[state] Error en suscriptor:', e); }
|
|
124
|
+
});
|
|
125
|
+
// Notificar también al comodín '*'
|
|
126
|
+
(_listeners['*'] || []).forEach(cb => {
|
|
127
|
+
try { cb(value, key); } catch (e) { console.error('[state] Error en suscriptor (*)', e); }
|
|
128
|
+
});
|
|
129
|
+
}
|