trackops 1.0.1 → 1.1.0

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 (57) hide show
  1. package/README.md +326 -270
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +517 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +135 -46
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +523 -0
  10. package/lib/opera.js +319 -170
  11. package/lib/registry.js +27 -13
  12. package/lib/release.js +56 -0
  13. package/lib/resources.js +42 -0
  14. package/lib/server.js +907 -554
  15. package/lib/skills.js +148 -124
  16. package/lib/workspace.js +260 -0
  17. package/locales/en.json +331 -139
  18. package/locales/es.json +331 -139
  19. package/package.json +7 -9
  20. package/scripts/skills-marketplace-smoke.js +124 -0
  21. package/scripts/smoke-tests.js +445 -0
  22. package/scripts/sync-skill-version.js +21 -0
  23. package/scripts/validate-skill.js +88 -0
  24. package/skills/trackops/SKILL.md +64 -0
  25. package/skills/trackops/agents/openai.yaml +3 -0
  26. package/skills/trackops/references/activation.md +39 -0
  27. package/skills/trackops/references/troubleshooting.md +34 -0
  28. package/skills/trackops/references/workflow.md +20 -0
  29. package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
  30. package/skills/trackops/skill.json +29 -0
  31. package/templates/opera/en/agent.md +26 -0
  32. package/templates/opera/en/genesis.md +79 -0
  33. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  34. package/templates/opera/en/references/opera-cycle.md +62 -0
  35. package/templates/opera/en/registry.md +28 -0
  36. package/templates/opera/en/router.md +39 -0
  37. package/templates/opera/genesis.md +79 -94
  38. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  39. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  40. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  41. package/ui/css/panels.css +956 -953
  42. package/ui/index.html +1 -1
  43. package/ui/js/api.js +211 -194
  44. package/ui/js/app.js +200 -199
  45. package/ui/js/i18n.js +14 -0
  46. package/ui/js/onboarding.js +439 -437
  47. package/ui/js/state.js +130 -129
  48. package/ui/js/utils.js +175 -172
  49. package/ui/js/views/board.js +255 -254
  50. package/ui/js/views/execution.js +256 -256
  51. package/ui/js/views/insights.js +340 -339
  52. package/ui/js/views/overview.js +365 -364
  53. package/ui/js/views/settings.js +340 -202
  54. package/ui/js/views/sidebar.js +131 -132
  55. package/ui/js/views/skills.js +163 -162
  56. package/ui/js/views/tasks.js +406 -405
  57. package/ui/js/views/topbar.js +239 -183
@@ -1,405 +1,406 @@
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
- }
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
+ import { t } from '../i18n.js';
12
+
13
+ export async function render() {
14
+ const payload = state.getPayload();
15
+ if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.tasks.noData', {}, 'No project data.')}</div>`;
16
+
17
+ const tasks = _filterTasks(payload.derived.tasks);
18
+ const selTask = state.findTask(state.get('selectedTaskId'));
19
+ const phases = state.getPhases();
20
+ const statusLabels = state.getStatusLabels();
21
+
22
+ const html = `
23
+ <div class="view-enter">
24
+ <div class="section-header">
25
+ <div class="section-header-left">
26
+ <p class="eyebrow">Task Studio</p>
27
+ <h2>${t('ui.tasks.title', {}, 'Task Management')}</h2>
28
+ </div>
29
+ <button class="btn btn-primary btn-sm" id="new-task-btn-top" type="button">
30
+ ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
31
+ </button>
32
+ </div>
33
+
34
+ <div class="grid-split">
35
+
36
+ <!-- Lista de tareas -->
37
+ <div style="display:flex;flex-direction:column;gap:var(--space-3)">
38
+ <!-- Quick filter -->
39
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.filters', {}, 'Status filters')}">
40
+ ${_renderStatusFilters(statusLabels, payload.derived.totals)}
41
+ </div>
42
+
43
+ <div class="stack stack-sm" id="task-list" aria-label="${t('ui.tasks.list', {}, 'Task list')}" role="list">
44
+ ${_renderTaskList(tasks, statusLabels, phases)}
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Editor de tarea -->
49
+ <div class="panel" id="task-editor" aria-label="${t('ui.tasks.editor', {}, 'Task editor')}" aria-live="polite">
50
+ ${_renderEditor(selTask, phases)}
51
+ </div>
52
+
53
+ </div>
54
+ </div>
55
+ `;
56
+
57
+ setTimeout(() => _bindEvents(), 0);
58
+ return html;
59
+ }
60
+
61
+ function _filterTasks(tasks) {
62
+ const query = state.get('searchQuery')?.toLowerCase();
63
+ const filter = sessionStorage.getItem('tasks-filter') || '';
64
+ let list = [...tasks];
65
+ if (query) {
66
+ list = list.filter(t =>
67
+ t.title.toLowerCase().includes(query) ||
68
+ (t.summary || '').toLowerCase().includes(query) ||
69
+ t.id.toLowerCase().includes(query)
70
+ );
71
+ }
72
+ if (filter) list = list.filter(t => t.status === filter);
73
+ return list;
74
+ }
75
+
76
+ function _renderStatusFilters(statusLabels, totals) {
77
+ const filters = [
78
+ { id: '', label: t('ui.tasks.all', {}, 'All'), count: totals.all },
79
+ { id: 'pending', label: statusLabels.pending || t('status.pending', {}, 'Pending'), count: totals.pending },
80
+ { id: 'in_progress', label: statusLabels.in_progress || t('status.in_progress', {}, 'In progress'), count: totals.inProgress },
81
+ { id: 'in_review', label: statusLabels.in_review || t('status.in_review', {}, 'In review'), count: totals.inReview },
82
+ { id: 'blocked', label: statusLabels.blocked || t('status.blocked', {}, 'Blocked'), count: totals.blocked },
83
+ { id: 'completed', label: statusLabels.completed || t('status.completed', {}, 'Completed'), count: totals.completed },
84
+ ];
85
+ const active = sessionStorage.getItem('tasks-filter') || '';
86
+ return filters.map(f => `
87
+ <button class="chip ${f.id === active ? 'is-active' : ''}"
88
+ type="button" data-task-filter="${esc(f.id)}"
89
+ aria-pressed="${f.id === active}">
90
+ ${esc(f.label)} <span class="badge badge-muted" style="font-size:0.65rem">${f.count}</span>
91
+ </button>
92
+ `).join('');
93
+ }
94
+
95
+ function _renderTaskList(tasks, statusLabels, phases) {
96
+ if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
97
+ const selectedId = state.get('selectedTaskId');
98
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
99
+
100
+ return tasks.map(t => {
101
+ const phase = phases.find(p => p.id === t.phase);
102
+ return `
103
+ <div class="task-card ${t.id === selectedId ? 'is-selected' : ''}"
104
+ data-task-id="${esc(t.id)}"
105
+ role="listitem" tabindex="0"
106
+ aria-selected="${t.id === selectedId}"
107
+ aria-label="${esc(t.title)}"
108
+ >
109
+ <strong class="task-card-title">${esc(t.title)}</strong>
110
+ <span class="task-card-id">${esc(t.id)}</span>
111
+ <div class="task-card-meta" style="margin-top:var(--space-2)">
112
+ <span class="badge badge-${priorityVariant[t.priority] || 'muted'}">${esc(t.priority)}</span>
113
+ <span class="badge status-${t.status}">${esc(statusLabels[t.status] || t.status)}</span>
114
+ ${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
115
+ </div>
116
+ </div>
117
+ `;
118
+ }).join('');
119
+ }
120
+
121
+ function _renderEditor(task, phases) {
122
+ const isNew = !task;
123
+ const phases_opts = phases.map(p =>
124
+ `<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
125
+ ).join('');
126
+
127
+ const statuses = [
128
+ { id: 'pending', label: t('status.pending', {}, 'Pending') },
129
+ { id: 'in_progress', label: t('status.in_progress', {}, 'In progress') },
130
+ { id: 'in_review', label: t('status.in_review', {}, 'In review') },
131
+ { id: 'blocked', label: t('status.blocked', {}, 'Blocked') },
132
+ { id: 'completed', label: t('status.completed', {}, 'Completed') },
133
+ { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') },
134
+ ];
135
+
136
+ return `
137
+ <div class="panel-header">
138
+ <div class="panel-header-left">
139
+ <p class="eyebrow">Task Studio</p>
140
+ <h3 class="panel-title" id="editor-title">${isNew ? t('ui.tasks.new', {}, 'New task') : esc(task.title)}</h3>
141
+ </div>
142
+ <div class="panel-header-right">
143
+ ${!isNew ? `
144
+ <button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" title="${t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
145
+ ${icon('timer', 14)} Timer
146
+ </button>
147
+ <button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
148
+ ${icon('copy', 14)}
149
+ </button>
150
+ ` : ''}
151
+ <button class="btn btn-ghost btn-sm" id="clear-task-btn" type="button" aria-label="${t('ui.tasks.clear', {}, 'Clear form')}">
152
+ ${icon('x', 14)}
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Action strip -->
158
+ ${!isNew ? `
159
+ <div class="panel-footer" style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.quickActions', {}, 'Quick task actions')}">
160
+ <button class="chip is-active" type="button" data-task-action="start" aria-label="${t('ui.tasks.start', {}, 'Start task')}">${t('ui.tasks.startLabel', {}, 'Start')}</button>
161
+ <button class="chip" type="button" data-task-action="review" aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
162
+ <button class="chip" type="button" data-task-action="complete" aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
163
+ <button class="chip" type="button" data-task-action="block" aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
164
+ <button class="chip" type="button" data-task-action="pending" aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
165
+ </div>
166
+ ` : ''}
167
+
168
+ <div class="panel-body">
169
+ <form id="task-form" class="stack stack-md" novalidate>
170
+
171
+ <div class="field">
172
+ <label for="task-title">${t('ui.tasks.field.title', {}, 'Title')} <span aria-hidden="true" style="color:var(--danger)">*</span></label>
173
+ <input id="task-title" name="title" type="text" required
174
+ value="${isNew ? '' : esc(task.title)}"
175
+ placeholder="${t('ui.tasks.placeholder.title', {}, 'Describe the task')}"
176
+ aria-required="true" />
177
+ </div>
178
+
179
+ <div class="field-row">
180
+ <div class="field">
181
+ <label for="task-phase">${t('ui.tasks.field.phase', {}, 'Phase')}</label>
182
+ <select id="task-phase" name="phase">${phases_opts}</select>
183
+ </div>
184
+ <div class="field">
185
+ <label for="task-priority">${t('ui.tasks.field.priority', {}, 'Priority')}</label>
186
+ <select id="task-priority" name="priority">
187
+ ${['P0','P1','P2','P3'].map(p => `<option value="${p}" ${!isNew && task.priority === p ? 'selected' : ''}>${p}</option>`).join('')}
188
+ </select>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="field-row">
193
+ <div class="field">
194
+ <label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
195
+ <select id="task-status" name="status">
196
+ ${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
197
+ </select>
198
+ </div>
199
+ <div class="field">
200
+ <label for="task-stream">${t('ui.tasks.field.stream', {}, 'Stream')}</label>
201
+ <input id="task-stream" name="stream" type="text"
202
+ value="${isNew ? 'Operations' : esc(task.stream || '')}"
203
+ placeholder="Operations" />
204
+ </div>
205
+ </div>
206
+
207
+ <div class="checkbox-field">
208
+ <input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
209
+ <label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
210
+ </div>
211
+
212
+ <div class="field">
213
+ <label for="task-summary">${t('ui.tasks.field.summary', {}, 'Summary')}</label>
214
+ <textarea id="task-summary" name="summary" rows="3"
215
+ placeholder="${t('ui.tasks.placeholder.summary', {}, 'Short description of the task')}">${isNew ? '' : esc(task.summary || '')}</textarea>
216
+ </div>
217
+
218
+ <div class="field">
219
+ <label for="task-acceptance">${t('ui.tasks.field.acceptance', {}, 'Acceptance criteria')}</label>
220
+ <textarea id="task-acceptance" name="acceptance" rows="3"
221
+ placeholder="${t('ui.tasks.placeholder.acceptance', {}, 'One criterion per line')}">${isNew ? '' : esc((task.acceptance || []).join('\n'))}</textarea>
222
+ </div>
223
+
224
+ <div class="field">
225
+ <label for="task-depends">${t('ui.tasks.field.depends', {}, 'Dependencies')}</label>
226
+ <textarea id="task-depends" name="dependsOn" rows="2"
227
+ placeholder="${t('ui.tasks.placeholder.depends', {}, 'Dependent task ID, one per line')}">${isNew ? '' : esc((task.dependsOn || []).join('\n'))}</textarea>
228
+ </div>
229
+
230
+ <div class="field">
231
+ <label for="task-blocker">${t('ui.tasks.field.blocker', {}, 'Blocker')}</label>
232
+ <textarea id="task-blocker" name="blocker" rows="2"
233
+ placeholder="${t('ui.tasks.placeholder.blocker', {}, 'Describe the blocker if applicable')}">${isNew ? '' : esc(task.blocker || '')}</textarea>
234
+ </div>
235
+
236
+ <div class="field">
237
+ <label for="task-note">${t('ui.tasks.field.note', {}, 'Update note')}</label>
238
+ <textarea id="task-note" name="note" rows="2"
239
+ placeholder="${t('ui.tasks.placeholder.note', {}, 'Optional note to append to history')}"></textarea>
240
+ </div>
241
+
242
+ <div class="form-actions">
243
+ <button class="btn btn-primary" type="submit" id="save-task-btn">
244
+ ${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
245
+ </button>
246
+ </div>
247
+
248
+ </form>
249
+
250
+ ${!isNew && task.history?.length ? `
251
+ <div style="margin-top:var(--space-6)">
252
+ <p class="eyebrow" style="margin-bottom:var(--space-3)">${t('ui.tasks.history', {}, 'History')}</p>
253
+ <div class="stack stack-sm">
254
+ ${task.history.slice(-5).reverse().map(h => `
255
+ <div class="info-row">
256
+ <p class="label-sm">${formatDate(h.at)}</p>
257
+ <p class="value">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
258
+ </div>
259
+ `).join('')}
260
+ </div>
261
+ </div>
262
+ ` : ''}
263
+ </div>
264
+ `;
265
+ }
266
+
267
+ function _bindEvents() {
268
+ // Seleccionar tarea de la lista
269
+ document.getElementById('task-list')?.addEventListener('click', e => {
270
+ const card = e.target.closest('[data-task-id]');
271
+ if (!card) return;
272
+ const id = card.dataset.taskId;
273
+ state.update('selectedTaskId', id);
274
+ const editor = document.getElementById('task-editor');
275
+ if (editor) {
276
+ const phases = state.getPhases();
277
+ const selTask = state.findTask(id);
278
+ editor.innerHTML = _renderEditor(selTask, phases);
279
+ _bindEditorForm();
280
+ }
281
+ // Actualizar selección en lista
282
+ document.querySelectorAll('[data-task-id]').forEach(c => {
283
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
284
+ c.setAttribute('aria-selected', c.dataset.taskId === id);
285
+ });
286
+ });
287
+
288
+ // Filtros de estado
289
+ document.querySelectorAll('[data-task-filter]').forEach(btn => {
290
+ btn.addEventListener('click', () => {
291
+ sessionStorage.setItem('tasks-filter', btn.dataset.taskFilter);
292
+ import('../router.js').then(r => r.refresh());
293
+ });
294
+ });
295
+
296
+ // Nueva tarea
297
+ document.getElementById('new-task-btn-top')?.addEventListener('click', () => {
298
+ state.update('selectedTaskId', null);
299
+ const editor = document.getElementById('task-editor');
300
+ if (editor) {
301
+ editor.innerHTML = _renderEditor(null, state.getPhases());
302
+ _bindEditorForm();
303
+ }
304
+ });
305
+
306
+ _bindEditorForm();
307
+ }
308
+
309
+ function _bindEditorForm() {
310
+ // Clear button
311
+ document.getElementById('clear-task-btn')?.addEventListener('click', () => {
312
+ state.update('selectedTaskId', null);
313
+ const editor = document.getElementById('task-editor');
314
+ if (editor) editor.innerHTML = _renderEditor(null, state.getPhases());
315
+ _bindEditorForm();
316
+ });
317
+
318
+ // Duplicate button
319
+ document.getElementById('duplicate-btn')?.addEventListener('click', () => {
320
+ const task = state.findTask(state.get('selectedTaskId'));
321
+ if (!task) return;
322
+ state.update('selectedTaskId', null);
323
+ const editor = document.getElementById('task-editor');
324
+ if (editor) {
325
+ editor.innerHTML = _renderEditor({ ...task, title: `${task.title} (${t('ui.tasks.copySuffix', {}, 'copy')})`, status: 'pending', history: [] }, state.getPhases());
326
+ _bindEditorForm();
327
+ document.getElementById('task-title')?.focus();
328
+ }
329
+ });
330
+
331
+ // Timer quick start
332
+ document.getElementById('timer-quick-btn')?.addEventListener('click', async () => {
333
+ const task = state.findTask(state.get('selectedTaskId'));
334
+ if (!task) return;
335
+ await timeTracker.start(task.id, task.title);
336
+ });
337
+
338
+ // Action strip
339
+ document.querySelectorAll('[data-task-action]').forEach(btn => {
340
+ btn.addEventListener('click', async () => {
341
+ const taskId = state.get('selectedTaskId');
342
+ if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
343
+ const action = btn.dataset.taskAction;
344
+ const note = document.getElementById('task-note')?.value?.trim() || '';
345
+ try {
346
+ await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`));
347
+ flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
348
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
349
+ } catch (err) {
350
+ flash(err.message, 'error');
351
+ }
352
+ });
353
+ });
354
+
355
+ // Submit form
356
+ const form = document.getElementById('task-form');
357
+ form?.addEventListener('submit', async e => {
358
+ e.preventDefault();
359
+ const btn = document.getElementById('save-task-btn');
360
+ if (btn) btn.disabled = true;
361
+ try {
362
+ await _submitForm();
363
+ } finally {
364
+ if (btn) btn.disabled = false;
365
+ }
366
+ });
367
+ }
368
+
369
+ async function _submitForm() {
370
+ const get = id => document.getElementById(id);
371
+
372
+ const payload = {
373
+ title: get('task-title')?.value.trim(),
374
+ phase: get('task-phase')?.value,
375
+ priority: get('task-priority')?.value,
376
+ status: get('task-status')?.value,
377
+ stream: get('task-stream')?.value.trim(),
378
+ required: get('task-required')?.checked,
379
+ summary: get('task-summary')?.value.trim(),
380
+ acceptance: splitLines(get('task-acceptance')?.value || ''),
381
+ dependsOn: splitLines(get('task-depends')?.value || ''),
382
+ blocker: get('task-blocker')?.value.trim(),
383
+ note: get('task-note')?.value.trim(),
384
+ };
385
+
386
+ if (!payload.title) {
387
+ flash('El título es obligatorio.', 'error');
388
+ get('task-title')?.focus();
389
+ return;
390
+ }
391
+
392
+ const selectedId = state.get('selectedTaskId');
393
+ try {
394
+ if (selectedId) {
395
+ await api.updateTask(selectedId, payload);
396
+ flash('Tarea actualizada.', 'success');
397
+ } else {
398
+ const result = await api.createTask(payload);
399
+ state.update('selectedTaskId', result.task?.id);
400
+ flash('Tarea creada.', 'success');
401
+ }
402
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
403
+ } catch (err) {
404
+ flash(err.message, 'error');
405
+ }
406
+ }