trackops 2.0.5 → 2.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 +319 -695
- package/bin/trackops.js +52 -23
- package/lib/cli-format.js +118 -0
- package/lib/config.js +277 -44
- package/lib/control.js +1052 -352
- package/lib/env.js +40 -28
- package/lib/i18n.js +5 -4
- package/lib/init.js +194 -56
- package/lib/opera-bootstrap.js +326 -106
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +243 -78
- package/lib/plans.js +1329 -0
- package/lib/quality-assert.js +49 -0
- package/lib/quality.js +1759 -0
- package/lib/release.js +18 -11
- package/lib/server.js +504 -192
- package/lib/skills.js +43 -35
- package/lib/workspace.js +32 -21
- package/locales/en.json +431 -75
- package/locales/es.json +432 -76
- package/package.json +6 -5
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/smoke-tests.js +438 -96
- package/skills/trackops/skill.json +29 -29
- package/templates/skills/opera-quality-guard/SKILL.md +26 -0
- package/templates/skills/opera-quality-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/opera-skill/SKILL.md +8 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +8 -0
- package/ui/js/api.js +93 -26
- package/ui/js/app.js +13 -7
- package/ui/js/filters.js +49 -29
- package/ui/js/time-tracker.js +41 -28
- package/ui/js/views/board.js +22 -14
- package/ui/js/views/dashboard.js +206 -49
- package/ui/js/views/execution.js +7 -3
- package/ui/js/views/plans.js +284 -0
- package/ui/js/views/scrum.js +25 -13
- package/ui/js/views/sidebar.js +9 -8
- package/ui/js/views/tasks.js +238 -134
package/ui/js/views/scrum.js
CHANGED
|
@@ -278,11 +278,16 @@ function _renderCard(task, phases) {
|
|
|
278
278
|
<div style="display:flex;justify-content:space-between;align-items:start;gap:var(--space-2, 0.5rem)">
|
|
279
279
|
<span class="scrum-card-title" style="font-size:var(--text-sm, 0.875rem);font-weight:500;color:var(--text-primary);line-height:1.3">${esc(task.title)}</span>
|
|
280
280
|
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
|
|
281
|
-
</div>
|
|
282
|
-
<div class="scrum-card-meta" style="display:flex;flex-wrap:wrap;gap:var(--space-1, 0.25rem);margin-top:var(--space-2, 0.5rem)">
|
|
283
|
-
<span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
|
|
284
|
-
${
|
|
285
|
-
|
|
281
|
+
</div>
|
|
282
|
+
<div class="scrum-card-meta" style="display:flex;flex-wrap:wrap;gap:var(--space-1, 0.25rem);margin-top:var(--space-2, 0.5rem)">
|
|
283
|
+
<span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
|
|
284
|
+
${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
|
|
285
|
+
${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
|
|
286
|
+
${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
|
|
287
|
+
${task.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
|
|
288
|
+
${task.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
|
|
289
|
+
${isBlocked ? `<span class="badge badge-danger">${icon('alertTriangle', 10)} Blocked</span>` : ''}
|
|
290
|
+
</div>
|
|
286
291
|
</div>
|
|
287
292
|
`;
|
|
288
293
|
}
|
|
@@ -309,10 +314,13 @@ function _renderBacklog(pendingTasks, phases) {
|
|
|
309
314
|
<div class="scrum-backlog-item" data-task-id="${esc(task.id)}" draggable="true"
|
|
310
315
|
style="display:flex;align-items:center;gap:var(--space-3, 0.75rem);padding:var(--space-2, 0.5rem) var(--space-3, 0.75rem);border-radius:var(--radius-sm, 4px);border:1px solid var(--border, rgba(255,255,255,0.08));cursor:grab"
|
|
311
316
|
role="listitem" tabindex="0">
|
|
312
|
-
<span style="flex:1;font-size:var(--text-sm, 0.875rem);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(task.title)}</span>
|
|
313
|
-
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
|
|
314
|
-
${phaseInfo ? `<span class="badge badge-muted" style="flex-shrink:0">${esc(phaseInfo.label)}</span>` : ''}
|
|
315
|
-
|
|
317
|
+
<span style="flex:1;font-size:var(--text-sm, 0.875rem);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(task.title)}</span>
|
|
318
|
+
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
|
|
319
|
+
${phaseInfo ? `<span class="badge badge-muted" style="flex-shrink:0">${esc(phaseInfo.label)}</span>` : ''}
|
|
320
|
+
${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
|
|
321
|
+
${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
|
|
322
|
+
${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
|
|
323
|
+
</div>
|
|
316
324
|
`;
|
|
317
325
|
}).join('');
|
|
318
326
|
|
|
@@ -460,10 +468,14 @@ function _bindDragDrop(board, columnSelector) {
|
|
|
460
468
|
const targetStatuses = SCRUM_COLUMNS.find(c => c.id === scrumStatus)?.statuses || [];
|
|
461
469
|
if (targetStatuses.includes(task.status)) return; // already there
|
|
462
470
|
|
|
463
|
-
try {
|
|
464
|
-
await api.taskAction(taskId, action,
|
|
465
|
-
t('ui.scrum.movedFromBoard', { status: scrumStatus }, `Moved to ${scrumStatus} from sprint board.`)
|
|
466
|
-
|
|
471
|
+
try {
|
|
472
|
+
await api.taskAction(taskId, action,
|
|
473
|
+
t('ui.scrum.movedFromBoard', { status: scrumStatus }, `Moved to ${scrumStatus} from sprint board.`),
|
|
474
|
+
{
|
|
475
|
+
actor: 'user',
|
|
476
|
+
source: 'scrum',
|
|
477
|
+
}
|
|
478
|
+
);
|
|
467
479
|
flash(
|
|
468
480
|
t('ui.scrum.movedSuccess', { status: scrumStatus }, `Task moved to ${scrumStatus}.`),
|
|
469
481
|
'success'
|
package/ui/js/views/sidebar.js
CHANGED
|
@@ -11,14 +11,15 @@ import * as consoleLogger from '../console-logger.js';
|
|
|
11
11
|
import * as onboarding from '../onboarding.js';
|
|
12
12
|
import { t } from '../i18n.js';
|
|
13
13
|
|
|
14
|
-
const NAV_ITEMS = [
|
|
15
|
-
{ id: 'projects', label: 'Projects', icon: 'folder' },
|
|
16
|
-
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
|
|
17
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
21
|
-
|
|
14
|
+
const NAV_ITEMS = [
|
|
15
|
+
{ id: 'projects', label: 'Projects', icon: 'folder' },
|
|
16
|
+
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
|
|
17
|
+
{ id: 'plans', label: 'Plans', icon: 'fileText' },
|
|
18
|
+
{ id: 'tasks', label: 'Tasks', icon: 'tasks', badge: true },
|
|
19
|
+
{ id: 'timeline', label: 'Timeline', icon: 'timeline' },
|
|
20
|
+
{ id: 'terminal', label: 'Terminal', icon: 'terminal2' },
|
|
21
|
+
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
|
22
|
+
];
|
|
22
23
|
|
|
23
24
|
const FOOTER_ITEMS = [
|
|
24
25
|
{ id: 'help', label: 'Help & Tour', icon: 'help', action: 'tour' },
|
package/ui/js/views/tasks.js
CHANGED
|
@@ -19,7 +19,12 @@ import * as timeTracker from '../time-tracker.js';
|
|
|
19
19
|
|
|
20
20
|
/* ─────────────────────────── Mode state ─────────────────────────── */
|
|
21
21
|
|
|
22
|
-
let _activeMode = localStorage.getItem('ops-tasks-mode') || 'kanban';
|
|
22
|
+
let _activeMode = localStorage.getItem('ops-tasks-mode') || 'kanban';
|
|
23
|
+
let _collapsedNodes = new Set(JSON.parse(sessionStorage.getItem('ops-task-collapsed') || '[]'));
|
|
24
|
+
|
|
25
|
+
function _persistCollapsed() {
|
|
26
|
+
sessionStorage.setItem('ops-task-collapsed', JSON.stringify([..._collapsedNodes]));
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
/* ─────────────────────────── Public API ─────────────────────────── */
|
|
25
30
|
|
|
@@ -28,13 +33,15 @@ export async function render() {
|
|
|
28
33
|
if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.tasks.noData', {}, 'No project data.')}</div>`;
|
|
29
34
|
|
|
30
35
|
const projectName = payload.control?.project?.name || payload.control?.name || '';
|
|
31
|
-
const activeFilters = filters.load('tasks');
|
|
32
|
-
const allTasks = payload.derived?.tasks || [];
|
|
33
|
-
const filteredTasks = filters.apply(allTasks, activeFilters);
|
|
34
|
-
const phases = state.getPhases();
|
|
35
|
-
const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
|
|
36
|
-
|
|
37
|
-
const
|
|
36
|
+
const activeFilters = filters.load('tasks');
|
|
37
|
+
const allTasks = payload.derived?.tasks || [];
|
|
38
|
+
const filteredTasks = filters.apply(allTasks, activeFilters);
|
|
39
|
+
const phases = state.getPhases();
|
|
40
|
+
const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
|
|
41
|
+
const sources = [...new Set(allTasks.map(t => t.sourceId || t.origin?.sourceId).filter(Boolean))];
|
|
42
|
+
const roots = allTasks.filter(t => t.rootId === t.id).map(t => ({ id: t.id, title: t.title }));
|
|
43
|
+
|
|
44
|
+
const modeContent = _renderModeContent(filteredTasks, phases, payload);
|
|
38
45
|
|
|
39
46
|
return `
|
|
40
47
|
<section class="view-section view-enter">
|
|
@@ -53,11 +60,11 @@ export async function render() {
|
|
|
53
60
|
${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
|
|
54
61
|
</button>
|
|
55
62
|
</div>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<div id="tasks-filter-bar">
|
|
59
|
-
${filters.renderBar('tasks', activeFilters, { phases, streams })}
|
|
60
|
-
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div id="tasks-filter-bar">
|
|
66
|
+
${filters.renderBar('tasks', activeFilters, { phases, streams, sources, roots })}
|
|
67
|
+
</div>
|
|
61
68
|
|
|
62
69
|
<div id="tasks-mode-content">
|
|
63
70
|
${modeContent}
|
|
@@ -128,12 +135,21 @@ function _refreshModeContent() {
|
|
|
128
135
|
const payload = state.getPayload();
|
|
129
136
|
if (!payload) return;
|
|
130
137
|
|
|
131
|
-
const activeFilters = filters.load('tasks');
|
|
132
|
-
const allTasks = payload.derived?.tasks || [];
|
|
133
|
-
const filteredTasks = filters.apply(allTasks, activeFilters);
|
|
134
|
-
const phases = state.getPhases();
|
|
135
|
-
|
|
136
|
-
const
|
|
138
|
+
const activeFilters = filters.load('tasks');
|
|
139
|
+
const allTasks = payload.derived?.tasks || [];
|
|
140
|
+
const filteredTasks = filters.apply(allTasks, activeFilters);
|
|
141
|
+
const phases = state.getPhases();
|
|
142
|
+
const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
|
|
143
|
+
const sources = [...new Set(allTasks.map(t => t.sourceId || t.origin?.sourceId).filter(Boolean))];
|
|
144
|
+
const roots = allTasks.filter(t => t.rootId === t.id).map(t => ({ id: t.id, title: t.title }));
|
|
145
|
+
|
|
146
|
+
const filterBar = document.getElementById('tasks-filter-bar');
|
|
147
|
+
if (filterBar) {
|
|
148
|
+
filterBar.innerHTML = filters.renderBar('tasks', activeFilters, { phases, streams, sources, roots });
|
|
149
|
+
filters.bindBar('tasks', () => _refreshModeContent());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const container = document.getElementById('tasks-mode-content');
|
|
137
153
|
if (container) {
|
|
138
154
|
container.innerHTML = _renderModeContent(filteredTasks, phases, payload);
|
|
139
155
|
}
|
|
@@ -212,39 +228,70 @@ function _renderStatusFilters(statusLabels, totals) {
|
|
|
212
228
|
|
|
213
229
|
/* ─── Task list cards ─── */
|
|
214
230
|
|
|
215
|
-
function _renderTaskList(tasks, statusLabels, phases) {
|
|
216
|
-
if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
|
|
217
|
-
const selectedId = state.get('selectedTaskId');
|
|
218
|
-
const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
231
|
+
function _renderTaskList(tasks, statusLabels, phases) {
|
|
232
|
+
if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
|
|
233
|
+
const selectedId = state.get('selectedTaskId');
|
|
234
|
+
const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
235
|
+
const visibleTasks = tasks.filter(tk => {
|
|
236
|
+
let currentParentId = tk.parentId;
|
|
237
|
+
while (currentParentId) {
|
|
238
|
+
if (_collapsedNodes.has(currentParentId)) return false;
|
|
239
|
+
const parent = tasks.find(entry => entry.id === currentParentId);
|
|
240
|
+
currentParentId = parent?.parentId || null;
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return visibleTasks.map(tk => {
|
|
246
|
+
const phase = phases.find(p => p.id === tk.phase);
|
|
247
|
+
const isCollapsed = _collapsedNodes.has(tk.id);
|
|
248
|
+
const indent = (tk.depth || 0) * 18;
|
|
249
|
+
return `
|
|
250
|
+
<div class="task-card ${tk.id === selectedId ? 'is-selected' : ''}"
|
|
251
|
+
data-task-id="${esc(tk.id)}"
|
|
252
|
+
role="listitem" tabindex="0"
|
|
253
|
+
aria-selected="${tk.id === selectedId}"
|
|
254
|
+
aria-label="${esc(tk.title)}"
|
|
255
|
+
style="padding-left:calc(var(--space-4) + ${indent}px)"
|
|
256
|
+
>
|
|
257
|
+
<div style="display:flex;align-items:flex-start;gap:var(--space-2)">
|
|
258
|
+
${tk.isParent ? `
|
|
259
|
+
<button class="btn btn-ghost btn-sm" type="button" data-task-toggle="${esc(tk.id)}" aria-label="${isCollapsed ? 'Expand children' : 'Collapse children'}" style="padding:0;min-width:auto">
|
|
260
|
+
${icon(isCollapsed ? 'arrowRight' : 'chevronDown', 14)}
|
|
261
|
+
</button>
|
|
262
|
+
` : `<span style="width:14px;display:inline-block"></span>`}
|
|
263
|
+
<strong class="task-card-title">${esc(tk.title)}</strong>
|
|
264
|
+
</div>
|
|
265
|
+
<span class="task-card-id">${esc(tk.id)}</span>
|
|
266
|
+
<div class="task-card-meta" style="margin-top:var(--space-2)">
|
|
267
|
+
<span class="badge badge-${priorityVariant[tk.priority] || 'muted'}">${esc(tk.priority)}</span>
|
|
268
|
+
<span class="badge status-${tk.status}">${esc(statusLabels[tk.status] || tk.status)}</span>
|
|
269
|
+
${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
|
|
270
|
+
${tk.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${tk.executionOwner}`)}</span>` : ''}
|
|
271
|
+
${tk.sourceId ? `<span class="badge badge-muted">${esc(`plan:${tk.sourceId}`)}</span>` : ''}
|
|
272
|
+
${tk.rootId && tk.rootId !== tk.id ? `<span class="badge badge-muted">${esc(`root:${tk.rootTitle}`)}</span>` : ''}
|
|
273
|
+
${tk.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
|
|
274
|
+
${tk.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
|
|
275
|
+
${tk.detached ? `<span class="badge badge-danger">${t('ui.tasks.detached', {}, 'Detached')}</span>` : ''}
|
|
276
|
+
${tk.isParent ? `<span class="badge badge-muted">${esc(`${tk.childrenCount} child${tk.childrenCount === 1 ? '' : 'ren'}`)}</span>` : ''}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
`;
|
|
280
|
+
}).join('');
|
|
281
|
+
}
|
|
240
282
|
|
|
241
283
|
/* ─── Task editor ─── */
|
|
242
284
|
|
|
243
|
-
function _renderEditor(task, phases) {
|
|
244
|
-
const isNew = !task;
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
285
|
+
function _renderEditor(task, phases) {
|
|
286
|
+
const isNew = !task;
|
|
287
|
+
const allTasks = state.getPayload()?.derived?.tasks || [];
|
|
288
|
+
const parentOptions = allTasks
|
|
289
|
+
.filter(candidate => !task || candidate.id !== task.id)
|
|
290
|
+
.map(candidate => `<option value="${esc(candidate.id)}" ${!isNew && task.parentId === candidate.id ? 'selected' : ''}>${esc(candidate.title)} (${esc(candidate.id)})</option>`)
|
|
291
|
+
.join('');
|
|
292
|
+
const phasesOpts = phases.map(p =>
|
|
293
|
+
`<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
|
|
294
|
+
).join('');
|
|
248
295
|
|
|
249
296
|
const statuses = [
|
|
250
297
|
{ id: 'pending', label: t('status.pending', {}, 'Pending') },
|
|
@@ -260,32 +307,32 @@ function _renderEditor(task, phases) {
|
|
|
260
307
|
<div class="panel-header-left">
|
|
261
308
|
<p class="eyebrow">Task Studio</p>
|
|
262
309
|
<h3 class="panel-title" id="editor-title">${isNew ? t('ui.tasks.new', {}, 'New task') : esc(task.title)}</h3>
|
|
263
|
-
</div>
|
|
264
|
-
<div class="panel-header-right">
|
|
265
|
-
${!isNew ? `
|
|
266
|
-
<button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" title="${t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
|
|
267
|
-
${icon('timer', 14)} Timer
|
|
268
|
-
</button>
|
|
269
|
-
<button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
|
|
270
|
-
${icon('copy', 14)}
|
|
271
|
-
</button>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="panel-header-right">
|
|
312
|
+
${!isNew ? `
|
|
313
|
+
<button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" ${task.isParent ? 'disabled' : ''} title="${task.isParent ? t('ui.tasks.parentTimerHint', {}, 'Track time on leaf tasks only.') : t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
|
|
314
|
+
${icon('timer', 14)} Timer
|
|
315
|
+
</button>
|
|
316
|
+
<button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
|
|
317
|
+
${icon('copy', 14)}
|
|
318
|
+
</button>
|
|
272
319
|
` : ''}
|
|
273
320
|
<button class="btn btn-ghost btn-sm" id="clear-task-btn" type="button" aria-label="${t('ui.tasks.clear', {}, 'Clear form')}">
|
|
274
321
|
${icon('x', 14)}
|
|
275
322
|
</button>
|
|
276
323
|
</div>
|
|
277
324
|
</div>
|
|
278
|
-
|
|
279
|
-
<!-- Quick action strip -->
|
|
280
|
-
${!isNew ? `
|
|
281
|
-
<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')}">
|
|
282
|
-
<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>
|
|
283
|
-
<button class="chip" type="button" data-task-action="review" aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
|
|
284
|
-
<button class="chip" type="button" data-task-action="complete" aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
|
|
285
|
-
<button class="chip" type="button" data-task-action="block" aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
|
|
286
|
-
<button class="chip" type="button" data-task-action="pending" aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
|
|
287
|
-
</div>
|
|
288
|
-
` : ''}
|
|
325
|
+
|
|
326
|
+
<!-- Quick action strip -->
|
|
327
|
+
${!isNew ? `
|
|
328
|
+
<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')}">
|
|
329
|
+
<button class="chip is-active" type="button" data-task-action="start" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.start', {}, 'Start task')}">${t('ui.tasks.startLabel', {}, 'Start')}</button>
|
|
330
|
+
<button class="chip" type="button" data-task-action="review" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
|
|
331
|
+
<button class="chip" type="button" data-task-action="complete" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
|
|
332
|
+
<button class="chip" type="button" data-task-action="block" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
|
|
333
|
+
<button class="chip" type="button" data-task-action="pending" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
|
|
334
|
+
</div>
|
|
335
|
+
` : ''}
|
|
289
336
|
|
|
290
337
|
<div class="panel-body">
|
|
291
338
|
<form id="task-form" class="stack stack-md" novalidate>
|
|
@@ -312,29 +359,55 @@ function _renderEditor(task, phases) {
|
|
|
312
359
|
</div>
|
|
313
360
|
|
|
314
361
|
<div class="field-row">
|
|
315
|
-
<div class="field">
|
|
316
|
-
<label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
|
|
317
|
-
<select id="task-status" name="status">
|
|
318
|
-
${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
|
|
319
|
-
</select>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
<
|
|
324
|
-
|
|
362
|
+
<div class="field">
|
|
363
|
+
<label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
|
|
364
|
+
<select id="task-status" name="status" ${!isNew && task.isParent ? 'disabled' : ''}>
|
|
365
|
+
${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
|
|
366
|
+
</select>
|
|
367
|
+
${!isNew && task.isParent ? `<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:var(--space-1)">${t('ui.tasks.parentStatusHint', {}, 'Parent status is rolled up from its leaf tasks.')}</p>` : ''}
|
|
368
|
+
</div>
|
|
369
|
+
<div class="field">
|
|
370
|
+
<label for="task-stream">${t('ui.tasks.field.stream', {}, 'Stream')}</label>
|
|
371
|
+
<input id="task-stream" name="stream" type="text"
|
|
372
|
+
value="${isNew ? 'Operations' : esc(task.stream || '')}"
|
|
325
373
|
placeholder="Operations" />
|
|
326
374
|
</div>
|
|
327
375
|
</div>
|
|
328
376
|
|
|
329
|
-
<div class="checkbox-field">
|
|
330
|
-
<input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
|
|
331
|
-
<label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
|
|
332
|
-
</div>
|
|
333
|
-
|
|
334
|
-
<div class="field">
|
|
335
|
-
<label for="task-
|
|
336
|
-
<
|
|
337
|
-
|
|
377
|
+
<div class="checkbox-field">
|
|
378
|
+
<input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
|
|
379
|
+
<label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div class="field">
|
|
383
|
+
<label for="task-execution-owner">${t('ui.tasks.field.executionOwner', {}, 'Execution owner')}</label>
|
|
384
|
+
<select id="task-execution-owner" name="executionOwner">
|
|
385
|
+
${[
|
|
386
|
+
{ id: 'shared', label: t('ui.tasks.execution.shared', {}, 'Shared') },
|
|
387
|
+
{ id: 'agent', label: t('ui.tasks.execution.agent', {}, 'Agent') },
|
|
388
|
+
{ id: 'user', label: t('ui.tasks.execution.user', {}, 'User') },
|
|
389
|
+
].map(option => `<option value="${option.id}" ${(!isNew ? (task.executionOwner || task.execution?.owner || 'shared') : 'shared') === option.id ? 'selected' : ''}>${esc(option.label)}</option>`).join('')}
|
|
390
|
+
</select>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div class="field-row">
|
|
394
|
+
<div class="field">
|
|
395
|
+
<label for="task-parent">${t('ui.tasks.field.parent', {}, 'Parent')}</label>
|
|
396
|
+
<select id="task-parent" name="parentId">
|
|
397
|
+
<option value="">${t('ui.tasks.none', {}, 'None')}</option>
|
|
398
|
+
${parentOptions}
|
|
399
|
+
</select>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="field">
|
|
402
|
+
<label for="task-sequence">${t('ui.tasks.field.sequence', {}, 'Sequence')}</label>
|
|
403
|
+
<input id="task-sequence" name="sequence" type="number" step="10" value="${isNew ? '' : esc(task.sequence ?? '')}" placeholder="10" />
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<div class="field">
|
|
408
|
+
<label for="task-summary">${t('ui.tasks.field.summary', {}, 'Summary')}</label>
|
|
409
|
+
<textarea id="task-summary" name="summary" rows="3"
|
|
410
|
+
placeholder="${t('ui.tasks.placeholder.summary', {}, 'Short description of the task')}">${isNew ? '' : esc(task.summary || '')}</textarea>
|
|
338
411
|
</div>
|
|
339
412
|
|
|
340
413
|
<div class="field">
|
|
@@ -361,16 +434,30 @@ function _renderEditor(task, phases) {
|
|
|
361
434
|
placeholder="${t('ui.tasks.placeholder.note', {}, 'Optional note to append to history')}"></textarea>
|
|
362
435
|
</div>
|
|
363
436
|
|
|
364
|
-
<div class="form-actions">
|
|
365
|
-
<button class="btn btn-primary" type="submit" id="save-task-btn">
|
|
366
|
-
${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
|
|
367
|
-
</button>
|
|
368
|
-
</div>
|
|
369
|
-
|
|
370
|
-
</form>
|
|
371
|
-
|
|
372
|
-
${!isNew
|
|
373
|
-
<div style="margin-top:var(--space-
|
|
437
|
+
<div class="form-actions">
|
|
438
|
+
<button class="btn btn-primary" type="submit" id="save-task-btn">
|
|
439
|
+
${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
</form>
|
|
444
|
+
|
|
445
|
+
${!isNew ? `
|
|
446
|
+
<div class="panel" style="margin-top:var(--space-4)">
|
|
447
|
+
<p class="eyebrow">${t('ui.tasks.traceability', {}, 'Traceability')}</p>
|
|
448
|
+
<div class="stack stack-sm">
|
|
449
|
+
<div class="info-row"><p class="label-sm">Origin</p><p class="value">${esc(task.origin?.kind || 'manual')}</p></div>
|
|
450
|
+
<div class="info-row"><p class="label-sm">Source</p><p class="value">${esc(task.sourceId || task.origin?.sourceId || 'manual')}</p></div>
|
|
451
|
+
<div class="info-row"><p class="label-sm">Detached</p><p class="value">${task.detached ? 'yes' : 'no'}</p></div>
|
|
452
|
+
<div class="info-row"><p class="label-sm">Execution</p><p class="value">${esc(task.executionOwner || task.execution?.owner || 'shared')}</p></div>
|
|
453
|
+
<div class="info-row"><p class="label-sm">Awaiting user</p><p class="value">${task.awaitingUserConfirmation ? 'yes' : 'no'}</p></div>
|
|
454
|
+
<div class="info-row"><p class="label-sm">Verify state</p><p class="value">${task.verificationPending ? 'yes' : 'no'}</p></div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
` : ''}
|
|
458
|
+
|
|
459
|
+
${!isNew && task.history?.length ? `
|
|
460
|
+
<div style="margin-top:var(--space-6)">
|
|
374
461
|
<p class="eyebrow" style="margin-bottom:var(--space-3)">${t('ui.tasks.history', {}, 'History')}</p>
|
|
375
462
|
<div class="stack stack-sm">
|
|
376
463
|
${task.history.slice(-5).reverse().map(h => `
|
|
@@ -388,11 +475,22 @@ function _renderEditor(task, phases) {
|
|
|
388
475
|
|
|
389
476
|
/* ─── List mode event binding ─── */
|
|
390
477
|
|
|
391
|
-
function _bindListEvents() {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
478
|
+
function _bindListEvents() {
|
|
479
|
+
document.querySelectorAll('[data-task-toggle]').forEach(button => {
|
|
480
|
+
button.addEventListener('click', event => {
|
|
481
|
+
event.stopPropagation();
|
|
482
|
+
const taskId = button.dataset.taskToggle;
|
|
483
|
+
if (_collapsedNodes.has(taskId)) _collapsedNodes.delete(taskId);
|
|
484
|
+
else _collapsedNodes.add(taskId);
|
|
485
|
+
_persistCollapsed();
|
|
486
|
+
_refreshModeContent();
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Select task from list
|
|
491
|
+
document.getElementById('task-list')?.addEventListener('click', e => {
|
|
492
|
+
const card = e.target.closest('[data-task-id]');
|
|
493
|
+
if (!card) return;
|
|
396
494
|
const id = card.dataset.taskId;
|
|
397
495
|
state.update('selectedTaskId', id);
|
|
398
496
|
const editor = document.getElementById('task-editor');
|
|
@@ -455,17 +553,20 @@ function _bindEditorForm() {
|
|
|
455
553
|
// Quick action strip
|
|
456
554
|
document.querySelectorAll('[data-task-action]').forEach(btn => {
|
|
457
555
|
btn.addEventListener('click', async () => {
|
|
458
|
-
const taskId = state.get('selectedTaskId');
|
|
459
|
-
if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
|
|
460
|
-
const action = btn.dataset.taskAction;
|
|
461
|
-
const note = document.getElementById('task-note')?.value?.trim() || '';
|
|
462
|
-
try {
|
|
463
|
-
await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
flash(
|
|
468
|
-
|
|
556
|
+
const taskId = state.get('selectedTaskId');
|
|
557
|
+
if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
|
|
558
|
+
const action = btn.dataset.taskAction;
|
|
559
|
+
const note = document.getElementById('task-note')?.value?.trim() || '';
|
|
560
|
+
try {
|
|
561
|
+
await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`), {
|
|
562
|
+
actor: 'user',
|
|
563
|
+
source: 'tasks_view',
|
|
564
|
+
});
|
|
565
|
+
flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
|
|
566
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
567
|
+
} catch (err) {
|
|
568
|
+
flash(err.message, 'error');
|
|
569
|
+
}
|
|
469
570
|
});
|
|
470
571
|
});
|
|
471
572
|
|
|
@@ -486,18 +587,21 @@ function _bindEditorForm() {
|
|
|
486
587
|
async function _submitForm() {
|
|
487
588
|
const get = id => document.getElementById(id);
|
|
488
589
|
|
|
489
|
-
const payload = {
|
|
490
|
-
title: get('task-title')?.value.trim(),
|
|
491
|
-
phase: get('task-phase')?.value,
|
|
492
|
-
priority: get('task-priority')?.value,
|
|
493
|
-
status: get('task-status')?.value,
|
|
494
|
-
stream: get('task-stream')?.value.trim(),
|
|
495
|
-
required: get('task-required')?.checked,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
590
|
+
const payload = {
|
|
591
|
+
title: get('task-title')?.value.trim(),
|
|
592
|
+
phase: get('task-phase')?.value,
|
|
593
|
+
priority: get('task-priority')?.value,
|
|
594
|
+
status: get('task-status')?.value,
|
|
595
|
+
stream: get('task-stream')?.value.trim(),
|
|
596
|
+
required: get('task-required')?.checked,
|
|
597
|
+
executionOwner: get('task-execution-owner')?.value || 'shared',
|
|
598
|
+
parentId: get('task-parent')?.value || '',
|
|
599
|
+
sequence: get('task-sequence')?.value || '',
|
|
600
|
+
summary: get('task-summary')?.value.trim(),
|
|
601
|
+
acceptance: splitLines(get('task-acceptance')?.value || ''),
|
|
602
|
+
dependsOn: splitLines(get('task-depends')?.value || ''),
|
|
603
|
+
blocker: get('task-blocker')?.value.trim(),
|
|
604
|
+
note: get('task-note')?.value.trim(),
|
|
501
605
|
};
|
|
502
606
|
|
|
503
607
|
if (!payload.title) {
|
|
@@ -508,14 +612,14 @@ async function _submitForm() {
|
|
|
508
612
|
|
|
509
613
|
const selectedId = state.get('selectedTaskId');
|
|
510
614
|
try {
|
|
511
|
-
if (selectedId) {
|
|
512
|
-
await api.updateTask(selectedId, payload);
|
|
513
|
-
flash(t('ui.tasks.taskUpdated', {}, 'Task updated.'), 'success');
|
|
514
|
-
} else {
|
|
515
|
-
const result = await api.createTask(payload);
|
|
516
|
-
state.update('selectedTaskId', result.task?.id);
|
|
517
|
-
flash(t('ui.tasks.taskCreated', {}, 'Task created.'), 'success');
|
|
518
|
-
}
|
|
615
|
+
if (selectedId) {
|
|
616
|
+
await api.updateTask(selectedId, payload, { actor: 'user', source: 'tasks_editor' });
|
|
617
|
+
flash(t('ui.tasks.taskUpdated', {}, 'Task updated.'), 'success');
|
|
618
|
+
} else {
|
|
619
|
+
const result = await api.createTask({ ...payload, actor: 'user', source: 'tasks_editor' });
|
|
620
|
+
state.update('selectedTaskId', result.task?.id);
|
|
621
|
+
flash(t('ui.tasks.taskCreated', {}, 'Task created.'), 'success');
|
|
622
|
+
}
|
|
519
623
|
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
520
624
|
} catch (err) {
|
|
521
625
|
flash(err.message, 'error');
|