trackops 1.0.0 → 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 (72) hide show
  1. package/README.md +341 -232
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +518 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +146 -55
  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 +912 -418
  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 +14 -3
  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/etapa/agent.md +2 -2
  32. package/templates/etapa/references/etapa-cycle.md +1 -1
  33. package/templates/opera/agent.md +1 -1
  34. package/templates/opera/en/agent.md +26 -0
  35. package/templates/opera/en/genesis.md +79 -0
  36. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  37. package/templates/opera/en/references/opera-cycle.md +62 -0
  38. package/templates/opera/en/registry.md +28 -0
  39. package/templates/opera/en/router.md +39 -0
  40. package/templates/opera/genesis.md +79 -94
  41. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  42. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  43. package/templates/skills/project-starter-skill/SKILL.md +5 -3
  44. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  45. package/ui/css/base.css +266 -0
  46. package/ui/css/charts.css +327 -0
  47. package/ui/css/components.css +570 -0
  48. package/ui/css/panels.css +956 -0
  49. package/ui/css/tokens.css +227 -0
  50. package/ui/favicon.svg +5 -0
  51. package/ui/index.html +91 -351
  52. package/ui/js/api.js +220 -0
  53. package/ui/js/app.js +200 -0
  54. package/ui/js/console-logger.js +172 -0
  55. package/ui/js/i18n.js +14 -0
  56. package/ui/js/icons.js +104 -0
  57. package/ui/js/onboarding.js +439 -0
  58. package/ui/js/router.js +125 -0
  59. package/ui/js/state.js +130 -0
  60. package/ui/js/theme.js +100 -0
  61. package/ui/js/time-tracker.js +248 -0
  62. package/ui/js/utils.js +175 -0
  63. package/ui/js/views/board.js +255 -0
  64. package/ui/js/views/execution.js +256 -0
  65. package/ui/js/views/flash.js +47 -0
  66. package/ui/js/views/insights.js +340 -0
  67. package/ui/js/views/overview.js +365 -0
  68. package/ui/js/views/settings.js +381 -0
  69. package/ui/js/views/sidebar.js +131 -0
  70. package/ui/js/views/skills.js +163 -0
  71. package/ui/js/views/tasks.js +406 -0
  72. package/ui/js/views/topbar.js +239 -0
@@ -0,0 +1,163 @@
1
+ import * as api from '../api.js';
2
+ import * as flash from './flash.js';
3
+ import { t } from '../i18n.js';
4
+
5
+ export function init() {
6
+ // Sin estado local init por ahora
7
+ }
8
+
9
+ export async function render() {
10
+ return `
11
+ <div class="view-enter">
12
+ <header class="section-header">
13
+ <div>
14
+ <h2 class="section-title">${t('ui.skills.title', {}, 'Skill hub')}</h2>
15
+ <p class="section-desc">${t('ui.skills.desc', {}, 'Manage your copilot skills and install new community workflows.')}</p>
16
+ </div>
17
+ <div class="actions">
18
+ <button class="btn btn-ghost btn-sm" id="btn-refresh-skills">
19
+ <svg viewBox="0 0 24 24" fill="none" class="icon" style="width:16px;height:16px"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
20
+ ${t('ui.skills.refresh', {}, 'Refresh')}
21
+ </button>
22
+ </div>
23
+ </header>
24
+
25
+ <div class="grid-split" style="grid-template-columns: 1fr 1fr; gap: var(--space-6);">
26
+
27
+ <!-- Panel Izquierdo: Capacidades Locales -->
28
+ <div class="panel">
29
+ <div class="panel-header">
30
+ <h3 class="panel-title">${t('ui.skills.installed', {}, 'Installed capabilities')}</h3>
31
+ </div>
32
+ <div id="skills-local-list" class="stack" style="gap: var(--space-4); max-height: calc(100vh - 200px); overflow-y: auto; padding: 2px;">
33
+ <div class="card"><div class="card-body"><p class="text-sm color-muted">${t('ui.skills.loading', {}, 'Loading...')}</p></div></div>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Panel Derecho: Discover (skills.sh) -->
38
+ <div class="panel" style="border-color: var(--accent);">
39
+ <div class="panel-header" style="justify-content: space-between;">
40
+ <h3 class="panel-title" style="color: var(--accent);">✨ ${t('ui.skills.discover', {}, 'Discover')}</h3>
41
+ <span class="badge badge-success">${t('ui.skills.recommended', {}, 'Recommended')}</span>
42
+ </div>
43
+ <div id="skills-discover-list" class="stack" style="gap: var(--space-4); max-height: calc(100vh - 200px); overflow-y: auto; padding: 2px;">
44
+ <div class="card"><div class="card-body"><p class="text-sm color-muted">${t('ui.skills.connecting', {}, 'Connecting...')}</p></div></div>
45
+ </div>
46
+ </div>
47
+
48
+ </div>
49
+ </div>
50
+ `;
51
+ }
52
+
53
+ export function bindEvents() {
54
+ const btn = document.getElementById('btn-refresh-skills');
55
+ if (btn) {
56
+ btn.addEventListener('click', loadData);
57
+ }
58
+ }
59
+
60
+ export async function loadData() {
61
+ const localEl = document.getElementById('skills-local-list');
62
+ const discoverEl = document.getElementById('skills-discover-list');
63
+
64
+ if (!localEl || !discoverEl) return;
65
+
66
+ localEl.innerHTML = `<p class="text-sm text-muted">${t('ui.skills.loadingLocal', {}, 'Loading local data...')}</p>`;
67
+ discoverEl.innerHTML = `<p class="text-sm text-muted">${t('ui.skills.connectingRemote', {}, 'Connecting to skills.sh...')}</p>`;
68
+
69
+ try {
70
+ const [localRes, discoverRes] = await Promise.all([
71
+ api.fetchSkillsLocal(),
72
+ api.fetchSkillsDiscover()
73
+ ]);
74
+
75
+ // DOM Locales
76
+ if (localRes.ok && localRes.skills) {
77
+ if (localRes.skills.length === 0) {
78
+ localEl.innerHTML = `
79
+ <div class="empty-state">
80
+ <div class="empty-icon">🧠</div>
81
+ <p class="empty-title">Sin habilidades locales</p>
82
+ <p class="empty-desc" style="font-size:var(--text-xs); color:var(--text-muted)">Tu agente no tiene skills especializadas. Instala alguna desde el panel derecho.</p>
83
+ </div>
84
+ `;
85
+ } else {
86
+ localEl.innerHTML = localRes.skills.map(s => `
87
+ <div class="card">
88
+ <div class="card-body">
89
+ <div style="display: flex; gap: 12px; align-items: center;">
90
+ <div style="font-size: 24px;">⚙️</div>
91
+ <div>
92
+ <h4 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: 4px;">${s.title}</h4>
93
+ <p style="font-size: var(--text-xs); color: var(--text-secondary);">${s.description || 'Sin descripción'}</p>
94
+ <code style="font-size: 10px; margin-top: 8px; display: inline-block; background: var(--surface-3); padding: 2px 4px; border-radius: 4px;">${s.path}</code>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ `).join('');
100
+ }
101
+ }
102
+
103
+ // DOM Discover
104
+ if (discoverRes.ok && discoverRes.catalog) {
105
+ // Filter out already installed
106
+ const installedIds = (localRes.skills || []).map(s => s.id);
107
+ const available = discoverRes.catalog.filter(c => !installedIds.includes(c.id));
108
+
109
+ if (available.length === 0) {
110
+ discoverEl.innerHTML = `
111
+ <div class="empty-state">
112
+ <div class="empty-icon">✨</div>
113
+ <p class="empty-title" style="margin-bottom:var(--space-2)">Todo instalado</p>
114
+ <p class="empty-desc" style="font-size:var(--text-xs); color:var(--text-muted)">Ya tienes instaladas todas las habilidades recomendadas.</p>
115
+ </div>
116
+ `;
117
+ } else {
118
+ discoverEl.innerHTML = available.map(s => `
119
+ <div class="card" style="border: 1px dashed var(--border-accent);">
120
+ <div class="card-body">
121
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;">
122
+ <div style="flex:1;">
123
+ <h4 style="font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">${s.title}</h4>
124
+ <p style="font-size: var(--text-xs); color: var(--text-secondary);">${s.description}</p>
125
+ </div>
126
+ <button class="btn btn-primary btn-sm btn-install-skill" data-id="${s.id}" style="padding: 4px 12px; font-size: 11px;">Instalar</button>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ `).join('');
131
+
132
+ // Bind events
133
+ discoverEl.querySelectorAll('.btn-install-skill').forEach(btn => {
134
+ btn.addEventListener('click', async (e) => {
135
+ const id = e.target.dataset.id;
136
+ const originalText = e.target.textContent;
137
+ e.target.textContent = 'Instalando...';
138
+ e.target.disabled = true;
139
+
140
+ const res = await api.installSkill(id).catch(err => {
141
+ flash.show('error', err.message);
142
+ return null;
143
+ });
144
+
145
+ if (res && res.ok) {
146
+ flash.show('success', `Skill ${id} instalada correctamente en /.agents/skills`);
147
+ loadData(); // recargar
148
+ } else {
149
+ e.target.textContent = originalText;
150
+ e.target.disabled = false;
151
+ }
152
+ });
153
+ });
154
+ }
155
+ }
156
+
157
+ } catch (err) {
158
+ console.error(err);
159
+ flash.show('error', 'Error conectando con la API de Skills.');
160
+ localEl.innerHTML = `<p class="color-danger text-sm">Error cargando.</p>`;
161
+ discoverEl.innerHTML = `<p class="color-danger text-sm">Error conectando.</p>`;
162
+ }
163
+ }
@@ -0,0 +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
+ 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
+ }