trackops 2.0.2 → 2.0.4

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 (48) hide show
  1. package/README.md +238 -0
  2. package/lib/init.js +2 -2
  3. package/lib/locale.js +41 -17
  4. package/lib/opera-bootstrap.js +68 -7
  5. package/lib/opera.js +10 -2
  6. package/lib/registry.js +18 -0
  7. package/lib/server.js +312 -207
  8. package/locales/en.json +4 -0
  9. package/locales/es.json +4 -0
  10. package/package.json +1 -1
  11. package/skills/trackops/SKILL.md +39 -4
  12. package/skills/trackops/agents/openai.yaml +2 -2
  13. package/skills/trackops/locales/en/SKILL.md +39 -4
  14. package/skills/trackops/locales/en/references/activation.md +15 -0
  15. package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
  16. package/skills/trackops/references/activation.md +15 -0
  17. package/skills/trackops/references/troubleshooting.md +12 -0
  18. package/skills/trackops/skill.json +4 -4
  19. package/ui/css/base.css +19 -1
  20. package/ui/css/charts.css +106 -8
  21. package/ui/css/components.css +554 -17
  22. package/ui/css/onboarding.css +133 -0
  23. package/ui/css/panels.css +345 -406
  24. package/ui/css/terminal.css +125 -0
  25. package/ui/css/timeline.css +58 -0
  26. package/ui/css/tokens.css +170 -113
  27. package/ui/index.html +3 -0
  28. package/ui/js/api.js +49 -13
  29. package/ui/js/app.js +28 -32
  30. package/ui/js/charts.js +526 -0
  31. package/ui/js/filters.js +247 -0
  32. package/ui/js/icons.js +82 -57
  33. package/ui/js/keyboard.js +229 -0
  34. package/ui/js/onboarding.js +33 -42
  35. package/ui/js/router.js +20 -3
  36. package/ui/js/views/board.js +84 -114
  37. package/ui/js/views/dashboard.js +870 -0
  38. package/ui/js/views/projects.js +745 -0
  39. package/ui/js/views/scrum.js +476 -0
  40. package/ui/js/views/settings.js +197 -247
  41. package/ui/js/views/sidebar.js +37 -31
  42. package/ui/js/views/tasks.js +218 -101
  43. package/ui/js/views/timeline.js +265 -0
  44. package/ui/js/views/topbar.js +94 -107
  45. package/ui/app.js +0 -950
  46. package/ui/js/views/insights.js +0 -340
  47. package/ui/js/views/overview.js +0 -369
  48. package/ui/styles.css +0 -688
@@ -0,0 +1,229 @@
1
+ /**
2
+ * keyboard.js — Sistema de atajos de teclado global para TrackOps Dashboard
3
+ * Secuencias de 2 teclas estilo Vim/GitHub (g d = go dashboard).
4
+ * No activo cuando un input/textarea/select tiene foco.
5
+ */
6
+
7
+ import * as router from './router.js';
8
+ import * as state from './state.js';
9
+ import { icon } from './icons.js';
10
+
11
+ /** @type {Map<string, {handler: Function, label: string, group: string}>} */
12
+ const _shortcuts = new Map();
13
+
14
+ /** Buffer para secuencias de 2 teclas */
15
+ let _pendingKey = '';
16
+ let _pendingTimer = null;
17
+ const SEQUENCE_TIMEOUT = 600; // ms para completar secuencia
18
+
19
+ /** Elemento del panel de ayuda */
20
+ let _helpVisible = false;
21
+
22
+ // ─────────────────────────────── REGISTRO ────────────────────────────────────
23
+
24
+ /**
25
+ * Registrar un atajo de teclado
26
+ * @param {string} keys — Tecla o secuencia ('g o', '/', '?', 'Escape')
27
+ * @param {Function} handler — Callback
28
+ * @param {string} label — Descripcion para el panel de ayuda
29
+ * @param {string} [group='General'] — Grupo en el panel de ayuda
30
+ */
31
+ export function register(keys, handler, label, group = 'General') {
32
+ _shortcuts.set(keys, { handler, label, group });
33
+ }
34
+
35
+ // ─────────────────────────────── DEFAULTS ────────────────────────────────────
36
+
37
+ function _registerDefaults() {
38
+ // Navegacion
39
+ register('g d', () => router.navigate('dashboard'), 'Go to Dashboard', 'Navigation');
40
+ register('g t', () => router.navigate('tasks'), 'Go to Tasks', 'Navigation');
41
+ register('g l', () => router.navigate('timeline'), 'Go to Timeline', 'Navigation');
42
+ register('g e', () => router.navigate('terminal'), 'Go to Terminal', 'Navigation');
43
+ register('g p', () => router.navigate('projects'), 'Go to Projects', 'Navigation');
44
+ register('g s', () => router.navigate('settings'), 'Go to Settings', 'Navigation');
45
+
46
+ // Acciones
47
+ register('/', () => _focusSearch(), 'Focus search', 'Actions');
48
+ register('r', () => _refreshState(), 'Refresh data', 'Actions');
49
+ register('Escape', () => _closeActiveOverlay(), 'Close overlay', 'Actions');
50
+
51
+ // Ayuda
52
+ register('?', () => toggleHelp(), 'Show shortcuts', 'Help');
53
+ }
54
+
55
+ // ─────────────────────────────── HANDLER ─────────────────────────────────────
56
+
57
+ function _onKeyDown(e) {
58
+ // No interceptar si un input tiene foco
59
+ const tag = document.activeElement?.tagName?.toLowerCase();
60
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
61
+ // Solo Escape escapa de inputs
62
+ if (e.key === 'Escape') {
63
+ document.activeElement.blur();
64
+ e.preventDefault();
65
+ }
66
+ return;
67
+ }
68
+
69
+ // No interceptar si hay modificadores (excepto Shift para ?)
70
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
71
+
72
+ const key = e.key;
73
+
74
+ // Buscar atajo directo (1 tecla)
75
+ if (!_pendingKey) {
76
+ const direct = _shortcuts.get(key);
77
+ if (direct) {
78
+ e.preventDefault();
79
+ direct.handler();
80
+ return;
81
+ }
82
+
83
+ // Iniciar secuencia de 2 teclas
84
+ // Solo letras minusculas inician secuencias
85
+ if (/^[a-z]$/.test(key)) {
86
+ _pendingKey = key;
87
+ _pendingTimer = setTimeout(() => {
88
+ _pendingKey = '';
89
+ _pendingTimer = null;
90
+ }, SEQUENCE_TIMEOUT);
91
+ return;
92
+ }
93
+ return;
94
+ }
95
+
96
+ // Completar secuencia de 2 teclas
97
+ clearTimeout(_pendingTimer);
98
+ const sequence = `${_pendingKey} ${key}`;
99
+ _pendingKey = '';
100
+ _pendingTimer = null;
101
+
102
+ const seq = _shortcuts.get(sequence);
103
+ if (seq) {
104
+ e.preventDefault();
105
+ seq.handler();
106
+ }
107
+ }
108
+
109
+ // ─────────────────────────────── ACCIONES ────────────────────────────────────
110
+
111
+ function _focusSearch() {
112
+ const input = document.querySelector('.topbar-search input, .topbar-search-input');
113
+ if (input) {
114
+ input.focus();
115
+ input.select();
116
+ }
117
+ }
118
+
119
+ function _refreshState() {
120
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
121
+ }
122
+
123
+ function _closeActiveOverlay() {
124
+ // Cerrar help panel
125
+ if (_helpVisible) {
126
+ toggleHelp();
127
+ return;
128
+ }
129
+ // Cerrar modal
130
+ const modal = document.querySelector('.modal-overlay:not(.is-hidden)');
131
+ if (modal) {
132
+ modal.classList.add('is-hidden');
133
+ return;
134
+ }
135
+ // Cerrar console panel
136
+ const consolePanel = document.getElementById('console-panel');
137
+ if (consolePanel?.classList.contains('is-open')) {
138
+ consolePanel.classList.remove('is-open');
139
+ }
140
+ }
141
+
142
+ // ─────────────────────────────── HELP PANEL ──────────────────────────────────
143
+
144
+ export function toggleHelp() {
145
+ _helpVisible = !_helpVisible;
146
+ let panel = document.getElementById('keyboard-help-panel');
147
+
148
+ if (!_helpVisible && panel) {
149
+ panel.remove();
150
+ return;
151
+ }
152
+
153
+ if (_helpVisible) {
154
+ panel = document.createElement('div');
155
+ panel.id = 'keyboard-help-panel';
156
+ panel.className = 'modal-overlay';
157
+ panel.setAttribute('role', 'dialog');
158
+ panel.setAttribute('aria-modal', 'true');
159
+ panel.setAttribute('aria-label', 'Keyboard shortcuts');
160
+ panel.addEventListener('click', (e) => {
161
+ if (e.target === panel) toggleHelp();
162
+ });
163
+
164
+ // Agrupar shortcuts
165
+ const groups = new Map();
166
+ for (const [keys, { label, group }] of _shortcuts) {
167
+ if (!groups.has(group)) groups.set(group, []);
168
+ groups.get(group).push({ keys, label });
169
+ }
170
+
171
+ let groupsHtml = '';
172
+ for (const [group, items] of groups) {
173
+ groupsHtml += `
174
+ <div class="kb-help-group">
175
+ <h3 class="kb-help-group-title">${group}</h3>
176
+ <div class="kb-help-items">
177
+ ${items.map(({ keys, label }) => `
178
+ <div class="kb-help-item">
179
+ <kbd class="kb-help-key">${_formatKey(keys)}</kbd>
180
+ <span class="kb-help-label">${label}</span>
181
+ </div>
182
+ `).join('')}
183
+ </div>
184
+ </div>
185
+ `;
186
+ }
187
+
188
+ panel.innerHTML = `
189
+ <div class="modal" style="max-width:520px">
190
+ <div class="modal-header">
191
+ <h2 class="modal-title">${icon('keyboard', 18)} Keyboard shortcuts</h2>
192
+ <button class="modal-close" type="button" aria-label="Close" id="kb-help-close">&times;</button>
193
+ </div>
194
+ <div class="modal-body" style="padding:var(--space-4) var(--space-6)">
195
+ ${groupsHtml}
196
+ </div>
197
+ <div class="modal-footer" style="justify-content:center">
198
+ <p style="font-size:var(--text-xs);color:var(--text-muted)">Press <kbd class="kb-help-key">?</kbd> to toggle this panel</p>
199
+ </div>
200
+ </div>
201
+ `;
202
+
203
+ document.body.appendChild(panel);
204
+ panel.querySelector('#kb-help-close')?.addEventListener('click', () => toggleHelp());
205
+ }
206
+ }
207
+
208
+ function _formatKey(keys) {
209
+ return keys.split(' ').map(k => {
210
+ if (k === 'Escape') return 'Esc';
211
+ if (k === '/') return '/';
212
+ if (k === '?') return '?';
213
+ return k.toUpperCase();
214
+ }).join(' <span style="opacity:0.4">then</span> ');
215
+ }
216
+
217
+ // ─────────────────────────────── INIT ────────────────────────────────────────
218
+
219
+ export function init() {
220
+ _registerDefaults();
221
+ document.addEventListener('keydown', _onKeyDown);
222
+ }
223
+
224
+ export function destroy() {
225
+ document.removeEventListener('keydown', _onKeyDown);
226
+ _shortcuts.clear();
227
+ }
228
+
229
+ export default { init, destroy, register, toggleHelp };
@@ -28,7 +28,7 @@ const STEPS = [
28
28
  titleKey: 'ui.onboarding.nav.title',
29
29
  descKey: 'ui.onboarding.nav.desc',
30
30
  target: '#sidebar',
31
- view: 'overview',
31
+ view: 'dashboard',
32
32
  pos: 'right',
33
33
  },
34
34
 
@@ -36,8 +36,8 @@ const STEPS = [
36
36
  {
37
37
  titleKey: 'ui.onboarding.kpi.title',
38
38
  descKey: 'ui.onboarding.kpi.desc',
39
- target: '.kpi-grid',
40
- view: 'overview',
39
+ target: '.grid-4',
40
+ view: 'dashboard',
41
41
  pos: 'bottom',
42
42
  },
43
43
 
@@ -46,7 +46,7 @@ const STEPS = [
46
46
  titleKey: 'ui.onboarding.activity.title',
47
47
  descKey: 'ui.onboarding.activity.desc',
48
48
  target: '.chart-card',
49
- view: 'overview',
49
+ view: 'dashboard',
50
50
  pos: 'bottom',
51
51
  },
52
52
 
@@ -55,71 +55,62 @@ const STEPS = [
55
55
  titleKey: 'ui.onboarding.progress.title',
56
56
  descKey: 'ui.onboarding.progress.desc',
57
57
  target: '.donut-wrapper',
58
- view: 'overview',
58
+ view: 'dashboard',
59
59
  pos: 'left',
60
60
  },
61
61
 
62
- // 5 — Time Tracker
63
- {
64
- titleKey: 'ui.onboarding.time.title',
65
- descKey: 'ui.onboarding.time.desc',
66
- target: '.time-tracker-card',
67
- view: 'overview',
68
- pos: 'top',
69
- },
70
-
71
- // 6 — Topbar: búsqueda
62
+ // 5 — Topbar: búsqueda
72
63
  {
73
64
  titleKey: 'ui.onboarding.search.title',
74
65
  descKey: 'ui.onboarding.search.desc',
75
66
  target: '.topbar-search',
76
- view: 'overview',
67
+ view: 'dashboard',
77
68
  pos: 'bottom',
78
69
  },
79
70
 
80
- // 7 — Board (Kanban)
81
- {
82
- titleKey: 'ui.onboarding.board.title',
83
- descKey: 'ui.onboarding.board.desc',
84
- target: '.board-grid',
85
- view: 'board',
86
- pos: 'top',
87
- },
88
-
89
- // 8 — Editor de tareas
71
+ // 7 — Tasks (Kanban mode)
90
72
  {
91
73
  titleKey: 'ui.onboarding.tasks.title',
92
74
  descKey: 'ui.onboarding.tasks.desc',
93
- target: '.task-list',
75
+ target: '#task-list',
94
76
  view: 'tasks',
95
77
  pos: 'right',
96
78
  },
97
79
 
98
- // 9Ejecución / Consola
80
+ // 8Terminal
99
81
  {
100
82
  titleKey: 'ui.onboarding.execution.title',
101
83
  descKey: 'ui.onboarding.execution.desc',
102
84
  target: '.terminal-surface',
103
- view: 'execution',
85
+ view: 'terminal',
104
86
  pos: 'top',
105
87
  },
106
88
 
107
- // 10Analytics
89
+ // 9Timeline
108
90
  {
109
- titleKey: 'ui.onboarding.insights.title',
110
- descKey: 'ui.onboarding.insights.desc',
111
- target: '.health-grid',
112
- view: 'insights',
113
- pos: 'bottom',
91
+ titleKey: 'ui.onboarding.timeline.title',
92
+ descKey: 'ui.onboarding.timeline.desc',
93
+ target: '.timeline-container',
94
+ view: 'timeline',
95
+ pos: 'top',
114
96
  },
115
97
 
116
- // 11AI Skill Hub
98
+ // 10Projects
117
99
  {
118
- titleKey: 'ui.onboarding.skills.title',
119
- descKey: 'ui.onboarding.skills.desc',
120
- target: '#view-skills',
121
- view: 'skills',
122
- pos: 'right',
100
+ titleKey: 'ui.onboarding.projects.title',
101
+ descKey: 'ui.onboarding.projects.desc',
102
+ target: '.projects-grid',
103
+ view: 'projects',
104
+ pos: 'top',
105
+ },
106
+
107
+ // 11 — Keyboard shortcuts
108
+ {
109
+ titleKey: 'ui.onboarding.keyboard.title',
110
+ descKey: 'ui.onboarding.keyboard.desc',
111
+ target: null,
112
+ view: null,
113
+ pos: 'center',
123
114
  },
124
115
 
125
116
  // 12 — Theme toggle
@@ -127,7 +118,7 @@ const STEPS = [
127
118
  titleKey: 'ui.onboarding.theme.title',
128
119
  descKey: 'ui.onboarding.theme.desc',
129
120
  target: '#theme-toggle-btn',
130
- view: 'overview',
121
+ view: 'dashboard',
131
122
  pos: 'bottom',
132
123
  },
133
124
 
package/ui/js/router.js CHANGED
@@ -6,6 +6,14 @@
6
6
 
7
7
  import * as state from './state.js';
8
8
 
9
+ const _redirects = {
10
+ overview: 'dashboard',
11
+ insights: 'dashboard',
12
+ board: 'tasks',
13
+ skills: 'settings',
14
+ execution: 'terminal',
15
+ };
16
+
9
17
  /** @type {Map<string, function(): HTMLElement|string>} */
10
18
  const _registry = new Map();
11
19
  let _currentView = null;
@@ -108,7 +116,12 @@ export function current() { return _currentView; }
108
116
  * Handler de hashchange
109
117
  */
110
118
  function _handleHash() {
111
- const hash = window.location.hash.slice(1);
119
+ let hash = window.location.hash.slice(1);
120
+ // Redirect old routes
121
+ if (_redirects[hash]) {
122
+ hash = _redirects[hash];
123
+ history.replaceState(null, '', `#${hash}`);
124
+ }
112
125
  if (hash && _registry.has(hash) && hash !== _currentView) {
113
126
  navigate(hash, false);
114
127
  }
@@ -118,8 +131,12 @@ function _handleHash() {
118
131
  * Carga la vista inicial desde el hash o usa la vista por defecto
119
132
  * @param {string} [defaultView='overview']
120
133
  */
121
- export async function start(defaultView = 'overview') {
122
- const hash = window.location.hash.slice(1);
134
+ export async function start(defaultView = 'dashboard') {
135
+ let hash = window.location.hash.slice(1);
136
+ if (_redirects[hash]) {
137
+ hash = _redirects[hash];
138
+ history.replaceState(null, '', `#${hash}`);
139
+ }
123
140
  const initial = hash && _registry.has(hash) ? hash : defaultView;
124
141
  await navigate(initial, true);
125
142
  }
@@ -1,97 +1,121 @@
1
1
  /**
2
- * board.js — Tablero Kanban con drag & drop
2
+ * board.js — Headless Kanban module (embeddable inside tasks.js)
3
+ *
4
+ * Exports:
5
+ * renderKanban(tasks, phases, opts) — returns kanban board HTML (columns + cards only)
6
+ * bindKanbanEvents() — binds drag-drop, card click/selection, task actions
3
7
  */
4
8
 
5
9
  import { icon } from '../icons.js';
6
10
  import * as state from '../state.js';
7
11
  import * as api from '../api.js';
8
- import * as router from '../router.js';
9
12
  import { flash } from './flash.js';
10
- import { esc, debounce } from '../utils.js';
13
+ import { esc } from '../utils.js';
11
14
  import { t } from '../i18n.js';
12
15
 
13
16
  const COLUMNS = [
14
17
  { id: 'pending', label: 'Pending' },
15
18
  { id: 'in_progress', label: 'In Progress' },
16
- { id: 'in_review', label: 'In Review' },
17
- { id: 'blocked', label: 'Blocked' },
18
- { id: 'completed', label: 'Completed' },
19
+ { id: 'in_review', label: 'In Review' },
20
+ { id: 'blocked', label: 'Blocked' },
21
+ { id: 'completed', label: 'Completed' },
19
22
  ];
20
23
 
21
24
  let _dragTaskId = null;
22
25
 
23
- export async function render() {
24
- const payload = state.getPayload();
25
- if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.board.noData', {}, 'No project data.')}</div>`;
26
+ /**
27
+ * Render the kanban board HTML (columns + cards only).
28
+ *
29
+ * @param {Array} tasks — pre-filtered task list
30
+ * @param {Array} phases — array of phase objects ({ id, label, ... })
31
+ * @param {Object} opts — { showCompleted, showCancelled, wipLimits }
32
+ * wipLimits is an optional map: { status: number }
33
+ * @returns {string} HTML string for the board grid
34
+ */
35
+ export function renderKanban(tasks, phases, opts = {}) {
36
+ const { showCompleted = true, showCancelled = false, wipLimits = {} } = opts;
37
+
38
+ // Filter out completed / cancelled unless opted-in
39
+ let visibleTasks = tasks;
40
+ if (!showCompleted) {
41
+ visibleTasks = visibleTasks.filter(t => t.status !== 'completed');
42
+ }
43
+ if (!showCancelled) {
44
+ visibleTasks = visibleTasks.filter(t => t.status !== 'cancelled');
45
+ }
26
46
 
27
- const tasks = _filterTasks(payload.derived.tasks);
28
- const hasCancelled = tasks.some(t => t.status === 'cancelled');
47
+ // Build column set
48
+ const hasCancelled = showCancelled && visibleTasks.some(t => t.status === 'cancelled');
29
49
  const columns = hasCancelled
30
50
  ? [...COLUMNS, { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') }]
31
51
  : COLUMNS.map(col => ({ ...col, label: t(`status.${col.id}`, {}, col.label) }));
32
52
 
33
- const html = `
34
- <div class="view-enter">
35
- <div class="section-header">
36
- <div class="section-header-left">
37
- <p class="eyebrow">${t('ui.board.eyebrow', {}, 'Board')}</p>
38
- <h2>${t('ui.board.title', {}, 'Operational board')}</h2>
39
- </div>
40
- <div style="display:flex;gap:var(--space-2)">
41
- <button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
42
- ${icon('check', 14)} ${_showCompleted() ? t('ui.board.hideCompleted', {}, 'Hide completed') : t('ui.board.showCompleted', {}, 'Show completed')}
43
- </button>
44
- <button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="${t('ui.board.newTask', {}, 'Create new task')}">
45
- ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
46
- </button>
47
- </div>
48
- </div>
49
-
50
- <div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
51
- ${columns.map(col => {
52
- const colTasks = tasks.filter(t => t.status === col.id);
53
- return _renderColumn(col, colTasks);
54
- }).join('')}
55
- </div>
53
+ return `
54
+ <div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
55
+ ${columns.map(col => {
56
+ const colTasks = visibleTasks.filter(t => t.status === col.id);
57
+ const wipLimit = wipLimits[col.id] ?? null;
58
+ return _renderColumn(col, colTasks, phases, wipLimit);
59
+ }).join('')}
56
60
  </div>
57
61
  `;
58
-
59
- setTimeout(() => _bindEvents(), 0);
60
- return html;
61
62
  }
62
63
 
63
- function _filterTasks(tasks) {
64
- let list = tasks;
65
- const query = state.get('searchQuery')?.toLowerCase();
66
- if (query) {
67
- list = list.filter(t =>
68
- t.title.toLowerCase().includes(query) ||
69
- t.id.toLowerCase().includes(query) ||
70
- (t.summary || '').toLowerCase().includes(query)
71
- );
72
- }
73
- if (!_showCompleted()) {
74
- list = list.filter(t => t.status !== 'completed' && t.status !== 'cancelled');
75
- }
76
- return list;
77
- }
64
+ /**
65
+ * Bind kanban-specific events: drag-drop, card click/selection, task actions.
66
+ * Call this after the board HTML has been inserted into the DOM.
67
+ */
68
+ export function bindKanbanEvents() {
69
+ const board = document.getElementById('board');
70
+ if (!board) return;
71
+
72
+ // Click on task card — select and highlight
73
+ board.addEventListener('click', e => {
74
+ const card = e.target.closest('.task-card');
75
+ if (!card) return;
76
+
77
+ const id = card.dataset.taskId;
78
+ state.update('selectedTaskId', id);
79
+
80
+ // Highlight the selected card without a full re-render
81
+ board.querySelectorAll('.task-card').forEach(c => {
82
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
83
+ c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
84
+ });
85
+ });
86
+
87
+ // Keyboard on cards (Enter = select)
88
+ board.addEventListener('keydown', e => {
89
+ const card = e.target.closest('.task-card');
90
+ if (!card) return;
91
+ if (e.key === 'Enter') {
92
+ state.update('selectedTaskId', card.dataset.taskId);
93
+ }
94
+ });
78
95
 
79
- function _showCompleted() {
80
- return sessionStorage.getItem('board-show-completed') === 'true';
96
+ // Drag & drop
97
+ _bindDragDrop(board);
81
98
  }
82
99
 
83
- function _renderColumn(col, tasks) {
100
+ /* ------------------------------------------------------------------ */
101
+ /* Internal helpers */
102
+ /* ------------------------------------------------------------------ */
103
+
104
+ function _renderColumn(col, tasks, phases, wipLimit) {
105
+ const wipExceeded = wipLimit != null && tasks.length > wipLimit;
106
+ const wipAttr = wipLimit != null ? ` data-wip-limit="${wipLimit}"` : '';
107
+
84
108
  return `
85
- <section class="board-column col-${col.id}" data-status="${col.id}" aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
86
- <div class="board-column-header">
109
+ <section class="board-column col-${col.id}" data-status="${col.id}"${wipAttr} aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
110
+ <div class="board-column-header${wipExceeded ? ' wip-exceeded' : ''}">
87
111
  <h3 class="board-column-title" id="col-${col.id}">
88
112
  <span class="board-column-dot" aria-hidden="true"></span>
89
113
  ${esc(col.label)}
90
114
  </h3>
91
- <span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}</span>
115
+ <span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}${wipLimit != null ? `/${wipLimit}` : ''}</span>
92
116
  </div>
93
117
  <div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
94
- ${tasks.map(t => _renderCard(t)).join('')}
118
+ ${tasks.map(task => _renderCard(task, phases)).join('')}
95
119
  ${tasks.length === 0
96
120
  ? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">${t('ui.board.noTasks', {}, 'No tasks')}</div>`
97
121
  : ''}
@@ -100,10 +124,9 @@ function _renderColumn(col, tasks) {
100
124
  `;
101
125
  }
102
126
 
103
- function _renderCard(task) {
127
+ function _renderCard(task, phases) {
104
128
  const isSelected = task.id === state.get('selectedTaskId');
105
129
  const statusLabels = state.getStatusLabels();
106
- const phases = state.getPhases();
107
130
  const phaseInfo = phases.find(p => p.id === task.phase);
108
131
 
109
132
  const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
@@ -132,59 +155,7 @@ function _renderCard(task) {
132
155
  `;
133
156
  }
134
157
 
135
- function _bindEvents() {
136
- const board = document.getElementById('board');
137
- if (!board) return;
138
-
139
- // Clic en task card → seleccionar y navegar a tasks
140
- board.addEventListener('click', e => {
141
- const card = e.target.closest('.task-card');
142
- if (!card) return;
143
-
144
- const id = card.dataset.taskId;
145
- state.update('selectedTaskId', id);
146
-
147
- // Navegar al editor si hubo doble clic; si un clic simple, solo marcar
148
- if (e.detail === 2) {
149
- router.navigate('tasks');
150
- } else {
151
- // Resaltar la card seleccionada sin re-render completo
152
- board.querySelectorAll('.task-card').forEach(c => {
153
- c.classList.toggle('is-selected', c.dataset.taskId === id);
154
- c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
155
- });
156
- }
157
- });
158
-
159
- // Teclado en cards (Enter = seleccionar, Space = toggle)
160
- board.addEventListener('keydown', e => {
161
- const card = e.target.closest('.task-card');
162
- if (!card) return;
163
- if (e.key === 'Enter') {
164
- state.update('selectedTaskId', card.dataset.taskId);
165
- router.navigate('tasks');
166
- }
167
- });
168
-
169
- // Toggle mostrar completadas
170
- document.getElementById('board-filter-done')?.addEventListener('click', () => {
171
- const current = _showCompleted();
172
- sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
173
- router.refresh();
174
- });
175
-
176
- // Nueva tarea
177
- document.getElementById('new-task-btn')?.addEventListener('click', () => {
178
- state.update('selectedTaskId', null);
179
- router.navigate('tasks');
180
- });
181
-
182
- // Drag & drop
183
- _bindDragDrop(board);
184
- }
185
-
186
158
  function _bindDragDrop(board) {
187
- // Dragstart
188
159
  board.addEventListener('dragstart', e => {
189
160
  const card = e.target.closest('.task-card');
190
161
  if (!card) return;
@@ -213,7 +184,6 @@ function _bindDragDrop(board) {
213
184
  board.addEventListener('dragleave', e => {
214
185
  const col = e.target.closest('.board-column');
215
186
  if (!col) return;
216
- // Solo eliminar si salimos de la columna, no de un hijo
217
187
  if (!col.contains(e.relatedTarget)) {
218
188
  col.classList.remove('is-drop-target');
219
189
  }