trackops 2.0.3 → 2.0.5

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 (103) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -402
  3. package/bin/trackops.js +116 -116
  4. package/lib/config.js +326 -326
  5. package/lib/control.js +208 -208
  6. package/lib/env.js +244 -244
  7. package/lib/init.js +325 -325
  8. package/lib/locale.js +24 -0
  9. package/lib/opera-bootstrap.js +941 -874
  10. package/lib/opera.js +494 -477
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -196
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/server.js +312 -207
  16. package/lib/skills.js +74 -57
  17. package/lib/workspace.js +260 -260
  18. package/locales/en.json +192 -166
  19. package/locales/es.json +192 -166
  20. package/package.json +61 -58
  21. package/scripts/postinstall-locale.js +21 -21
  22. package/scripts/skills-marketplace-smoke.js +124 -124
  23. package/scripts/smoke-tests.js +558 -554
  24. package/scripts/sync-skill-version.js +21 -21
  25. package/scripts/validate-skill.js +103 -103
  26. package/skills/trackops/SKILL.md +126 -122
  27. package/skills/trackops/agents/openai.yaml +7 -7
  28. package/skills/trackops/locales/en/SKILL.md +126 -122
  29. package/skills/trackops/locales/en/references/activation.md +94 -75
  30. package/skills/trackops/locales/en/references/troubleshooting.md +73 -55
  31. package/skills/trackops/locales/en/references/workflow.md +55 -32
  32. package/skills/trackops/references/activation.md +94 -75
  33. package/skills/trackops/references/troubleshooting.md +73 -55
  34. package/skills/trackops/references/workflow.md +55 -32
  35. package/skills/trackops/skill.json +29 -29
  36. package/templates/hooks/post-checkout +2 -2
  37. package/templates/hooks/post-commit +2 -2
  38. package/templates/hooks/post-merge +2 -2
  39. package/templates/opera/agent.md +28 -27
  40. package/templates/opera/architecture/dependency-graph.md +24 -24
  41. package/templates/opera/architecture/runtime-automation.md +24 -24
  42. package/templates/opera/architecture/runtime-operations.md +34 -34
  43. package/templates/opera/en/agent.md +22 -21
  44. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  45. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  46. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  47. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  48. package/templates/opera/en/reviews/integration-audit.md +18 -18
  49. package/templates/opera/en/router.md +24 -19
  50. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  51. package/templates/opera/references/opera-cycle.md +193 -193
  52. package/templates/opera/registry.md +28 -28
  53. package/templates/opera/reviews/delivery-audit.md +18 -18
  54. package/templates/opera/reviews/integration-audit.md +18 -18
  55. package/templates/opera/router.md +54 -49
  56. package/templates/skills/changelog-updater/SKILL.md +69 -69
  57. package/templates/skills/commiter/SKILL.md +99 -99
  58. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  59. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  60. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  61. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  62. package/templates/skills/opera-skill/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  64. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  65. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  66. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  67. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  68. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  69. package/ui/css/base.css +284 -266
  70. package/ui/css/charts.css +425 -327
  71. package/ui/css/components.css +1107 -570
  72. package/ui/css/onboarding.css +133 -0
  73. package/ui/css/panels.css +345 -406
  74. package/ui/css/terminal.css +125 -0
  75. package/ui/css/timeline.css +58 -0
  76. package/ui/css/tokens.css +284 -227
  77. package/ui/favicon.svg +5 -5
  78. package/ui/index.html +99 -96
  79. package/ui/js/api.js +49 -13
  80. package/ui/js/app.js +28 -32
  81. package/ui/js/charts.js +526 -0
  82. package/ui/js/console-logger.js +172 -172
  83. package/ui/js/filters.js +247 -0
  84. package/ui/js/icons.js +129 -104
  85. package/ui/js/keyboard.js +229 -0
  86. package/ui/js/onboarding.js +33 -42
  87. package/ui/js/router.js +142 -125
  88. package/ui/js/theme.js +100 -100
  89. package/ui/js/time-tracker.js +248 -248
  90. package/ui/js/views/board.js +84 -114
  91. package/ui/js/views/dashboard.js +870 -0
  92. package/ui/js/views/flash.js +47 -47
  93. package/ui/js/views/projects.js +745 -0
  94. package/ui/js/views/scrum.js +476 -0
  95. package/ui/js/views/settings.js +153 -203
  96. package/ui/js/views/sidebar.js +37 -31
  97. package/ui/js/views/tasks.js +218 -101
  98. package/ui/js/views/timeline.js +265 -0
  99. package/ui/js/views/topbar.js +94 -107
  100. package/ui/app.js +0 -950
  101. package/ui/js/views/insights.js +0 -340
  102. package/ui/js/views/overview.js +0 -369
  103. package/ui/styles.css +0 -688
@@ -0,0 +1,476 @@
1
+ /**
2
+ * scrum.js — Scrum mode view for the Tasks page
3
+ *
4
+ * Exports:
5
+ * renderScrum(tasks, milestones, phases, opts) — returns HTML string
6
+ * bindScrumEvents() — binds all interactions
7
+ */
8
+
9
+ import * as state from '../state.js';
10
+ import * as api from '../api.js';
11
+ import { icon } from '../icons.js';
12
+ import { t } from '../i18n.js';
13
+ import { esc, formatDate } from '../utils.js';
14
+ import { lineChart } from '../charts.js';
15
+ import { flash } from './flash.js';
16
+
17
+ /* ------------------------------------------------------------------ */
18
+ /* Constants */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ const SCRUM_COLUMNS = [
22
+ { id: 'todo', label: 'To Do', statuses: ['pending'], dotColor: 'var(--accent-blue, var(--accent))' },
23
+ { id: 'doing', label: 'Doing', statuses: ['in_progress', 'in_review', 'blocked'], dotColor: 'var(--accent-cyan, var(--accent))' },
24
+ { id: 'done', label: 'Done', statuses: ['completed'], dotColor: 'var(--success, var(--accent-green, #22c55e))' },
25
+ ];
26
+
27
+ const STATUS_ACTION_MAP = {
28
+ todo: 'pending',
29
+ doing: 'start',
30
+ done: 'complete',
31
+ };
32
+
33
+ const SPRINT_DAYS = 14;
34
+
35
+ let _dragTaskId = null;
36
+
37
+ /* ------------------------------------------------------------------ */
38
+ /* Sprint helpers */
39
+ /* ------------------------------------------------------------------ */
40
+
41
+ /**
42
+ * Determine the current sprint from milestones.
43
+ * Current sprint = nearest future milestone, or most recent past one.
44
+ */
45
+ function _currentSprint(milestones) {
46
+ if (!milestones || milestones.length === 0) return null;
47
+
48
+ const now = Date.now();
49
+ const sorted = [...milestones]
50
+ .filter(m => m.date)
51
+ .sort((a, b) => new Date(a.date) - new Date(b.date));
52
+
53
+ // Nearest future milestone
54
+ const future = sorted.find(m => new Date(m.date) >= now);
55
+ if (future) return future;
56
+
57
+ // Most recent past milestone
58
+ return sorted[sorted.length - 1] || null;
59
+ }
60
+
61
+ /**
62
+ * Compute sprint start date: either the milestone before the current one,
63
+ * or current milestone date minus SPRINT_DAYS.
64
+ */
65
+ function _sprintDateRange(sprint, milestones) {
66
+ if (!sprint) {
67
+ const end = new Date();
68
+ const start = new Date(end);
69
+ start.setDate(start.getDate() - SPRINT_DAYS);
70
+ return { start, end };
71
+ }
72
+
73
+ const end = new Date(sprint.date);
74
+ const sorted = [...milestones]
75
+ .filter(m => m.date)
76
+ .sort((a, b) => new Date(a.date) - new Date(b.date));
77
+ const idx = sorted.findIndex(m => m.id === sprint.id);
78
+ const prev = idx > 0 ? sorted[idx - 1] : null;
79
+
80
+ const start = prev ? new Date(prev.date) : new Date(end);
81
+ if (!prev) start.setDate(start.getDate() - SPRINT_DAYS);
82
+
83
+ return { start, end };
84
+ }
85
+
86
+ function _daysRemaining(endDate) {
87
+ const diff = Math.ceil((endDate - Date.now()) / (1000 * 60 * 60 * 24));
88
+ return Math.max(0, diff);
89
+ }
90
+
91
+ /**
92
+ * Build burndown data points for the line chart.
93
+ * Simple model: assumes tasks complete linearly across sprint range.
94
+ */
95
+ function _buildBurndownData(sprintTasks, startDate, endDate) {
96
+ const totalDays = Math.max(1, Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)));
97
+ const total = sprintTasks.length;
98
+ const completedTasks = sprintTasks.filter(t => t.status === 'completed');
99
+
100
+ // Ideal line
101
+ const points = [];
102
+ const step = Math.max(1, Math.floor(totalDays / Math.min(totalDays, 10)));
103
+
104
+ for (let d = 0; d <= totalDays; d += step) {
105
+ const date = new Date(startDate);
106
+ date.setDate(date.getDate() + d);
107
+ const dayStr = date.toISOString().slice(5, 10); // MM-DD
108
+
109
+ // Count tasks completed by this date
110
+ const doneByDate = completedTasks.filter(task => {
111
+ const history = task.history || [];
112
+ const completedEntry = history.find(h => h.action === 'complete' || h.status === 'completed');
113
+ if (completedEntry) {
114
+ return new Date(completedEntry.timestamp || completedEntry.date) <= date;
115
+ }
116
+ return false;
117
+ }).length;
118
+
119
+ const remaining = total - doneByDate;
120
+ points.push({ label: dayStr, value: Math.max(0, remaining) });
121
+ }
122
+
123
+ // Ensure we have at least 2 points
124
+ if (points.length < 2) {
125
+ points.push({ label: 'Start', value: total });
126
+ points.push({ label: 'Now', value: total - completedTasks.length });
127
+ }
128
+
129
+ return points;
130
+ }
131
+
132
+ /* ------------------------------------------------------------------ */
133
+ /* Render */
134
+ /* ------------------------------------------------------------------ */
135
+
136
+ /**
137
+ * Render the full Scrum view.
138
+ *
139
+ * @param {Array} tasks — full task list
140
+ * @param {Array} milestones — milestones from project_control.json
141
+ * @param {Array} phases — phase objects ({ id, label, ... })
142
+ * @param {Object} opts — options
143
+ * @returns {string} HTML string
144
+ */
145
+ export function renderScrum(tasks, milestones = [], phases = [], opts = {}) {
146
+ const sprint = _currentSprint(milestones);
147
+ const { start: sprintStart, end: sprintEnd } = _sprintDateRange(sprint, milestones);
148
+ const daysLeft = _daysRemaining(sprintEnd);
149
+
150
+ // Partition tasks by scrum category
151
+ const activeTasks = tasks.filter(t => t.status !== 'cancelled');
152
+ const todoTasks = activeTasks.filter(t => t.status === 'pending');
153
+ const doingTasks = activeTasks.filter(t =>
154
+ t.status === 'in_progress' || t.status === 'in_review' || t.status === 'blocked'
155
+ );
156
+ const doneTasks = activeTasks.filter(t => t.status === 'completed');
157
+
158
+ // Sprint tasks = all non-cancelled (the sprint encompasses active work)
159
+ const sprintTasks = [...todoTasks, ...doingTasks, ...doneTasks];
160
+ const committed = sprintTasks.length;
161
+ const completed = doneTasks.length;
162
+ const remaining = committed - completed;
163
+
164
+ // Burndown
165
+ const burndownData = _buildBurndownData(sprintTasks, sprintStart, sprintEnd);
166
+ const burndownSvg = lineChart(burndownData, {
167
+ width: 200,
168
+ height: 80,
169
+ color: 'var(--accent-blue, var(--accent))',
170
+ showDots: false,
171
+ showGrid: false,
172
+ fill: true,
173
+ animate: true,
174
+ });
175
+
176
+ // Sprint info
177
+ const sprintTitle = sprint ? esc(sprint.title) : 'Current Sprint';
178
+ const sprintMeta = sprint
179
+ ? `${formatDate(sprintStart, 'date')} — ${formatDate(sprintEnd, 'date')} &middot; ${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} remaining`
180
+ : `${formatDate(sprintStart, 'date')} — ${formatDate(sprintEnd, 'date')} &middot; ${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} remaining`;
181
+
182
+ // No milestones notice
183
+ const noMilestonesNotice = milestones.length === 0
184
+ ? `<div class="glass-card" style="text-align:center;padding:var(--space-8, 2rem)">
185
+ <p style="color:var(--text-secondary)">No sprints configured yet.</p>
186
+ <p style="color:var(--text-muted);font-size:var(--text-sm, 0.875rem)">
187
+ Add milestones to your project to enable sprint tracking.
188
+ </p>
189
+ </div>`
190
+ : '';
191
+
192
+ // Column data for the board
193
+ const columnData = [
194
+ { ...SCRUM_COLUMNS[0], tasks: todoTasks },
195
+ { ...SCRUM_COLUMNS[1], tasks: doingTasks },
196
+ { ...SCRUM_COLUMNS[2], tasks: doneTasks },
197
+ ];
198
+
199
+ return `
200
+ <div class="scrum-view" style="display:flex;flex-direction:column;gap:var(--space-4, 1rem)">
201
+
202
+ <!-- Sprint Panel -->
203
+ <div class="glass-card scrum-sprint-panel">
204
+ <div class="scrum-sprint-header" style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-4, 1rem);justify-content:space-between">
205
+ <div style="min-width:180px">
206
+ <h3 class="scrum-sprint-title" style="margin:0;font-size:var(--text-lg, 1.125rem);color:var(--text-primary)">${sprintTitle}</h3>
207
+ <p class="scrum-sprint-meta" style="margin:var(--space-1, 0.25rem) 0 0;font-size:var(--text-sm, 0.875rem);color:var(--text-muted)">${sprintMeta}</p>
208
+ </div>
209
+ <div class="scrum-sprint-stats" style="display:flex;gap:var(--space-5, 1.25rem);text-align:center">
210
+ <div class="scrum-stat">
211
+ <span class="scrum-stat-value" style="display:block;font-size:var(--text-xl, 1.25rem);font-weight:700;color:var(--text-primary);font-variant-numeric:tabular-nums">${committed}</span>
212
+ <span class="scrum-stat-label" style="font-size:var(--text-xs, 0.75rem);color:var(--text-muted)">Committed</span>
213
+ </div>
214
+ <div class="scrum-stat">
215
+ <span class="scrum-stat-value" style="display:block;font-size:var(--text-xl, 1.25rem);font-weight:700;color:var(--success, #22c55e);font-variant-numeric:tabular-nums">${completed}</span>
216
+ <span class="scrum-stat-label" style="font-size:var(--text-xs, 0.75rem);color:var(--text-muted)">Done</span>
217
+ </div>
218
+ <div class="scrum-stat">
219
+ <span class="scrum-stat-value" style="display:block;font-size:var(--text-xl, 1.25rem);font-weight:700;color:var(--warning, #f59e0b);font-variant-numeric:tabular-nums">${remaining}</span>
220
+ <span class="scrum-stat-label" style="font-size:var(--text-xs, 0.75rem);color:var(--text-muted)">Remaining</span>
221
+ </div>
222
+ </div>
223
+ <!-- Mini burndown -->
224
+ <div class="scrum-burndown" id="scrum-burndown-chart" style="flex-shrink:0">
225
+ ${burndownSvg}
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ ${noMilestonesNotice}
231
+
232
+ <!-- Sprint Board (3 columns) -->
233
+ <div class="scrum-board" id="scrum-board" style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3, 0.75rem);min-height:300px" role="region" aria-label="Sprint board">
234
+ ${columnData.map(col => _renderColumn(col, phases)).join('')}
235
+ </div>
236
+
237
+ <!-- Product Backlog -->
238
+ ${_renderBacklog(todoTasks, phases)}
239
+ </div>
240
+ `;
241
+ }
242
+
243
+ /* ------------------------------------------------------------------ */
244
+ /* Column rendering */
245
+ /* ------------------------------------------------------------------ */
246
+
247
+ function _renderColumn(col, phases) {
248
+ return `
249
+ <div class="scrum-column" data-scrum-status="${col.id}" style="display:flex;flex-direction:column;background:var(--surface-2, rgba(255,255,255,0.03));border-radius:var(--radius-md, 8px);border:1px solid var(--border, rgba(255,255,255,0.08));overflow:hidden">
250
+ <div class="scrum-column-header" style="display:flex;align-items:center;gap:var(--space-2, 0.5rem);padding:var(--space-3, 0.75rem) var(--space-3, 0.75rem);border-bottom:1px solid var(--border, rgba(255,255,255,0.08))">
251
+ <span class="scrum-column-dot" style="width:8px;height:8px;border-radius:50%;background:${col.dotColor};flex-shrink:0" aria-hidden="true"></span>
252
+ <span style="font-size:var(--text-sm, 0.875rem);font-weight:600;color:var(--text-primary)">${esc(col.label)}</span>
253
+ <span class="scrum-column-count" style="margin-left:auto;font-size:var(--text-xs, 0.75rem);color:var(--text-muted);font-variant-numeric:tabular-nums">${col.tasks.length}</span>
254
+ </div>
255
+ <div class="scrum-column-body" style="flex:1;padding:var(--space-2, 0.5rem);display:flex;flex-direction:column;gap:var(--space-2, 0.5rem);overflow-y:auto;min-height:100px" role="list">
256
+ ${col.tasks.map(task => _renderCard(task, phases)).join('')}
257
+ ${col.tasks.length === 0
258
+ ? `<div style="padding:var(--space-5, 1.25rem);text-align:center;color:var(--text-muted);font-size:var(--text-sm, 0.875rem);border:1px dashed var(--border, rgba(255,255,255,0.08));border-radius:var(--radius-sm, 4px);min-height:80px;display:flex;align-items:center;justify-content:center">No tasks</div>`
259
+ : ''}
260
+ </div>
261
+ </div>
262
+ `;
263
+ }
264
+
265
+ /* ------------------------------------------------------------------ */
266
+ /* Card rendering */
267
+ /* ------------------------------------------------------------------ */
268
+
269
+ function _renderCard(task, phases) {
270
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
271
+ const phaseInfo = phases.find(p => p.id === task.phase);
272
+ const isBlocked = task.status === 'blocked';
273
+
274
+ return `
275
+ <div class="scrum-card glass-card" data-task-id="${esc(task.id)}" draggable="true" role="listitem" tabindex="0"
276
+ style="padding:var(--space-3, 0.75rem);border-radius:var(--radius-sm, 4px);cursor:grab"
277
+ aria-label="${esc(task.title)}, ${esc(task.status)}, priority ${esc(task.priority)}">
278
+ <div style="display:flex;justify-content:space-between;align-items:start;gap:var(--space-2, 0.5rem)">
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
+ <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
+ ${isBlocked ? `<span class="badge badge-danger">${icon('alertTriangle', 10)} Blocked</span>` : ''}
285
+ </div>
286
+ </div>
287
+ `;
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ */
291
+ /* Backlog rendering */
292
+ /* ------------------------------------------------------------------ */
293
+
294
+ function _renderBacklog(pendingTasks, phases) {
295
+ // Product backlog = pending tasks (never started)
296
+ const backlogTasks = pendingTasks.filter(task => {
297
+ const history = task.history || [];
298
+ // Only show tasks that have never been moved out of pending
299
+ return history.length === 0 || history.every(h => h.status === 'pending' || h.action === 'create');
300
+ });
301
+
302
+ if (backlogTasks.length === 0) return '';
303
+
304
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
305
+
306
+ const items = backlogTasks.map(task => {
307
+ const phaseInfo = phases.find(p => p.id === task.phase);
308
+ return `
309
+ <div class="scrum-backlog-item" data-task-id="${esc(task.id)}" draggable="true"
310
+ 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
+ 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
+ </div>
316
+ `;
317
+ }).join('');
318
+
319
+ return `
320
+ <div class="glass-card scrum-backlog" style="margin-top:var(--space-2, 0.5rem)">
321
+ <div class="scrum-backlog-header" style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-3, 0.75rem);border-bottom:1px solid var(--border, rgba(255,255,255,0.08))">
322
+ <h3 style="margin:0;font-size:var(--text-base, 1rem);color:var(--text-primary)">Product Backlog</h3>
323
+ <span class="badge badge-muted">${backlogTasks.length} items</span>
324
+ </div>
325
+ <div class="scrum-backlog-body" style="padding:var(--space-2, 0.5rem);display:flex;flex-direction:column;gap:var(--space-2, 0.5rem);max-height:300px;overflow-y:auto" role="list">
326
+ ${items}
327
+ </div>
328
+ </div>
329
+ `;
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ */
333
+ /* Event binding */
334
+ /* ------------------------------------------------------------------ */
335
+
336
+ /**
337
+ * Bind scrum-specific events: drag-drop between columns, card selection.
338
+ * Call this after the scrum HTML has been inserted into the DOM.
339
+ */
340
+ export function bindScrumEvents() {
341
+ const board = document.getElementById('scrum-board');
342
+ if (!board) return;
343
+
344
+ // Card click — select and highlight
345
+ board.addEventListener('click', e => {
346
+ const card = e.target.closest('.scrum-card');
347
+ if (!card) return;
348
+
349
+ const id = card.dataset.taskId;
350
+ state.update('selectedTaskId', id);
351
+
352
+ board.querySelectorAll('.scrum-card').forEach(c => {
353
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
354
+ c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
355
+ });
356
+ });
357
+
358
+ // Keyboard: Enter = select
359
+ board.addEventListener('keydown', e => {
360
+ const card = e.target.closest('.scrum-card');
361
+ if (!card) return;
362
+ if (e.key === 'Enter') {
363
+ state.update('selectedTaskId', card.dataset.taskId);
364
+ }
365
+ });
366
+
367
+ // Drag & drop on the sprint board columns
368
+ _bindDragDrop(board, '.scrum-column');
369
+
370
+ // Also bind drag on backlog items
371
+ const backlog = document.querySelector('.scrum-backlog-body');
372
+ if (backlog) {
373
+ backlog.addEventListener('dragstart', e => {
374
+ const item = e.target.closest('.scrum-backlog-item');
375
+ if (!item) return;
376
+ _dragTaskId = item.dataset.taskId;
377
+ item.classList.add('is-dragging');
378
+ item.style.opacity = '0.4';
379
+ e.dataTransfer.setData('text/plain', _dragTaskId);
380
+ e.dataTransfer.effectAllowed = 'move';
381
+ });
382
+
383
+ backlog.addEventListener('dragend', e => {
384
+ const item = e.target.closest('.scrum-backlog-item');
385
+ if (item) {
386
+ item.classList.remove('is-dragging');
387
+ item.style.opacity = '';
388
+ }
389
+ _dragTaskId = null;
390
+ });
391
+ }
392
+ }
393
+
394
+ /* ------------------------------------------------------------------ */
395
+ /* Drag & drop */
396
+ /* ------------------------------------------------------------------ */
397
+
398
+ function _bindDragDrop(board, columnSelector) {
399
+ board.addEventListener('dragstart', e => {
400
+ const card = e.target.closest('.scrum-card');
401
+ if (!card) return;
402
+ _dragTaskId = card.dataset.taskId;
403
+ card.classList.add('is-dragging');
404
+ card.style.opacity = '0.4';
405
+ e.dataTransfer.setData('text/plain', _dragTaskId);
406
+ e.dataTransfer.effectAllowed = 'move';
407
+ });
408
+
409
+ board.addEventListener('dragend', e => {
410
+ const card = e.target.closest('.scrum-card');
411
+ if (card) {
412
+ card.classList.remove('is-dragging');
413
+ card.style.opacity = '';
414
+ }
415
+ _dragTaskId = null;
416
+ board.querySelectorAll(columnSelector).forEach(col => col.classList.remove('is-drop-target'));
417
+ });
418
+
419
+ board.addEventListener('dragover', e => {
420
+ e.preventDefault();
421
+ const col = e.target.closest(columnSelector);
422
+ if (!col) return;
423
+ e.dataTransfer.dropEffect = 'move';
424
+ board.querySelectorAll(columnSelector).forEach(c => c.classList.remove('is-drop-target'));
425
+ col.classList.add('is-drop-target');
426
+ col.style.outline = '2px solid var(--accent)';
427
+ col.style.outlineOffset = '-2px';
428
+ });
429
+
430
+ board.addEventListener('dragleave', e => {
431
+ const col = e.target.closest(columnSelector);
432
+ if (!col) return;
433
+ if (!col.contains(e.relatedTarget)) {
434
+ col.classList.remove('is-drop-target');
435
+ col.style.outline = '';
436
+ col.style.outlineOffset = '';
437
+ }
438
+ });
439
+
440
+ board.addEventListener('drop', async e => {
441
+ e.preventDefault();
442
+ const col = e.target.closest(columnSelector);
443
+ if (!col) return;
444
+
445
+ col.classList.remove('is-drop-target');
446
+ col.style.outline = '';
447
+ col.style.outlineOffset = '';
448
+
449
+ const taskId = e.dataTransfer.getData('text/plain') || _dragTaskId;
450
+ if (!taskId) return;
451
+
452
+ const scrumStatus = col.dataset.scrumStatus;
453
+ const action = STATUS_ACTION_MAP[scrumStatus];
454
+ if (!action) return;
455
+
456
+ // Check if the task is already in the target status
457
+ const task = state.getPayload()?.derived?.tasks?.find(t => t.id === taskId);
458
+ if (!task) return;
459
+
460
+ const targetStatuses = SCRUM_COLUMNS.find(c => c.id === scrumStatus)?.statuses || [];
461
+ if (targetStatuses.includes(task.status)) return; // already there
462
+
463
+ try {
464
+ await api.taskAction(taskId, action,
465
+ t('ui.scrum.movedFromBoard', { status: scrumStatus }, `Moved to ${scrumStatus} from sprint board.`)
466
+ );
467
+ flash(
468
+ t('ui.scrum.movedSuccess', { status: scrumStatus }, `Task moved to ${scrumStatus}.`),
469
+ 'success'
470
+ );
471
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
472
+ } catch (err) {
473
+ flash(err.message, 'error');
474
+ }
475
+ });
476
+ }