trackops 2.0.4 → 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 (90) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -640
  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 +41 -41
  9. package/lib/opera-bootstrap.js +942 -936
  10. package/lib/opera.js +495 -486
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -214
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/skills.js +74 -57
  16. package/lib/workspace.js +260 -260
  17. package/locales/en.json +192 -170
  18. package/locales/es.json +192 -170
  19. package/package.json +61 -58
  20. package/scripts/postinstall-locale.js +21 -21
  21. package/scripts/skills-marketplace-smoke.js +124 -124
  22. package/scripts/smoke-tests.js +558 -554
  23. package/scripts/sync-skill-version.js +21 -21
  24. package/scripts/validate-skill.js +103 -103
  25. package/skills/trackops/SKILL.md +126 -122
  26. package/skills/trackops/agents/openai.yaml +7 -7
  27. package/skills/trackops/locales/en/SKILL.md +126 -122
  28. package/skills/trackops/locales/en/references/activation.md +94 -90
  29. package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
  30. package/skills/trackops/locales/en/references/workflow.md +55 -32
  31. package/skills/trackops/references/activation.md +94 -90
  32. package/skills/trackops/references/troubleshooting.md +73 -67
  33. package/skills/trackops/references/workflow.md +55 -32
  34. package/skills/trackops/skill.json +29 -29
  35. package/templates/hooks/post-checkout +2 -2
  36. package/templates/hooks/post-commit +2 -2
  37. package/templates/hooks/post-merge +2 -2
  38. package/templates/opera/agent.md +28 -27
  39. package/templates/opera/architecture/dependency-graph.md +24 -24
  40. package/templates/opera/architecture/runtime-automation.md +24 -24
  41. package/templates/opera/architecture/runtime-operations.md +34 -34
  42. package/templates/opera/en/agent.md +22 -21
  43. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  44. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  45. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  46. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  47. package/templates/opera/en/reviews/integration-audit.md +18 -18
  48. package/templates/opera/en/router.md +24 -19
  49. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  50. package/templates/opera/references/opera-cycle.md +193 -193
  51. package/templates/opera/registry.md +28 -28
  52. package/templates/opera/reviews/delivery-audit.md +18 -18
  53. package/templates/opera/reviews/integration-audit.md +18 -18
  54. package/templates/opera/router.md +54 -49
  55. package/templates/skills/changelog-updater/SKILL.md +69 -69
  56. package/templates/skills/commiter/SKILL.md +99 -99
  57. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  58. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  59. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  60. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  61. package/templates/skills/opera-skill/SKILL.md +279 -0
  62. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  64. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  65. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  66. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  67. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  68. package/ui/css/base.css +284 -284
  69. package/ui/css/charts.css +425 -425
  70. package/ui/css/components.css +1107 -1107
  71. package/ui/css/onboarding.css +133 -133
  72. package/ui/css/terminal.css +125 -125
  73. package/ui/css/timeline.css +58 -58
  74. package/ui/css/tokens.css +284 -284
  75. package/ui/favicon.svg +5 -5
  76. package/ui/index.html +99 -99
  77. package/ui/js/charts.js +526 -526
  78. package/ui/js/console-logger.js +172 -172
  79. package/ui/js/filters.js +247 -247
  80. package/ui/js/icons.js +129 -129
  81. package/ui/js/keyboard.js +229 -229
  82. package/ui/js/router.js +142 -142
  83. package/ui/js/theme.js +100 -100
  84. package/ui/js/time-tracker.js +248 -248
  85. package/ui/js/views/dashboard.js +870 -870
  86. package/ui/js/views/flash.js +47 -47
  87. package/ui/js/views/projects.js +745 -745
  88. package/ui/js/views/scrum.js +476 -476
  89. package/ui/js/views/settings.js +331 -331
  90. package/ui/js/views/timeline.js +265 -265
@@ -1,476 +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
- }
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
+ }