trackops 2.0.3 → 2.0.4
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 +238 -0
- package/lib/init.js +2 -2
- package/lib/locale.js +41 -17
- package/lib/opera-bootstrap.js +68 -7
- package/lib/opera.js +10 -2
- package/lib/registry.js +18 -0
- package/lib/server.js +312 -207
- package/locales/en.json +4 -0
- package/locales/es.json +4 -0
- package/package.json +1 -1
- package/skills/trackops/locales/en/references/activation.md +15 -0
- package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
- package/skills/trackops/references/activation.md +15 -0
- package/skills/trackops/references/troubleshooting.md +12 -0
- package/skills/trackops/skill.json +2 -2
- package/ui/css/base.css +19 -1
- package/ui/css/charts.css +106 -8
- package/ui/css/components.css +554 -17
- package/ui/css/onboarding.css +133 -0
- package/ui/css/panels.css +345 -406
- package/ui/css/terminal.css +125 -0
- package/ui/css/timeline.css +58 -0
- package/ui/css/tokens.css +170 -113
- package/ui/index.html +3 -0
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +82 -57
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +20 -3
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +197 -247
- package/ui/js/views/sidebar.js +37 -31
- package/ui/js/views/tasks.js +218 -101
- package/ui/js/views/timeline.js +265 -0
- package/ui/js/views/topbar.js +94 -107
- package/ui/app.js +0 -950
- package/ui/js/views/insights.js +0 -340
- package/ui/js/views/overview.js +0 -369
- 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')} · ${daysLeft} ${daysLeft === 1 ? 'day' : 'days'} remaining`
|
|
180
|
+
: `${formatDate(sprintStart, 'date')} — ${formatDate(sprintEnd, 'date')} · ${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
|
+
}
|