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,405 @@
1
+ /**
2
+ * tasks.js — Editor de tareas (split: lista + formulario)
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, splitLines, formatDate } from '../utils.js';
10
+ import * as timeTracker from '../time-tracker.js';
11
+
12
+ export async function render() {
13
+ const payload = state.getPayload();
14
+ if (!payload) return '<div class="empty-state" style="margin:3rem">Sin datos del proyecto.</div>';
15
+
16
+ const tasks = _filterTasks(payload.derived.tasks);
17
+ const selTask = state.findTask(state.get('selectedTaskId'));
18
+ const phases = state.getPhases();
19
+ const statusLabels = state.getStatusLabels();
20
+
21
+ const html = `
22
+ <div class="view-enter">
23
+ <div class="section-header">
24
+ <div class="section-header-left">
25
+ <p class="eyebrow">Task Studio</p>
26
+ <h2>Gestión de Tareas</h2>
27
+ </div>
28
+ <button class="btn btn-primary btn-sm" id="new-task-btn-top" type="button">
29
+ ${icon('plus', 14)} Nueva tarea
30
+ </button>
31
+ </div>
32
+
33
+ <div class="grid-split">
34
+
35
+ <!-- Lista de tareas -->
36
+ <div style="display:flex;flex-direction:column;gap:var(--space-3)">
37
+ <!-- Quick filter -->
38
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="Filtros de estado">
39
+ ${_renderStatusFilters(statusLabels, payload.derived.totals)}
40
+ </div>
41
+
42
+ <div class="stack stack-sm" id="task-list" aria-label="Lista de tareas" role="list">
43
+ ${_renderTaskList(tasks, statusLabels, phases)}
44
+ </div>
45
+ </div>
46
+
47
+ <!-- Editor de tarea -->
48
+ <div class="panel" id="task-editor" aria-label="Editor de tarea" aria-live="polite">
49
+ ${_renderEditor(selTask, phases)}
50
+ </div>
51
+
52
+ </div>
53
+ </div>
54
+ `;
55
+
56
+ setTimeout(() => _bindEvents(), 0);
57
+ return html;
58
+ }
59
+
60
+ function _filterTasks(tasks) {
61
+ const query = state.get('searchQuery')?.toLowerCase();
62
+ const filter = sessionStorage.getItem('tasks-filter') || '';
63
+ let list = [...tasks];
64
+ if (query) {
65
+ list = list.filter(t =>
66
+ t.title.toLowerCase().includes(query) ||
67
+ (t.summary || '').toLowerCase().includes(query) ||
68
+ t.id.toLowerCase().includes(query)
69
+ );
70
+ }
71
+ if (filter) list = list.filter(t => t.status === filter);
72
+ return list;
73
+ }
74
+
75
+ function _renderStatusFilters(statusLabels, totals) {
76
+ const filters = [
77
+ { id: '', label: 'Todas', count: totals.all },
78
+ { id: 'pending', label: 'Pendiente', count: totals.pending },
79
+ { id: 'in_progress', label: 'En progreso', count: totals.inProgress },
80
+ { id: 'in_review', label: 'En revisión', count: totals.inReview },
81
+ { id: 'blocked', label: 'Bloqueadas', count: totals.blocked },
82
+ { id: 'completed', label: 'Completadas', count: totals.completed },
83
+ ];
84
+ const active = sessionStorage.getItem('tasks-filter') || '';
85
+ return filters.map(f => `
86
+ <button class="chip ${f.id === active ? 'is-active' : ''}"
87
+ type="button" data-task-filter="${esc(f.id)}"
88
+ aria-pressed="${f.id === active}">
89
+ ${esc(f.label)} <span class="badge badge-muted" style="font-size:0.65rem">${f.count}</span>
90
+ </button>
91
+ `).join('');
92
+ }
93
+
94
+ function _renderTaskList(tasks, statusLabels, phases) {
95
+ if (!tasks.length) return '<div class="empty-state">No hay tareas que coincidan.</div>';
96
+ const selectedId = state.get('selectedTaskId');
97
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
98
+
99
+ return tasks.map(t => {
100
+ const phase = phases.find(p => p.id === t.phase);
101
+ return `
102
+ <div class="task-card ${t.id === selectedId ? 'is-selected' : ''}"
103
+ data-task-id="${esc(t.id)}"
104
+ role="listitem" tabindex="0"
105
+ aria-selected="${t.id === selectedId}"
106
+ aria-label="${esc(t.title)}"
107
+ >
108
+ <strong class="task-card-title">${esc(t.title)}</strong>
109
+ <span class="task-card-id">${esc(t.id)}</span>
110
+ <div class="task-card-meta" style="margin-top:var(--space-2)">
111
+ <span class="badge badge-${priorityVariant[t.priority] || 'muted'}">${esc(t.priority)}</span>
112
+ <span class="badge status-${t.status}">${esc(statusLabels[t.status] || t.status)}</span>
113
+ ${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
114
+ </div>
115
+ </div>
116
+ `;
117
+ }).join('');
118
+ }
119
+
120
+ function _renderEditor(task, phases) {
121
+ const isNew = !task;
122
+ const phases_opts = phases.map(p =>
123
+ `<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
124
+ ).join('');
125
+
126
+ const statuses = [
127
+ { id: 'pending', label: 'Pendiente' },
128
+ { id: 'in_progress', label: 'En progreso' },
129
+ { id: 'in_review', label: 'En revisión' },
130
+ { id: 'blocked', label: 'Bloqueada' },
131
+ { id: 'completed', label: 'Completada' },
132
+ { id: 'cancelled', label: 'Cancelada' },
133
+ ];
134
+
135
+ return `
136
+ <div class="panel-header">
137
+ <div class="panel-header-left">
138
+ <p class="eyebrow">Task Studio</p>
139
+ <h3 class="panel-title" id="editor-title">${isNew ? 'Nueva tarea' : esc(task.title)}</h3>
140
+ </div>
141
+ <div class="panel-header-right">
142
+ ${!isNew ? `
143
+ <button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" title="Iniciar timer para esta tarea">
144
+ ${icon('timer', 14)} Timer
145
+ </button>
146
+ <button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="Duplicar tarea">
147
+ ${icon('copy', 14)}
148
+ </button>
149
+ ` : ''}
150
+ <button class="btn btn-ghost btn-sm" id="clear-task-btn" type="button" aria-label="Limpiar formulario">
151
+ ${icon('x', 14)}
152
+ </button>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- Action strip -->
157
+ ${!isNew ? `
158
+ <div class="panel-footer" style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="Acciones rápidas de tarea">
159
+ <button class="chip is-active" type="button" data-task-action="start" aria-label="Iniciar tarea">Iniciar</button>
160
+ <button class="chip" type="button" data-task-action="review" aria-label="Pasar a revisión">Revisión</button>
161
+ <button class="chip" type="button" data-task-action="complete" aria-label="Completar tarea">Completar</button>
162
+ <button class="chip" type="button" data-task-action="block" aria-label="Bloquear tarea">Bloquear</button>
163
+ <button class="chip" type="button" data-task-action="pending" aria-label="Volver a pendiente">Pendiente</button>
164
+ </div>
165
+ ` : ''}
166
+
167
+ <div class="panel-body">
168
+ <form id="task-form" class="stack stack-md" novalidate>
169
+
170
+ <div class="field">
171
+ <label for="task-title">Título <span aria-hidden="true" style="color:var(--danger)">*</span></label>
172
+ <input id="task-title" name="title" type="text" required
173
+ value="${isNew ? '' : esc(task.title)}"
174
+ placeholder="Describe la tarea"
175
+ aria-required="true" />
176
+ </div>
177
+
178
+ <div class="field-row">
179
+ <div class="field">
180
+ <label for="task-phase">Fase</label>
181
+ <select id="task-phase" name="phase">${phases_opts}</select>
182
+ </div>
183
+ <div class="field">
184
+ <label for="task-priority">Prioridad</label>
185
+ <select id="task-priority" name="priority">
186
+ ${['P0','P1','P2','P3'].map(p => `<option value="${p}" ${!isNew && task.priority === p ? 'selected' : ''}>${p}</option>`).join('')}
187
+ </select>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="field-row">
192
+ <div class="field">
193
+ <label for="task-status">Estado</label>
194
+ <select id="task-status" name="status">
195
+ ${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
196
+ </select>
197
+ </div>
198
+ <div class="field">
199
+ <label for="task-stream">Stream</label>
200
+ <input id="task-stream" name="stream" type="text"
201
+ value="${isNew ? 'Operations' : esc(task.stream || '')}"
202
+ placeholder="Operations" />
203
+ </div>
204
+ </div>
205
+
206
+ <div class="checkbox-field">
207
+ <input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
208
+ <label for="task-required">Tarea requerida para entrega</label>
209
+ </div>
210
+
211
+ <div class="field">
212
+ <label for="task-summary">Resumen</label>
213
+ <textarea id="task-summary" name="summary" rows="3"
214
+ placeholder="Descripción breve de la tarea">${isNew ? '' : esc(task.summary || '')}</textarea>
215
+ </div>
216
+
217
+ <div class="field">
218
+ <label for="task-acceptance">Criterios de aceptación</label>
219
+ <textarea id="task-acceptance" name="acceptance" rows="3"
220
+ placeholder="Un criterio por línea">${isNew ? '' : esc((task.acceptance || []).join('\n'))}</textarea>
221
+ </div>
222
+
223
+ <div class="field">
224
+ <label for="task-depends">Dependencias</label>
225
+ <textarea id="task-depends" name="dependsOn" rows="2"
226
+ placeholder="ID de tarea dependiente, uno por línea">${isNew ? '' : esc((task.dependsOn || []).join('\n'))}</textarea>
227
+ </div>
228
+
229
+ <div class="field">
230
+ <label for="task-blocker">Bloqueador</label>
231
+ <textarea id="task-blocker" name="blocker" rows="2"
232
+ placeholder="Describe el bloqueo si aplica">${isNew ? '' : esc(task.blocker || '')}</textarea>
233
+ </div>
234
+
235
+ <div class="field">
236
+ <label for="task-note">Nota de actualización</label>
237
+ <textarea id="task-note" name="note" rows="2"
238
+ placeholder="Nota que se añadirá al historial (opcional)"></textarea>
239
+ </div>
240
+
241
+ <div class="form-actions">
242
+ <button class="btn btn-primary" type="submit" id="save-task-btn">
243
+ ${icon('check', 16)} ${isNew ? 'Crear tarea' : 'Guardar cambios'}
244
+ </button>
245
+ </div>
246
+
247
+ </form>
248
+
249
+ ${!isNew && task.history?.length ? `
250
+ <div style="margin-top:var(--space-6)">
251
+ <p class="eyebrow" style="margin-bottom:var(--space-3)">Historial</p>
252
+ <div class="stack stack-sm">
253
+ ${task.history.slice(-5).reverse().map(h => `
254
+ <div class="info-row">
255
+ <p class="label-sm">${formatDate(h.at)}</p>
256
+ <p class="value">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
257
+ </div>
258
+ `).join('')}
259
+ </div>
260
+ </div>
261
+ ` : ''}
262
+ </div>
263
+ `;
264
+ }
265
+
266
+ function _bindEvents() {
267
+ // Seleccionar tarea de la lista
268
+ document.getElementById('task-list')?.addEventListener('click', e => {
269
+ const card = e.target.closest('[data-task-id]');
270
+ if (!card) return;
271
+ const id = card.dataset.taskId;
272
+ state.update('selectedTaskId', id);
273
+ const editor = document.getElementById('task-editor');
274
+ if (editor) {
275
+ const phases = state.getPhases();
276
+ const selTask = state.findTask(id);
277
+ editor.innerHTML = _renderEditor(selTask, phases);
278
+ _bindEditorForm();
279
+ }
280
+ // Actualizar selección en lista
281
+ document.querySelectorAll('[data-task-id]').forEach(c => {
282
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
283
+ c.setAttribute('aria-selected', c.dataset.taskId === id);
284
+ });
285
+ });
286
+
287
+ // Filtros de estado
288
+ document.querySelectorAll('[data-task-filter]').forEach(btn => {
289
+ btn.addEventListener('click', () => {
290
+ sessionStorage.setItem('tasks-filter', btn.dataset.taskFilter);
291
+ import('../router.js').then(r => r.refresh());
292
+ });
293
+ });
294
+
295
+ // Nueva tarea
296
+ document.getElementById('new-task-btn-top')?.addEventListener('click', () => {
297
+ state.update('selectedTaskId', null);
298
+ const editor = document.getElementById('task-editor');
299
+ if (editor) {
300
+ editor.innerHTML = _renderEditor(null, state.getPhases());
301
+ _bindEditorForm();
302
+ }
303
+ });
304
+
305
+ _bindEditorForm();
306
+ }
307
+
308
+ function _bindEditorForm() {
309
+ // Clear button
310
+ document.getElementById('clear-task-btn')?.addEventListener('click', () => {
311
+ state.update('selectedTaskId', null);
312
+ const editor = document.getElementById('task-editor');
313
+ if (editor) editor.innerHTML = _renderEditor(null, state.getPhases());
314
+ _bindEditorForm();
315
+ });
316
+
317
+ // Duplicate button
318
+ document.getElementById('duplicate-btn')?.addEventListener('click', () => {
319
+ const task = state.findTask(state.get('selectedTaskId'));
320
+ if (!task) return;
321
+ state.update('selectedTaskId', null);
322
+ const editor = document.getElementById('task-editor');
323
+ if (editor) {
324
+ editor.innerHTML = _renderEditor({ ...task, title: `${task.title} (copia)`, status: 'pending', history: [] }, state.getPhases());
325
+ _bindEditorForm();
326
+ document.getElementById('task-title')?.focus();
327
+ }
328
+ });
329
+
330
+ // Timer quick start
331
+ document.getElementById('timer-quick-btn')?.addEventListener('click', async () => {
332
+ const task = state.findTask(state.get('selectedTaskId'));
333
+ if (!task) return;
334
+ await timeTracker.start(task.id, task.title);
335
+ });
336
+
337
+ // Action strip
338
+ document.querySelectorAll('[data-task-action]').forEach(btn => {
339
+ btn.addEventListener('click', async () => {
340
+ const taskId = state.get('selectedTaskId');
341
+ if (!taskId) { flash('Selecciona una tarea primero.', 'warning'); return; }
342
+ const action = btn.dataset.taskAction;
343
+ const note = document.getElementById('task-note')?.value?.trim() || '';
344
+ try {
345
+ await api.taskAction(taskId, action, note || `Cambio a "${action}" desde el board.`);
346
+ flash('Estado actualizado.', 'success');
347
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
348
+ } catch (err) {
349
+ flash(err.message, 'error');
350
+ }
351
+ });
352
+ });
353
+
354
+ // Submit form
355
+ const form = document.getElementById('task-form');
356
+ form?.addEventListener('submit', async e => {
357
+ e.preventDefault();
358
+ const btn = document.getElementById('save-task-btn');
359
+ if (btn) btn.disabled = true;
360
+ try {
361
+ await _submitForm();
362
+ } finally {
363
+ if (btn) btn.disabled = false;
364
+ }
365
+ });
366
+ }
367
+
368
+ async function _submitForm() {
369
+ const get = id => document.getElementById(id);
370
+
371
+ const payload = {
372
+ title: get('task-title')?.value.trim(),
373
+ phase: get('task-phase')?.value,
374
+ priority: get('task-priority')?.value,
375
+ status: get('task-status')?.value,
376
+ stream: get('task-stream')?.value.trim(),
377
+ required: get('task-required')?.checked,
378
+ summary: get('task-summary')?.value.trim(),
379
+ acceptance: splitLines(get('task-acceptance')?.value || ''),
380
+ dependsOn: splitLines(get('task-depends')?.value || ''),
381
+ blocker: get('task-blocker')?.value.trim(),
382
+ note: get('task-note')?.value.trim(),
383
+ };
384
+
385
+ if (!payload.title) {
386
+ flash('El título es obligatorio.', 'error');
387
+ get('task-title')?.focus();
388
+ return;
389
+ }
390
+
391
+ const selectedId = state.get('selectedTaskId');
392
+ try {
393
+ if (selectedId) {
394
+ await api.updateTask(selectedId, payload);
395
+ flash('Tarea actualizada.', 'success');
396
+ } else {
397
+ const result = await api.createTask(payload);
398
+ state.update('selectedTaskId', result.task?.id);
399
+ flash('Tarea creada.', 'success');
400
+ }
401
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
402
+ } catch (err) {
403
+ flash(err.message, 'error');
404
+ }
405
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * topbar.js — Barra superior del dashboard
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, debounce } from '../utils.js';
10
+ import * as theme from '../theme.js';
11
+
12
+ /** Renderiza el topbar */
13
+ export function render() {
14
+ const el = document.getElementById('topbar');
15
+ if (!el) return;
16
+
17
+ const payload = state.getPayload();
18
+ const projects = state.get('projects');
19
+ const currentId = state.get('currentProjectId');
20
+ const runtime = payload?.runtime;
21
+
22
+ el.innerHTML = `
23
+ <div class="topbar">
24
+ <!-- Hamburger (mobile) -->
25
+ <button class="topbar-hamburger" type="button" id="sidebar-toggle" aria-label="Abrir menú" aria-expanded="false" aria-controls="sidebar">
26
+ <span></span><span></span><span></span>
27
+ </button>
28
+
29
+ <!-- Search -->
30
+ <div class="topbar-search">
31
+ <div class="search-wrapper" role="search">
32
+ <span class="search-icon" aria-hidden="true">${icon('search', 16)}</span>
33
+ <input
34
+ type="search"
35
+ id="global-search"
36
+ aria-label="Buscar tareas"
37
+ placeholder="Buscar tareas…"
38
+ autocomplete="off"
39
+ value="${esc(state.get('searchQuery'))}"
40
+ />
41
+ <span class="search-kbd" aria-hidden="true">⌘F</span>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Derecha -->
46
+ <div class="topbar-right">
47
+
48
+ <!-- Timer activo -->
49
+ <div class="topbar-timer" id="topbar-timer" aria-label="Seguimiento de tiempo" aria-live="polite">
50
+ <span class="topbar-timer-dot" aria-hidden="true"></span>
51
+ <span id="topbar-timer-display">00:00:00</span>
52
+ </div>
53
+
54
+ <!-- Repo status -->
55
+ ${runtime ? _renderRepoBadge(runtime) : ''}
56
+
57
+ <!-- Project selector -->
58
+ ${projects.length > 0 ? _renderProjectSelector(projects, currentId) : ''}
59
+
60
+ <!-- Sync button -->
61
+ <button class="btn btn-ghost btn-sm" id="sync-btn" type="button" aria-label="Sincronizar documentación">
62
+ ${icon('sync', 16)} Sincronizar
63
+ </button>
64
+
65
+ <!-- Theme toggle -->
66
+ ${theme.renderButton()}
67
+
68
+ <!-- Refresh -->
69
+ <button class="btn btn-ghost btn-sm btn-icon" id="refresh-btn" type="button" aria-label="Refrescar estado">
70
+ ${icon('refresh', 16)}
71
+ </button>
72
+
73
+ </div>
74
+ </div>
75
+ `;
76
+
77
+ _bindEvents();
78
+ }
79
+
80
+ function _renderRepoBadge(runtime) {
81
+ const isClean = runtime.clean;
82
+ const label = isClean ? 'Repo limpio' : `${runtime.staged}s ${runtime.unstaged}u ${runtime.untracked}?`;
83
+ return `
84
+ <div class="repo-badge ${isClean ? 'clean' : 'dirty'}" title="${esc(runtime.branch || '')}">
85
+ <span class="repo-badge-dot" aria-hidden="true"></span>
86
+ <span>${icon('gitBranch', 12)} ${esc(runtime.branch || 'sin rama')} · ${label}</span>
87
+ </div>
88
+ `;
89
+ }
90
+
91
+ function _renderProjectSelector(projects, currentId) {
92
+ const options = projects.map(p =>
93
+ `<option value="${esc(p.id)}" ${p.id === currentId ? 'selected' : ''} ${p.available ? '' : 'disabled'}>
94
+ ${esc(p.name)}${p.available ? '' : ' (no disponible)'}
95
+ </option>`
96
+ ).join('');
97
+
98
+ return `
99
+ <div class="project-select-wrapper" title="Proyecto activo">
100
+ <select id="project-select" aria-label="Seleccionar proyecto activo">
101
+ ${options}
102
+ </select>
103
+ </div>
104
+ `;
105
+ }
106
+
107
+ function _bindEvents() {
108
+ // Hamburger → sidebar open
109
+ document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
110
+ const sidebar = document.getElementById('sidebar');
111
+ const isOpen = sidebar?.classList.toggle('is-open');
112
+ document.getElementById('sidebar-toggle')?.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
113
+ });
114
+
115
+ // Theme toggle
116
+ theme.bindButton();
117
+
118
+ // Cerrar sidebar al hacer clic fuera (mobile)
119
+ document.addEventListener('click', e => {
120
+ const sidebar = document.getElementById('sidebar');
121
+ const toggle = document.getElementById('sidebar-toggle');
122
+ if (sidebar?.classList.contains('is-open') &&
123
+ !sidebar.contains(e.target) && !toggle?.contains(e.target)) {
124
+ sidebar.classList.remove('is-open');
125
+ toggle?.setAttribute('aria-expanded', 'false');
126
+ }
127
+ });
128
+
129
+ // Project selector
130
+ document.getElementById('project-select')?.addEventListener('change', async e => {
131
+ const id = e.target.value;
132
+ state.update('currentProjectId', id);
133
+ localStorage.setItem('ops-dashboard-project', id);
134
+ state.update('selectedTaskId', null);
135
+ // Trigger refresh global
136
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
137
+ });
138
+
139
+ // Sync button
140
+ document.getElementById('sync-btn')?.addEventListener('click', async () => {
141
+ const btn = document.getElementById('sync-btn');
142
+ if (btn) { btn.disabled = true; btn.innerHTML = `${icon('sync', 16)} Sincronizando…`; }
143
+ try {
144
+ await api.syncDocs();
145
+ flash('Documentación sincronizada.', 'success');
146
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
147
+ } catch (err) {
148
+ flash(err.message, 'error');
149
+ } finally {
150
+ if (btn) { btn.disabled = false; btn.innerHTML = `${icon('sync', 16)} Sincronizar`; }
151
+ }
152
+ });
153
+
154
+ // Refresh button
155
+ document.getElementById('refresh-btn')?.addEventListener('click', () => {
156
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
157
+ });
158
+
159
+ // Búsqueda global (debounced)
160
+ const searchInput = document.getElementById('global-search');
161
+ if (searchInput) {
162
+ const handleSearch = debounce(e => {
163
+ state.update('searchQuery', e.target.value);
164
+ window.dispatchEvent(new CustomEvent('ops:search', { detail: { query: e.target.value } }));
165
+ }, 250);
166
+ searchInput.addEventListener('input', handleSearch);
167
+
168
+ // Atajo de teclado ⌘/Ctrl+F
169
+ document.addEventListener('keydown', e => {
170
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
171
+ e.preventDefault();
172
+ searchInput.focus();
173
+ searchInput.select();
174
+ }
175
+ });
176
+ }
177
+ }
178
+
179
+ /** Actualiza solo el timer del topbar sin re-renderizar */
180
+ export function updateTimer(display) {
181
+ const el = document.getElementById('topbar-timer-display');
182
+ if (el) el.textContent = display;
183
+ }