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.
- package/README.md +326 -270
- package/bin/trackops.js +102 -70
- package/lib/config.js +260 -35
- package/lib/control.js +517 -475
- package/lib/env.js +227 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +135 -46
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +523 -0
- package/lib/opera.js +319 -170
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/server.js +907 -554
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +331 -139
- package/locales/es.json +331 -139
- package/package.json +7 -9
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +445 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +88 -0
- package/skills/trackops/SKILL.md +64 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +39 -0
- package/skills/trackops/references/troubleshooting.md +34 -0
- package/skills/trackops/references/workflow.md +20 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/opera/en/agent.md +26 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/router.md +39 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
- package/ui/css/panels.css +956 -953
- package/ui/index.html +1 -1
- package/ui/js/api.js +211 -194
- package/ui/js/app.js +200 -199
- package/ui/js/i18n.js +14 -0
- package/ui/js/onboarding.js +439 -437
- package/ui/js/state.js +130 -129
- package/ui/js/utils.js +175 -172
- package/ui/js/views/board.js +255 -254
- package/ui/js/views/execution.js +256 -256
- package/ui/js/views/insights.js +340 -339
- package/ui/js/views/overview.js +365 -364
- package/ui/js/views/settings.js +340 -202
- package/ui/js/views/sidebar.js +131 -132
- package/ui/js/views/skills.js +163 -162
- package/ui/js/views/tasks.js +406 -405
- package/ui/js/views/topbar.js +239 -183
package/ui/js/views/tasks.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
t.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{ id: '
|
|
79
|
-
{ id: '
|
|
80
|
-
{ id: '
|
|
81
|
-
{ id: '
|
|
82
|
-
{ id: '
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
${
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
aria-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
<span class="badge
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{ id: '
|
|
129
|
-
{ id: '
|
|
130
|
-
{ id: '
|
|
131
|
-
{ id: '
|
|
132
|
-
{ id: '
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<button class="chip" type="button" data-task-action="
|
|
161
|
-
<button class="chip" type="button" data-task-action="
|
|
162
|
-
<button class="chip" type="button" data-task-action="
|
|
163
|
-
<button class="chip" type="button" data-task-action="
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
<p class="
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
c.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
form
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
}
|