trackops 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -402
  3. package/bin/trackops.js +116 -116
  4. package/lib/config.js +326 -326
  5. package/lib/control.js +208 -208
  6. package/lib/env.js +244 -244
  7. package/lib/init.js +325 -325
  8. package/lib/locale.js +24 -0
  9. package/lib/opera-bootstrap.js +941 -874
  10. package/lib/opera.js +494 -477
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -196
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/server.js +312 -207
  16. package/lib/skills.js +74 -57
  17. package/lib/workspace.js +260 -260
  18. package/locales/en.json +192 -166
  19. package/locales/es.json +192 -166
  20. package/package.json +61 -58
  21. package/scripts/postinstall-locale.js +21 -21
  22. package/scripts/skills-marketplace-smoke.js +124 -124
  23. package/scripts/smoke-tests.js +558 -554
  24. package/scripts/sync-skill-version.js +21 -21
  25. package/scripts/validate-skill.js +103 -103
  26. package/skills/trackops/SKILL.md +126 -122
  27. package/skills/trackops/agents/openai.yaml +7 -7
  28. package/skills/trackops/locales/en/SKILL.md +126 -122
  29. package/skills/trackops/locales/en/references/activation.md +94 -75
  30. package/skills/trackops/locales/en/references/troubleshooting.md +73 -55
  31. package/skills/trackops/locales/en/references/workflow.md +55 -32
  32. package/skills/trackops/references/activation.md +94 -75
  33. package/skills/trackops/references/troubleshooting.md +73 -55
  34. package/skills/trackops/references/workflow.md +55 -32
  35. package/skills/trackops/skill.json +29 -29
  36. package/templates/hooks/post-checkout +2 -2
  37. package/templates/hooks/post-commit +2 -2
  38. package/templates/hooks/post-merge +2 -2
  39. package/templates/opera/agent.md +28 -27
  40. package/templates/opera/architecture/dependency-graph.md +24 -24
  41. package/templates/opera/architecture/runtime-automation.md +24 -24
  42. package/templates/opera/architecture/runtime-operations.md +34 -34
  43. package/templates/opera/en/agent.md +22 -21
  44. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  45. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  46. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  47. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  48. package/templates/opera/en/reviews/integration-audit.md +18 -18
  49. package/templates/opera/en/router.md +24 -19
  50. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  51. package/templates/opera/references/opera-cycle.md +193 -193
  52. package/templates/opera/registry.md +28 -28
  53. package/templates/opera/reviews/delivery-audit.md +18 -18
  54. package/templates/opera/reviews/integration-audit.md +18 -18
  55. package/templates/opera/router.md +54 -49
  56. package/templates/skills/changelog-updater/SKILL.md +69 -69
  57. package/templates/skills/commiter/SKILL.md +99 -99
  58. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  59. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  60. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  61. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  62. package/templates/skills/opera-skill/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  64. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  65. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  66. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  67. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  68. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  69. package/ui/css/base.css +284 -266
  70. package/ui/css/charts.css +425 -327
  71. package/ui/css/components.css +1107 -570
  72. package/ui/css/onboarding.css +133 -0
  73. package/ui/css/panels.css +345 -406
  74. package/ui/css/terminal.css +125 -0
  75. package/ui/css/timeline.css +58 -0
  76. package/ui/css/tokens.css +284 -227
  77. package/ui/favicon.svg +5 -5
  78. package/ui/index.html +99 -96
  79. package/ui/js/api.js +49 -13
  80. package/ui/js/app.js +28 -32
  81. package/ui/js/charts.js +526 -0
  82. package/ui/js/console-logger.js +172 -172
  83. package/ui/js/filters.js +247 -0
  84. package/ui/js/icons.js +129 -104
  85. package/ui/js/keyboard.js +229 -0
  86. package/ui/js/onboarding.js +33 -42
  87. package/ui/js/router.js +142 -125
  88. package/ui/js/theme.js +100 -100
  89. package/ui/js/time-tracker.js +248 -248
  90. package/ui/js/views/board.js +84 -114
  91. package/ui/js/views/dashboard.js +870 -0
  92. package/ui/js/views/flash.js +47 -47
  93. package/ui/js/views/projects.js +745 -0
  94. package/ui/js/views/scrum.js +476 -0
  95. package/ui/js/views/settings.js +153 -203
  96. package/ui/js/views/sidebar.js +37 -31
  97. package/ui/js/views/tasks.js +218 -101
  98. package/ui/js/views/timeline.js +265 -0
  99. package/ui/js/views/topbar.js +94 -107
  100. package/ui/app.js +0 -950
  101. package/ui/js/views/insights.js +0 -340
  102. package/ui/js/views/overview.js +0 -369
  103. package/ui/styles.css +0 -688
@@ -1,172 +1,172 @@
1
- /**
2
- * console-logger.js — Panel de logs y captura de errores del frontend
3
- * Intercepta console.error, console.warn y window.onerror.
4
- * Muestra un panel colapsable en la parte inferior de la pantalla.
5
- */
6
-
7
- import * as state from './state.js';
8
-
9
- const MAX_LOGS = 100;
10
- let _panel = null;
11
- let _logsList = null;
12
- let _errorBadge = null;
13
- let _titleEl = null;
14
- let _errorCount = 0;
15
-
16
- /** Inicializa el módulo y vincula el panel del DOM */
17
- export function init() {
18
- _panel = document.getElementById('console-panel');
19
- _logsList = document.getElementById('console-logs');
20
- _errorBadge = document.getElementById('console-error-count');
21
- _titleEl = document.getElementById('console-panel-title');
22
-
23
- const closeBtn = document.getElementById('console-close-btn');
24
- const clearBtn = document.getElementById('console-clear-btn');
25
-
26
- closeBtn?.addEventListener('click', close);
27
- clearBtn?.addEventListener('click', clear);
28
-
29
- _patchConsole();
30
- _attachWindowErrors();
31
- }
32
-
33
- /** Abre el panel de console */
34
- export function open() {
35
- _panel?.classList.add('is-open');
36
- state.update('consolePanelOpen', true);
37
- // Scroll to bottom
38
- setTimeout(() => { if (_logsList) _logsList.scrollTop = _logsList.scrollHeight; }, 50);
39
- }
40
-
41
- /** Cierra el panel */
42
- export function close() {
43
- _panel?.classList.remove('is-open');
44
- state.update('consolePanelOpen', false);
45
- }
46
-
47
- /** Toggle */
48
- export function toggle() {
49
- if (state.get('consolePanelOpen')) close();
50
- else open();
51
- }
52
-
53
- /** Limpia los logs */
54
- export function clear() {
55
- state.update('consoleLogs', []);
56
- _errorCount = 0;
57
- if (_logsList) _logsList.innerHTML = '';
58
- _updateErrorBadge();
59
- }
60
-
61
- /**
62
- * Añade una entrada al log
63
- * @param {'error'|'warn'|'info'|'debug'} level
64
- * @param {string} message
65
- */
66
- export function log(level, message) {
67
- const entry = {
68
- level,
69
- message: String(message),
70
- time: new Date().toISOString(),
71
- };
72
-
73
- const logs = state.get('consoleLogs');
74
- logs.push(entry);
75
- if (logs.length > MAX_LOGS) logs.shift();
76
- state.update('consoleLogs', logs);
77
-
78
- _appendEntry(entry);
79
-
80
- if (level === 'error') {
81
- _errorCount++;
82
- _updateErrorBadge();
83
- // Auto-abrir en error
84
- if (!state.get('consolePanelOpen')) open();
85
- }
86
- }
87
-
88
- function _appendEntry(entry) {
89
- if (!_logsList) return;
90
- const time = entry.time.slice(11, 23); // HH:mm:ss.mmm
91
- const item = document.createElement('div');
92
- item.className = 'console-log-entry';
93
- item.setAttribute('role', 'listitem');
94
- item.innerHTML = `
95
- <span class="console-log-time">${time}</span>
96
- <span class="log-level log-${entry.level}">[${entry.level.toUpperCase()}]</span>
97
- <span class="console-log-msg">${_escapeHtml(entry.message)}</span>
98
- `;
99
- _logsList.appendChild(item);
100
- _logsList.scrollTop = _logsList.scrollHeight;
101
-
102
- // Limitar nodos en el DOM
103
- while (_logsList.childElementCount > MAX_LOGS) {
104
- _logsList.firstElementChild?.remove();
105
- }
106
- }
107
-
108
- function _updateErrorBadge() {
109
- if (!_errorBadge) return;
110
- if (_errorCount > 0) {
111
- _errorBadge.textContent = _errorCount;
112
- _errorBadge.style.display = '';
113
- } else {
114
- _errorBadge.style.display = 'none';
115
- }
116
- // Actualizar badge del sidebar
117
- const sidebarBadge = document.getElementById('sidebar-console-badge');
118
- if (sidebarBadge) {
119
- sidebarBadge.textContent = _errorCount;
120
- sidebarBadge.style.display = _errorCount > 0 ? '' : 'none';
121
- }
122
- }
123
-
124
- function _patchConsole() {
125
- const _origError = console.error.bind(console);
126
- const _origWarn = console.warn.bind(console);
127
- const _origLog = console.log.bind(console);
128
-
129
- console.error = (...args) => {
130
- _origError(...args);
131
- log('error', args.map(_serialize).join(' '));
132
- };
133
- console.warn = (...args) => {
134
- _origWarn(...args);
135
- log('warn', args.map(_serialize).join(' '));
136
- };
137
- // Solo capturar console.log marcados como [ops]
138
- console.log = (...args) => {
139
- _origLog(...args);
140
- const msg = args.map(_serialize).join(' ');
141
- if (msg.startsWith('[ops]') || msg.startsWith('[router]') || msg.startsWith('[api]')) {
142
- log('info', msg);
143
- }
144
- };
145
- }
146
-
147
- function _attachWindowErrors() {
148
- window.onerror = (message, source, lineno, colno, error) => {
149
- const msg = error?.stack || `${message} (${source}:${lineno}:${colno})`;
150
- log('error', msg);
151
- return false; // Dejar que sigan propagándose
152
- };
153
-
154
- window.addEventListener('unhandledrejection', event => {
155
- const msg = event.reason?.stack || String(event.reason);
156
- log('error', `Unhandled Promise rejection: ${msg}`);
157
- });
158
- }
159
-
160
- function _serialize(value) {
161
- if (typeof value === 'string') return value;
162
- if (value instanceof Error) return value.stack || value.message;
163
- try { return JSON.stringify(value, null, 0); } catch { return String(value); }
164
- }
165
-
166
- function _escapeHtml(str) {
167
- return String(str)
168
- .replace(/&/g, '&amp;')
169
- .replace(/</g, '&lt;')
170
- .replace(/>/g, '&gt;')
171
- .replace(/"/g, '&quot;');
172
- }
1
+ /**
2
+ * console-logger.js — Panel de logs y captura de errores del frontend
3
+ * Intercepta console.error, console.warn y window.onerror.
4
+ * Muestra un panel colapsable en la parte inferior de la pantalla.
5
+ */
6
+
7
+ import * as state from './state.js';
8
+
9
+ const MAX_LOGS = 100;
10
+ let _panel = null;
11
+ let _logsList = null;
12
+ let _errorBadge = null;
13
+ let _titleEl = null;
14
+ let _errorCount = 0;
15
+
16
+ /** Inicializa el módulo y vincula el panel del DOM */
17
+ export function init() {
18
+ _panel = document.getElementById('console-panel');
19
+ _logsList = document.getElementById('console-logs');
20
+ _errorBadge = document.getElementById('console-error-count');
21
+ _titleEl = document.getElementById('console-panel-title');
22
+
23
+ const closeBtn = document.getElementById('console-close-btn');
24
+ const clearBtn = document.getElementById('console-clear-btn');
25
+
26
+ closeBtn?.addEventListener('click', close);
27
+ clearBtn?.addEventListener('click', clear);
28
+
29
+ _patchConsole();
30
+ _attachWindowErrors();
31
+ }
32
+
33
+ /** Abre el panel de console */
34
+ export function open() {
35
+ _panel?.classList.add('is-open');
36
+ state.update('consolePanelOpen', true);
37
+ // Scroll to bottom
38
+ setTimeout(() => { if (_logsList) _logsList.scrollTop = _logsList.scrollHeight; }, 50);
39
+ }
40
+
41
+ /** Cierra el panel */
42
+ export function close() {
43
+ _panel?.classList.remove('is-open');
44
+ state.update('consolePanelOpen', false);
45
+ }
46
+
47
+ /** Toggle */
48
+ export function toggle() {
49
+ if (state.get('consolePanelOpen')) close();
50
+ else open();
51
+ }
52
+
53
+ /** Limpia los logs */
54
+ export function clear() {
55
+ state.update('consoleLogs', []);
56
+ _errorCount = 0;
57
+ if (_logsList) _logsList.innerHTML = '';
58
+ _updateErrorBadge();
59
+ }
60
+
61
+ /**
62
+ * Añade una entrada al log
63
+ * @param {'error'|'warn'|'info'|'debug'} level
64
+ * @param {string} message
65
+ */
66
+ export function log(level, message) {
67
+ const entry = {
68
+ level,
69
+ message: String(message),
70
+ time: new Date().toISOString(),
71
+ };
72
+
73
+ const logs = state.get('consoleLogs');
74
+ logs.push(entry);
75
+ if (logs.length > MAX_LOGS) logs.shift();
76
+ state.update('consoleLogs', logs);
77
+
78
+ _appendEntry(entry);
79
+
80
+ if (level === 'error') {
81
+ _errorCount++;
82
+ _updateErrorBadge();
83
+ // Auto-abrir en error
84
+ if (!state.get('consolePanelOpen')) open();
85
+ }
86
+ }
87
+
88
+ function _appendEntry(entry) {
89
+ if (!_logsList) return;
90
+ const time = entry.time.slice(11, 23); // HH:mm:ss.mmm
91
+ const item = document.createElement('div');
92
+ item.className = 'console-log-entry';
93
+ item.setAttribute('role', 'listitem');
94
+ item.innerHTML = `
95
+ <span class="console-log-time">${time}</span>
96
+ <span class="log-level log-${entry.level}">[${entry.level.toUpperCase()}]</span>
97
+ <span class="console-log-msg">${_escapeHtml(entry.message)}</span>
98
+ `;
99
+ _logsList.appendChild(item);
100
+ _logsList.scrollTop = _logsList.scrollHeight;
101
+
102
+ // Limitar nodos en el DOM
103
+ while (_logsList.childElementCount > MAX_LOGS) {
104
+ _logsList.firstElementChild?.remove();
105
+ }
106
+ }
107
+
108
+ function _updateErrorBadge() {
109
+ if (!_errorBadge) return;
110
+ if (_errorCount > 0) {
111
+ _errorBadge.textContent = _errorCount;
112
+ _errorBadge.style.display = '';
113
+ } else {
114
+ _errorBadge.style.display = 'none';
115
+ }
116
+ // Actualizar badge del sidebar
117
+ const sidebarBadge = document.getElementById('sidebar-console-badge');
118
+ if (sidebarBadge) {
119
+ sidebarBadge.textContent = _errorCount;
120
+ sidebarBadge.style.display = _errorCount > 0 ? '' : 'none';
121
+ }
122
+ }
123
+
124
+ function _patchConsole() {
125
+ const _origError = console.error.bind(console);
126
+ const _origWarn = console.warn.bind(console);
127
+ const _origLog = console.log.bind(console);
128
+
129
+ console.error = (...args) => {
130
+ _origError(...args);
131
+ log('error', args.map(_serialize).join(' '));
132
+ };
133
+ console.warn = (...args) => {
134
+ _origWarn(...args);
135
+ log('warn', args.map(_serialize).join(' '));
136
+ };
137
+ // Solo capturar console.log marcados como [ops]
138
+ console.log = (...args) => {
139
+ _origLog(...args);
140
+ const msg = args.map(_serialize).join(' ');
141
+ if (msg.startsWith('[ops]') || msg.startsWith('[router]') || msg.startsWith('[api]')) {
142
+ log('info', msg);
143
+ }
144
+ };
145
+ }
146
+
147
+ function _attachWindowErrors() {
148
+ window.onerror = (message, source, lineno, colno, error) => {
149
+ const msg = error?.stack || `${message} (${source}:${lineno}:${colno})`;
150
+ log('error', msg);
151
+ return false; // Dejar que sigan propagándose
152
+ };
153
+
154
+ window.addEventListener('unhandledrejection', event => {
155
+ const msg = event.reason?.stack || String(event.reason);
156
+ log('error', `Unhandled Promise rejection: ${msg}`);
157
+ });
158
+ }
159
+
160
+ function _serialize(value) {
161
+ if (typeof value === 'string') return value;
162
+ if (value instanceof Error) return value.stack || value.message;
163
+ try { return JSON.stringify(value, null, 0); } catch { return String(value); }
164
+ }
165
+
166
+ function _escapeHtml(str) {
167
+ return String(str)
168
+ .replace(/&/g, '&amp;')
169
+ .replace(/</g, '&lt;')
170
+ .replace(/>/g, '&gt;')
171
+ .replace(/"/g, '&quot;');
172
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * filters.js — Filtros persistentes para vistas de tareas
3
+ * Persiste en localStorage + sincroniza con state.
4
+ */
5
+
6
+ import * as state from './state.js';
7
+ import { esc } from './utils.js';
8
+ import { icon } from './icons.js';
9
+ import { t } from './i18n.js';
10
+
11
+ const STORAGE_PREFIX = 'ops-filters-';
12
+
13
+ /** Claves de filtro soportadas */
14
+ const FILTER_KEYS = ['status', 'priority', 'phase', 'stream', 'search'];
15
+
16
+ // ─────────────────────────────── PERSISTENCE ─────────────────────────────────
17
+
18
+ /**
19
+ * Guardar filtros de una vista en localStorage y state
20
+ * @param {string} viewId — ID de la vista (board, tasks)
21
+ * @param {Object} filters — Mapa de filtros activos
22
+ */
23
+ export function save(viewId, filters) {
24
+ // Limpiar filtros vacios
25
+ const clean = {};
26
+ for (const key of FILTER_KEYS) {
27
+ if (filters[key]) clean[key] = filters[key];
28
+ }
29
+ localStorage.setItem(`${STORAGE_PREFIX}${viewId}`, JSON.stringify(clean));
30
+ state.update('activeFilters', clean);
31
+ }
32
+
33
+ /**
34
+ * Cargar filtros guardados de una vista
35
+ * @param {string} viewId
36
+ * @returns {Object} Filtros guardados o {}
37
+ */
38
+ export function load(viewId) {
39
+ try {
40
+ const raw = localStorage.getItem(`${STORAGE_PREFIX}${viewId}`);
41
+ return raw ? JSON.parse(raw) : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Limpiar filtros de una vista
49
+ * @param {string} viewId
50
+ */
51
+ export function clear(viewId) {
52
+ localStorage.removeItem(`${STORAGE_PREFIX}${viewId}`);
53
+ state.update('activeFilters', {});
54
+ }
55
+
56
+ // ─────────────────────────────── APLICAR ─────────────────────────────────────
57
+
58
+ /**
59
+ * Aplicar filtros a una lista de tareas
60
+ * @param {Array} tasks — Lista de tareas
61
+ * @param {Object} filters — Filtros activos
62
+ * @returns {Array} Tareas filtradas
63
+ */
64
+ export function apply(tasks, filters) {
65
+ if (!filters || Object.keys(filters).length === 0) return tasks;
66
+
67
+ return tasks.filter(task => {
68
+ if (filters.status && task.status !== filters.status) return false;
69
+ if (filters.priority && task.priority !== filters.priority) return false;
70
+ if (filters.phase && task.phase !== filters.phase) return false;
71
+ if (filters.stream && task.stream !== filters.stream) return false;
72
+ if (filters.search) {
73
+ const q = filters.search.toLowerCase();
74
+ const haystack = `${task.title} ${task.id} ${task.summary || ''} ${task.stream || ''}`.toLowerCase();
75
+ if (!haystack.includes(q)) return false;
76
+ }
77
+ return true;
78
+ });
79
+ }
80
+
81
+ // ─────────────────────────────── CONTEO ──────────────────────────────────────
82
+
83
+ /**
84
+ * Contar filtros activos
85
+ * @param {Object} filters
86
+ * @returns {number}
87
+ */
88
+ export function count(filters) {
89
+ if (!filters) return 0;
90
+ return FILTER_KEYS.filter(k => filters[k]).length;
91
+ }
92
+
93
+ // ─────────────────────────────── UI ──────────────────────────────────────────
94
+
95
+ /**
96
+ * Renderizar la barra de filtros (chips + selectores)
97
+ * @param {string} viewId — ID de la vista
98
+ * @param {Object} filters — Filtros actuales
99
+ * @param {Object} options — Opciones de renderizado
100
+ * @param {Array} [options.statuses] — Estados disponibles
101
+ * @param {Array} [options.priorities] — Prioridades disponibles
102
+ * @param {Array} [options.phases] — Fases disponibles
103
+ * @param {Array} [options.streams] — Streams disponibles
104
+ * @returns {string} HTML
105
+ */
106
+ export function renderBar(viewId, filters, options = {}) {
107
+ const statusLabels = state.getStatusLabels?.() || {};
108
+ const phases = options.phases || state.getPhases?.() || [];
109
+ const statuses = options.statuses || ['pending', 'in_progress', 'in_review', 'blocked', 'completed', 'cancelled'];
110
+ const priorities = options.priorities || ['P0', 'P1', 'P2', 'P3'];
111
+ const streams = options.streams || [];
112
+
113
+ return `
114
+ <div class="filter-bar" role="search" aria-label="${t('ui.filters.label', {}, 'Filter tasks')}">
115
+ <div class="filter-controls">
116
+ ${_renderSelect('status', filters.status, statuses.map(s => ({
117
+ value: s, label: statusLabels[s] || s
118
+ })), t('ui.filters.status', {}, 'Status'))}
119
+
120
+ ${_renderSelect('priority', filters.priority, priorities.map(p => ({
121
+ value: p, label: p
122
+ })), t('ui.filters.priority', {}, 'Priority'))}
123
+
124
+ ${phases.length ? _renderSelect('phase', filters.phase, phases.map(p => ({
125
+ value: p.id, label: `${p.id} · ${p.label}`
126
+ })), t('ui.filters.phase', {}, 'Phase')) : ''}
127
+
128
+ ${streams.length ? _renderSelect('stream', filters.stream, streams.map(s => ({
129
+ value: s, label: s
130
+ })), t('ui.filters.stream', {}, 'Stream')) : ''}
131
+
132
+ <div class="filter-search">
133
+ <input
134
+ type="search"
135
+ class="form-input filter-search-input"
136
+ placeholder="${t('ui.filters.searchPlaceholder', {}, 'Search tasks...')}"
137
+ value="${esc(filters.search || '')}"
138
+ data-filter="search"
139
+ aria-label="${t('ui.filters.search', {}, 'Search')}"
140
+ />
141
+ </div>
142
+ </div>
143
+
144
+ ${count(filters) > 0 ? `
145
+ <div class="filter-active-bar">
146
+ ${_renderActiveChips(filters, statusLabels, phases)}
147
+ <button class="btn btn-ghost btn-sm filter-clear-all" type="button" data-filter-action="clear">
148
+ ${icon('x', 14)} ${t('ui.filters.clearAll', {}, 'Clear all')}
149
+ </button>
150
+ </div>
151
+ ` : ''}
152
+ </div>
153
+ `;
154
+ }
155
+
156
+ function _renderSelect(key, current, options, placeholder) {
157
+ return `
158
+ <select class="form-select filter-select" data-filter="${key}" aria-label="${placeholder}">
159
+ <option value="">${esc(placeholder)}</option>
160
+ ${options.map(o => `
161
+ <option value="${esc(o.value)}" ${current === o.value ? 'selected' : ''}>${esc(o.label)}</option>
162
+ `).join('')}
163
+ </select>
164
+ `;
165
+ }
166
+
167
+ function _renderActiveChips(filters, statusLabels, phases) {
168
+ const chips = [];
169
+
170
+ if (filters.status) {
171
+ chips.push({ key: 'status', label: statusLabels[filters.status] || filters.status });
172
+ }
173
+ if (filters.priority) {
174
+ chips.push({ key: 'priority', label: filters.priority });
175
+ }
176
+ if (filters.phase) {
177
+ const phase = phases.find(p => p.id === filters.phase);
178
+ chips.push({ key: 'phase', label: phase ? `${phase.id} · ${phase.label}` : filters.phase });
179
+ }
180
+ if (filters.stream) {
181
+ chips.push({ key: 'stream', label: filters.stream });
182
+ }
183
+ if (filters.search) {
184
+ chips.push({ key: 'search', label: `"${filters.search}"` });
185
+ }
186
+
187
+ return chips.map(c => `
188
+ <span class="chip chip-active" data-filter-remove="${c.key}">
189
+ ${esc(c.label)}
190
+ <button class="chip-remove" type="button" aria-label="Remove ${c.key} filter">${icon('x', 12)}</button>
191
+ </span>
192
+ `).join('');
193
+ }
194
+
195
+ /**
196
+ * Vincular eventos de la barra de filtros
197
+ * @param {string} viewId — ID de la vista
198
+ * @param {Function} onFilter — Callback cuando cambian los filtros
199
+ */
200
+ export function bindBar(viewId, onFilter) {
201
+ const filters = load(viewId);
202
+
203
+ // Selectores
204
+ document.querySelectorAll('.filter-select').forEach(select => {
205
+ select.addEventListener('change', () => {
206
+ const key = select.dataset.filter;
207
+ if (key) {
208
+ filters[key] = select.value || undefined;
209
+ if (!select.value) delete filters[key];
210
+ save(viewId, filters);
211
+ onFilter(filters);
212
+ }
213
+ });
214
+ });
215
+
216
+ // Busqueda con debounce
217
+ const searchInput = document.querySelector('.filter-search-input');
218
+ let searchTimer = null;
219
+ searchInput?.addEventListener('input', () => {
220
+ clearTimeout(searchTimer);
221
+ searchTimer = setTimeout(() => {
222
+ const val = searchInput.value.trim();
223
+ if (val) filters.search = val;
224
+ else delete filters.search;
225
+ save(viewId, filters);
226
+ onFilter(filters);
227
+ }, 300);
228
+ });
229
+
230
+ // Eliminar chip individual
231
+ document.querySelectorAll('[data-filter-remove]').forEach(chip => {
232
+ chip.addEventListener('click', () => {
233
+ const key = chip.dataset.filterRemove;
234
+ delete filters[key];
235
+ save(viewId, filters);
236
+ onFilter(filters);
237
+ });
238
+ });
239
+
240
+ // Limpiar todos
241
+ document.querySelector('.filter-clear-all')?.addEventListener('click', () => {
242
+ clear(viewId);
243
+ onFilter({});
244
+ });
245
+ }
246
+
247
+ export default { save, load, clear, apply, count, renderBar, bindBar };