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,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
+ }
@@ -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
+ }