bacon-tracker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +430 -0
- data/bin/tracker-dashboard +47 -0
- data/bin/tracker-init +267 -0
- data/lib/bacon_tracker/assets/app.js +1258 -0
- data/lib/bacon_tracker/assets/decisions.js +382 -0
- data/lib/bacon_tracker/assets/docs.js +456 -0
- data/lib/bacon_tracker/assets/logic.js +85 -0
- data/lib/bacon_tracker/assets/theme.js +33 -0
- data/lib/bacon_tracker/commands/tracker.md +269 -0
- data/lib/bacon_tracker/dashboard.rb +168 -0
- data/lib/bacon_tracker/launcher.rb +64 -0
- data/lib/bacon_tracker/server.rb +476 -0
- data/lib/bacon_tracker/tasks.rb +465 -0
- data/lib/bacon_tracker/version.rb +3 -0
- data/lib/bacon_tracker/views/_board_css.erb +122 -0
- data/lib/bacon_tracker/views/_detail_css.erb +109 -0
- data/lib/bacon_tracker/views/_header_css.erb +82 -0
- data/lib/bacon_tracker/views/_markdown_css.erb +81 -0
- data/lib/bacon_tracker/views/_root_css.erb +41 -0
- data/lib/bacon_tracker/views/_theme_boot.erb +1 -0
- data/lib/bacon_tracker/views/dashboard.erb +281 -0
- data/lib/bacon_tracker/views/decisions.erb +211 -0
- data/lib/bacon_tracker/views/docs.erb +234 -0
- data/lib/bacon_tracker/views/index.erb +1065 -0
- data/lib/bacon_tracker.rb +1993 -0
- metadata +213 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
const DONE_PER_PAGE = 20;
|
|
2
|
+
const API_BASE = window.BT_API_BASE || '';
|
|
3
|
+
let data = null;
|
|
4
|
+
let meta = null;
|
|
5
|
+
let donePage = 1;
|
|
6
|
+
const STAGE_KEYS = {
|
|
7
|
+
'1_icebox': 'icebox',
|
|
8
|
+
'2_backlog': 'backlog',
|
|
9
|
+
'3_started': 'started',
|
|
10
|
+
'4_done': 'done',
|
|
11
|
+
};
|
|
12
|
+
let dragged = null;
|
|
13
|
+
let draggedStage = null;
|
|
14
|
+
// undefined = no drop position shown yet; null = "append at the end".
|
|
15
|
+
let insertBeforeCard;
|
|
16
|
+
let activeMobileStage = localStorage.getItem('bt-mobile-tab') || '2_backlog';
|
|
17
|
+
// The /…#NS-042 deep link (BT-148) is honoured once, on the first load -
|
|
18
|
+
// later reloads (after a relation edit) must not yank the scroll back.
|
|
19
|
+
let deepLinkHandled = false;
|
|
20
|
+
|
|
21
|
+
// ── Data ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
async function load() {
|
|
24
|
+
const errBar = document.getElementById('load-error');
|
|
25
|
+
try {
|
|
26
|
+
const r = await fetch(API_BASE + '/api/stories');
|
|
27
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
28
|
+
data = await r.json();
|
|
29
|
+
} catch {
|
|
30
|
+
errBar.style.display = '';
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
errBar.style.display = 'none';
|
|
34
|
+
render();
|
|
35
|
+
if (!deepLinkHandled) {
|
|
36
|
+
deepLinkHandled = true;
|
|
37
|
+
const id = BTLogic.hashId(location.hash);
|
|
38
|
+
if (id && storyExists(id)) gotoStory(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function render() {
|
|
43
|
+
renderStage('1_icebox', data.icebox);
|
|
44
|
+
renderStage('2_backlog', data.backlog);
|
|
45
|
+
renderStage('3_started', data.started || []);
|
|
46
|
+
renderDone();
|
|
47
|
+
renderNextBar();
|
|
48
|
+
updateTabCounts();
|
|
49
|
+
if (data.meta) {
|
|
50
|
+
meta = data.meta;
|
|
51
|
+
const btn = document.getElementById('btn-reveal-backlog');
|
|
52
|
+
if (btn) {
|
|
53
|
+
btn.style.display = '';
|
|
54
|
+
btn.onclick = () => revealPath(meta.backlog_path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Re-render one column from the data arrays (done has its own renderer
|
|
60
|
+
// because of pagination).
|
|
61
|
+
function renderColumn(stage) {
|
|
62
|
+
if (stage === '4_done') renderDone();
|
|
63
|
+
else renderStage(stage, data[STAGE_KEYS[stage]] || []);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderStage(stage, stories) {
|
|
67
|
+
const col = document.querySelector(`[data-stage="${stage}"]`);
|
|
68
|
+
const cards = col.querySelector('.cards');
|
|
69
|
+
col.querySelector('.count').textContent = stories.length;
|
|
70
|
+
cards.innerHTML = '';
|
|
71
|
+
if (stories.length === 0) {
|
|
72
|
+
const msgs = {
|
|
73
|
+
'1_icebox': ['No ideas yet', 'use + to capture a maybe'],
|
|
74
|
+
'2_backlog': ['Backlog empty', 'use + to commit a story'],
|
|
75
|
+
'3_started': ['Nothing started', 'drag a backlog card here'],
|
|
76
|
+
'4_done': ['Nothing done yet', null],
|
|
77
|
+
};
|
|
78
|
+
const [text, hint] = msgs[stage] || ['Empty', null];
|
|
79
|
+
const es = el('div', { className: 'empty-state' });
|
|
80
|
+
es.innerHTML = `<div><p>${text}</p>${hint ? `<p class="hint">${hint}</p>` : ''}</div>`;
|
|
81
|
+
cards.appendChild(es);
|
|
82
|
+
} else {
|
|
83
|
+
stories.forEach((s) => cards.appendChild(makeCard(s, stage)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderDone() {
|
|
88
|
+
const stories = data.done;
|
|
89
|
+
const col = document.querySelector('[data-stage="4_done"]');
|
|
90
|
+
col.querySelector('.count').textContent = stories.length;
|
|
91
|
+
const maxPage = Math.max(1, Math.ceil(stories.length / DONE_PER_PAGE));
|
|
92
|
+
donePage = Math.min(donePage, maxPage);
|
|
93
|
+
const slice = stories.slice((donePage - 1) * DONE_PER_PAGE, donePage * DONE_PER_PAGE);
|
|
94
|
+
const cards = col.querySelector('.cards');
|
|
95
|
+
cards.innerHTML = '';
|
|
96
|
+
if (!slice.length) {
|
|
97
|
+
const es = el('div', { className: 'empty-state' });
|
|
98
|
+
es.innerHTML = '<div><p>Nothing done yet</p></div>';
|
|
99
|
+
cards.appendChild(es);
|
|
100
|
+
} else {
|
|
101
|
+
slice.forEach((s) => cards.appendChild(makeCard(s, '4_done')));
|
|
102
|
+
}
|
|
103
|
+
const pager = col.querySelector('.pagination');
|
|
104
|
+
pager.innerHTML = '';
|
|
105
|
+
if (stories.length > DONE_PER_PAGE) {
|
|
106
|
+
const btn = (label, fn) => {
|
|
107
|
+
const b = document.createElement('button');
|
|
108
|
+
b.textContent = label;
|
|
109
|
+
b.onclick = fn;
|
|
110
|
+
return b;
|
|
111
|
+
};
|
|
112
|
+
if (donePage > 1)
|
|
113
|
+
pager.appendChild(
|
|
114
|
+
btn('← prev', () => {
|
|
115
|
+
donePage--;
|
|
116
|
+
renderDone();
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
const info = document.createElement('span');
|
|
120
|
+
info.textContent = `${donePage} / ${maxPage}`;
|
|
121
|
+
pager.appendChild(info);
|
|
122
|
+
if (donePage < maxPage)
|
|
123
|
+
pager.appendChild(
|
|
124
|
+
btn('next →', () => {
|
|
125
|
+
donePage++;
|
|
126
|
+
renderDone();
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Card ──────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
// Relationship badges for a story, in one place so cards and the detail
|
|
135
|
+
// modal render them identically: blocked_by (⛔ red - this story is stuck),
|
|
136
|
+
// the derived reverse `blocks` (⛔ amber - this story holds another up), and
|
|
137
|
+
// the symmetric link (🔗 - the union of linked_to and the derived
|
|
138
|
+
// linked_from, deduped). A badge pointing at a known story is clickable;
|
|
139
|
+
// onJump(id) opens it in the detail view, from a card and from the detail
|
|
140
|
+
// view alike (BT-178). `blocks`/`linked_from` are derived server-side
|
|
141
|
+
// over the whole board (a file-based tracker stores each relation on one
|
|
142
|
+
// side only), so they arrive on the board GET but not on a single-story PUT.
|
|
143
|
+
function relationBadges(story, onJump) {
|
|
144
|
+
const badges = [];
|
|
145
|
+
const add = (id, cls, text, label) => {
|
|
146
|
+
const exists = storyExists(id);
|
|
147
|
+
const badge = el(
|
|
148
|
+
'span',
|
|
149
|
+
{
|
|
150
|
+
className: exists ? `meta-badge ${cls} link` : `meta-badge ${cls}`,
|
|
151
|
+
title: exists ? `${label} ${id} - click to open` : `${label} ${id} (not found)`,
|
|
152
|
+
},
|
|
153
|
+
text
|
|
154
|
+
);
|
|
155
|
+
if (exists)
|
|
156
|
+
badge.addEventListener('click', (e) => {
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
onJump(id);
|
|
159
|
+
});
|
|
160
|
+
badges.push(badge);
|
|
161
|
+
};
|
|
162
|
+
(story.blocked_by || []).forEach((id) => add(id, 'blocked', `⛔ ${id}`, 'Blocked by'));
|
|
163
|
+
(story.blocks || []).forEach((id) => add(id, 'blocks', `⛔ blocks ${id}`, 'Blocks'));
|
|
164
|
+
[...new Set([...(story.linked_to || []), ...(story.linked_from || [])])].forEach((id) =>
|
|
165
|
+
add(id, 'linked', `🔗 ${id}`, 'Linked to')
|
|
166
|
+
);
|
|
167
|
+
// Decisions citing this story (BT-148) live on another board - the badge
|
|
168
|
+
// navigates there and the decisions board opens the record from the hash.
|
|
169
|
+
(story.decisions || []).forEach((id) => {
|
|
170
|
+
const badge = el(
|
|
171
|
+
'span',
|
|
172
|
+
{ className: 'meta-badge linked link', title: `Decided in ${id} - click to open` },
|
|
173
|
+
`§ ${id}`
|
|
174
|
+
);
|
|
175
|
+
badge.addEventListener('click', (e) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
window.location = `${API_BASE}/docs/decisions#${id}`;
|
|
178
|
+
});
|
|
179
|
+
badges.push(badge);
|
|
180
|
+
});
|
|
181
|
+
return badges;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fillCardHeader(headerEl, story, onEdit, onReveal, onExpand) {
|
|
185
|
+
while (headerEl.firstChild) headerEl.removeChild(headerEl.firstChild);
|
|
186
|
+
const kids = [
|
|
187
|
+
el('span', { className: `badge ${story.type}` }, story.type),
|
|
188
|
+
el('span', { className: 'card-id' }, story.id),
|
|
189
|
+
];
|
|
190
|
+
if (story.size) kids.push(el('span', { className: 'meta-badge' }, story.size));
|
|
191
|
+
if (story.subtasks && story.subtasks.total)
|
|
192
|
+
kids.push(
|
|
193
|
+
el(
|
|
194
|
+
'span',
|
|
195
|
+
{ className: 'meta-badge subtask-progress', title: 'subtasks done' },
|
|
196
|
+
`${story.subtasks.done}/${story.subtasks.total}`
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
relationBadges(story, openStoryDetail).forEach((b) => kids.push(b));
|
|
200
|
+
if (story.assignee) kids.push(el('span', { className: 'meta-badge' }, story.assignee));
|
|
201
|
+
const btnEdit = el('button', { className: 'btn-edit' }, 'edit');
|
|
202
|
+
btnEdit.addEventListener('click', (e) => {
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
onEdit();
|
|
205
|
+
});
|
|
206
|
+
const btnRevl = el('button', { className: 'btn-reveal-story', title: 'Reveal the file', 'aria-label': 'Reveal the file' }, '↗');
|
|
207
|
+
btnRevl.addEventListener('click', (e) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
onReveal();
|
|
210
|
+
});
|
|
211
|
+
kids.push(btnEdit, btnRevl);
|
|
212
|
+
if (onExpand) {
|
|
213
|
+
const btnExpand = el(
|
|
214
|
+
'button',
|
|
215
|
+
{ className: 'btn-expand', title: 'Open detail view', 'aria-label': 'Open detail view' },
|
|
216
|
+
'⤢'
|
|
217
|
+
);
|
|
218
|
+
btnExpand.addEventListener('click', (e) => {
|
|
219
|
+
e.stopPropagation();
|
|
220
|
+
onExpand();
|
|
221
|
+
});
|
|
222
|
+
kids.push(btnExpand);
|
|
223
|
+
}
|
|
224
|
+
kids.forEach((k) => headerEl.appendChild(k));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Shared subtask click→toggle for any body-preview element (card + detail
|
|
228
|
+
// modal). getStory is a thunk so the same handler can serve a modal whose
|
|
229
|
+
// story changes on each open.
|
|
230
|
+
function attachSubtaskToggle(previewEl, getStory, afterUpdate) {
|
|
231
|
+
previewEl.addEventListener('click', (e) => {
|
|
232
|
+
const t = e.target.closest('.subtask');
|
|
233
|
+
if (!t) return;
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
toggle(t);
|
|
236
|
+
});
|
|
237
|
+
// Subtasks render as role="checkbox" with tabindex=0 - Space/Enter tick them.
|
|
238
|
+
previewEl.addEventListener('keydown', (e) => {
|
|
239
|
+
const t = e.target.closest('.subtask');
|
|
240
|
+
if (!t || (e.key !== ' ' && e.key !== 'Enter')) return;
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
toggle(t);
|
|
244
|
+
});
|
|
245
|
+
async function toggle(t) {
|
|
246
|
+
// Ignore a second click while the first is still in flight - otherwise
|
|
247
|
+
// both read the same pre-render classList and send the same `done`,
|
|
248
|
+
// and a stale index could toggle the wrong line (BT-112).
|
|
249
|
+
if (t.dataset.busy) return;
|
|
250
|
+
t.dataset.busy = '1';
|
|
251
|
+
const story = getStory();
|
|
252
|
+
const idx = parseInt(t.dataset.idx, 10);
|
|
253
|
+
const done = !t.classList.contains('done');
|
|
254
|
+
try {
|
|
255
|
+
const res = await api('PUT', API_BASE + `/api/stories/${story.id}/subtasks`, {
|
|
256
|
+
index: idx,
|
|
257
|
+
done,
|
|
258
|
+
});
|
|
259
|
+
if (res.ok) {
|
|
260
|
+
const j = await res.json();
|
|
261
|
+
story.body = toggleSubtaskInBody(story, idx, done);
|
|
262
|
+
story.subtasks = j.subtasks;
|
|
263
|
+
afterUpdate(story);
|
|
264
|
+
} else {
|
|
265
|
+
const err = await res.json().catch(() => ({}));
|
|
266
|
+
alert(err.error || 'Failed to update subtask');
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
alert('Network error - failed to update subtask');
|
|
270
|
+
} finally {
|
|
271
|
+
delete t.dataset.busy;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Story detail modal (BT-070): one overlay reused for any story. Opened from
|
|
277
|
+
// a card's maximize button; the board stays mounted behind it.
|
|
278
|
+
let detailStory = null,
|
|
279
|
+
detailOnChange = null,
|
|
280
|
+
detailEls = null;
|
|
281
|
+
|
|
282
|
+
function ensureDetailModal() {
|
|
283
|
+
if (detailEls) return detailEls;
|
|
284
|
+
const head = el('div', { className: 'detail-head' });
|
|
285
|
+
const titleEl = el('div', { className: 'detail-title' });
|
|
286
|
+
const bodyEl = el('div', { className: 'detail-body body-preview' });
|
|
287
|
+
const modal = el('div', { className: 'detail-modal' }, [head, titleEl, bodyEl]);
|
|
288
|
+
const backdrop = el('div', { className: 'detail-backdrop' }, [modal]);
|
|
289
|
+
document.body.appendChild(backdrop);
|
|
290
|
+
|
|
291
|
+
backdrop.addEventListener('click', (e) => {
|
|
292
|
+
if (e.target === backdrop) closeDetail();
|
|
293
|
+
});
|
|
294
|
+
attachSubtaskToggle(
|
|
295
|
+
bodyEl,
|
|
296
|
+
() => detailStory,
|
|
297
|
+
() => {
|
|
298
|
+
renderDetail();
|
|
299
|
+
if (detailOnChange) detailOnChange();
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
detailEls = { backdrop, head, titleEl, bodyEl };
|
|
304
|
+
return detailEls;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderDetail() {
|
|
308
|
+
const { head, titleEl, bodyEl } = detailEls;
|
|
309
|
+
const s = detailStory;
|
|
310
|
+
while (head.firstChild) head.removeChild(head.firstChild);
|
|
311
|
+
head.appendChild(el('span', { className: `badge ${s.type}` }, s.type));
|
|
312
|
+
head.appendChild(el('span', { className: 'detail-id' }, s.id));
|
|
313
|
+
if (s.stage)
|
|
314
|
+
head.appendChild(
|
|
315
|
+
el(
|
|
316
|
+
'span',
|
|
317
|
+
{ className: 'detail-stage', 'data-stage': s.stage, title: 'Current stage' },
|
|
318
|
+
STAGE_KEYS[s.stage] || s.stage
|
|
319
|
+
)
|
|
320
|
+
);
|
|
321
|
+
if (s.size) head.appendChild(el('span', { className: 'meta-badge' }, s.size));
|
|
322
|
+
if (s.subtasks && s.subtasks.total)
|
|
323
|
+
head.appendChild(
|
|
324
|
+
el(
|
|
325
|
+
'span',
|
|
326
|
+
{ className: 'meta-badge subtask-progress' },
|
|
327
|
+
`${s.subtasks.done}/${s.subtasks.total}`
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
relationBadges(s, openStoryDetail).forEach((b) => head.appendChild(b));
|
|
331
|
+
if (s.assignee) head.appendChild(el('span', { className: 'meta-badge' }, s.assignee));
|
|
332
|
+
const closeBtn = el('button', { className: 'detail-close', title: 'Close (Esc)' }, '✕');
|
|
333
|
+
closeBtn.addEventListener('click', closeDetail);
|
|
334
|
+
head.appendChild(closeBtn);
|
|
335
|
+
titleEl.textContent = s.title;
|
|
336
|
+
bodyEl.innerHTML = renderBody(s);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function openDetail(story, onChange) {
|
|
340
|
+
ensureDetailModal();
|
|
341
|
+
detailStory = story;
|
|
342
|
+
detailOnChange = onChange || null;
|
|
343
|
+
renderDetail();
|
|
344
|
+
detailEls.backdrop.classList.add('open');
|
|
345
|
+
document.addEventListener('keydown', detailEscHandler);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A relation badge (BT-178) opens the story it names in the detail view -
|
|
349
|
+
// from a card, and from the detail view itself, where it swaps the overlay
|
|
350
|
+
// to the referenced story rather than closing it to hunt down a card that
|
|
351
|
+
// may be paginated away or filtered out. Changes made there re-render the
|
|
352
|
+
// column the story actually lives in.
|
|
353
|
+
function openStoryDetail(id) {
|
|
354
|
+
const target = storyById(id);
|
|
355
|
+
if (!target) return;
|
|
356
|
+
openDetail(target, () => {
|
|
357
|
+
renderColumn(target.stage);
|
|
358
|
+
renderNextBar();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function closeDetail() {
|
|
363
|
+
if (!detailEls) return;
|
|
364
|
+
detailEls.backdrop.classList.remove('open');
|
|
365
|
+
detailStory = null;
|
|
366
|
+
detailOnChange = null;
|
|
367
|
+
document.removeEventListener('keydown', detailEscHandler);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function detailEscHandler(e) {
|
|
371
|
+
if (e.key === 'Escape') closeDetail();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function makeCard(story, stage) {
|
|
375
|
+
const div = el('div', {
|
|
376
|
+
className: `card type-${story.type}`,
|
|
377
|
+
draggable: 'true',
|
|
378
|
+
'data-id': story.id,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// View mode
|
|
382
|
+
const viewMode = el('div', { className: 'card-view' });
|
|
383
|
+
|
|
384
|
+
const header = el('div', { className: 'card-header' });
|
|
385
|
+
|
|
386
|
+
const titleEl = el('span', { className: 'card-title' }, story.title);
|
|
387
|
+
const chevron = el('span', { className: 'expand-chevron' }, '▾');
|
|
388
|
+
const titleRow = el(
|
|
389
|
+
'div',
|
|
390
|
+
{ className: 'card-title-row', role: 'button', tabindex: '0', 'aria-expanded': 'false' },
|
|
391
|
+
[titleEl, chevron]
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const preview = el('div', { className: 'body-preview' });
|
|
395
|
+
const bodyWrap = el('div', { className: 'card-body' }, [preview]);
|
|
396
|
+
function syncPreview() {
|
|
397
|
+
preview.innerHTML = renderBody(story);
|
|
398
|
+
}
|
|
399
|
+
syncPreview();
|
|
400
|
+
|
|
401
|
+
const revealCb = () => {
|
|
402
|
+
if (story.path) revealPath(story.path);
|
|
403
|
+
};
|
|
404
|
+
function expandCb() {
|
|
405
|
+
openDetail(story, syncCard);
|
|
406
|
+
}
|
|
407
|
+
function syncCard() {
|
|
408
|
+
syncPreview();
|
|
409
|
+
fillCardHeader(header, story, enterEdit, revealCb, expandCb);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
attachSubtaskToggle(preview, () => story, syncCard);
|
|
413
|
+
|
|
414
|
+
const toggleBody = () => {
|
|
415
|
+
titleRow.setAttribute('aria-expanded', String(div.classList.toggle('body-expanded')));
|
|
416
|
+
};
|
|
417
|
+
titleRow.addEventListener('click', toggleBody);
|
|
418
|
+
titleRow.addEventListener('keydown', (e) => {
|
|
419
|
+
if (e.target !== titleRow || (e.key !== 'Enter' && e.key !== ' ')) return;
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
toggleBody();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
viewMode.append(header, titleRow, bodyWrap);
|
|
425
|
+
|
|
426
|
+
// Edit mode - built lazily on first edit: most cards are never edited,
|
|
427
|
+
// and the edit subtree is ~20 hidden nodes plus a dozen listeners.
|
|
428
|
+
let editUi = null;
|
|
429
|
+
|
|
430
|
+
function enterEdit() {
|
|
431
|
+
editUi ||= buildEdit();
|
|
432
|
+
editUi.open();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildEdit() {
|
|
436
|
+
const editMode = el('div', { className: 'card-edit' });
|
|
437
|
+
const editHeader = el('div', { className: 'edit-header' }, [
|
|
438
|
+
el('span', { className: `badge ${story.type}` }, story.type),
|
|
439
|
+
el('span', { className: 'card-id' }, story.id),
|
|
440
|
+
]);
|
|
441
|
+
const titleInput = el('input', {
|
|
442
|
+
className: 'title-input',
|
|
443
|
+
type: 'text',
|
|
444
|
+
placeholder: 'Title...',
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
let currentSize = story.size || null;
|
|
448
|
+
const sizeBtns = {};
|
|
449
|
+
const szPicker = el('div', { className: 'size-picker' });
|
|
450
|
+
['S', 'M', 'L'].forEach((s) => {
|
|
451
|
+
const b = el('button', { className: 'size-btn' + (currentSize === s ? ' active' : '') }, s);
|
|
452
|
+
b.addEventListener('click', () => {
|
|
453
|
+
currentSize = currentSize === s ? null : s;
|
|
454
|
+
Object.values(sizeBtns).forEach((x) => x.classList.remove('active'));
|
|
455
|
+
if (currentSize && sizeBtns[currentSize]) sizeBtns[currentSize].classList.add('active');
|
|
456
|
+
});
|
|
457
|
+
sizeBtns[s] = b;
|
|
458
|
+
szPicker.appendChild(b);
|
|
459
|
+
});
|
|
460
|
+
const szRow = el('div', { className: 'field-row' }, [
|
|
461
|
+
el('span', { className: 'field-label' }, 'size:'),
|
|
462
|
+
szPicker,
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
const assigneeIn = el('input', {
|
|
466
|
+
className: 'field-input narrow',
|
|
467
|
+
type: 'text',
|
|
468
|
+
placeholder: 'AB',
|
|
469
|
+
maxlength: '4',
|
|
470
|
+
});
|
|
471
|
+
const assigneeRow = el('div', { className: 'field-row' }, [
|
|
472
|
+
el('span', { className: 'field-label' }, 'assignee:'),
|
|
473
|
+
assigneeIn,
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
const blockedIn = el('input', {
|
|
477
|
+
className: 'field-input wide',
|
|
478
|
+
type: 'text',
|
|
479
|
+
placeholder: 'BT-002, BT-003',
|
|
480
|
+
});
|
|
481
|
+
const blockedRow = el('div', { className: 'field-row' }, [
|
|
482
|
+
el('span', { className: 'field-label' }, 'blocked by:'),
|
|
483
|
+
blockedIn,
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
const linkedIn = el('input', {
|
|
487
|
+
className: 'field-input wide',
|
|
488
|
+
type: 'text',
|
|
489
|
+
placeholder: 'BT-004, BT-010',
|
|
490
|
+
});
|
|
491
|
+
const linkedRow = el('div', { className: 'field-row' }, [
|
|
492
|
+
el('span', { className: 'field-label' }, 'linked to:'),
|
|
493
|
+
linkedIn,
|
|
494
|
+
]);
|
|
495
|
+
|
|
496
|
+
// The non-drag way to move a story (keyboard and touch users). Done is
|
|
497
|
+
// append-only, so a done card shows its stage but cannot leave it.
|
|
498
|
+
const stageSel = el('select', { className: 'field-input', 'aria-label': 'Stage' });
|
|
499
|
+
Object.entries(STAGE_KEYS).forEach(([value, label]) =>
|
|
500
|
+
stageSel.appendChild(el('option', { value }, label))
|
|
501
|
+
);
|
|
502
|
+
const stageRow = el('div', { className: 'field-row' }, [
|
|
503
|
+
el('span', { className: 'field-label' }, 'stage:'),
|
|
504
|
+
stageSel,
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
const bodyEditor = el('textarea', {
|
|
508
|
+
className: 'body-editor',
|
|
509
|
+
placeholder: 'Markdown or Gherkin...',
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const actions = el('div', { className: 'card-actions' }, [
|
|
513
|
+
el('button', { className: 'btn-danger' }, 'delete'),
|
|
514
|
+
el('div', { className: 'flex-spacer' }),
|
|
515
|
+
el('button', { className: 'btn-secondary' }, 'cancel'),
|
|
516
|
+
el('button', { className: 'btn-primary' }, 'save'),
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
editMode.append(
|
|
520
|
+
editHeader,
|
|
521
|
+
titleInput,
|
|
522
|
+
szRow,
|
|
523
|
+
stageRow,
|
|
524
|
+
assigneeRow,
|
|
525
|
+
blockedRow,
|
|
526
|
+
linkedRow,
|
|
527
|
+
bodyEditor,
|
|
528
|
+
actions
|
|
529
|
+
);
|
|
530
|
+
div.appendChild(editMode);
|
|
531
|
+
|
|
532
|
+
function open() {
|
|
533
|
+
currentSize = story.size || null;
|
|
534
|
+
Object.entries(sizeBtns).forEach(([k, b]) => b.classList.toggle('active', k === currentSize));
|
|
535
|
+
assigneeIn.value = story.assignee || '';
|
|
536
|
+
blockedIn.value = (story.blocked_by || []).join(', ');
|
|
537
|
+
linkedIn.value = (story.linked_to || []).join(', ');
|
|
538
|
+
stageSel.value = stage;
|
|
539
|
+
stageSel.disabled = stage === '4_done';
|
|
540
|
+
div.draggable = false;
|
|
541
|
+
titleInput.value = story.title;
|
|
542
|
+
bodyEditor.value = story.body || '';
|
|
543
|
+
viewMode.style.display = 'none';
|
|
544
|
+
editMode.style.display = 'block';
|
|
545
|
+
div.classList.add('editing');
|
|
546
|
+
titleInput.focus();
|
|
547
|
+
titleInput.select();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function close() {
|
|
551
|
+
viewMode.style.display = '';
|
|
552
|
+
editMode.style.display = '';
|
|
553
|
+
div.classList.remove('editing', 'body-expanded');
|
|
554
|
+
div.draggable = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
actions.querySelector('.btn-secondary').addEventListener('click', close);
|
|
558
|
+
|
|
559
|
+
actions.querySelector('.btn-primary').addEventListener('click', async () => {
|
|
560
|
+
const newTitle = titleInput.value.trim();
|
|
561
|
+
if (!newTitle) return;
|
|
562
|
+
const newBlocked = blockedIn.value.trim()
|
|
563
|
+
? blockedIn.value
|
|
564
|
+
.trim()
|
|
565
|
+
.split(/\s*,\s*/)
|
|
566
|
+
.filter(Boolean)
|
|
567
|
+
: [];
|
|
568
|
+
const oldBlocked = story.blocked_by || [];
|
|
569
|
+
const newLinked = linkedIn.value.trim()
|
|
570
|
+
? linkedIn.value
|
|
571
|
+
.trim()
|
|
572
|
+
.split(/\s*,\s*/)
|
|
573
|
+
.filter(Boolean)
|
|
574
|
+
: [];
|
|
575
|
+
const oldLinked = story.linked_to || [];
|
|
576
|
+
const newAssignee = assigneeIn.value.trim().toUpperCase();
|
|
577
|
+
|
|
578
|
+
const changed = {};
|
|
579
|
+
if (newTitle !== story.title) changed.title = newTitle;
|
|
580
|
+
if (bodyEditor.value !== (story.body || '')) changed.body = bodyEditor.value;
|
|
581
|
+
if (currentSize !== (story.size || null)) changed.size = currentSize || '';
|
|
582
|
+
if (newBlocked.join(',') !== oldBlocked.join(',')) changed.blocked_by = newBlocked;
|
|
583
|
+
if (newLinked.join(',') !== oldLinked.join(',')) changed.linked_to = newLinked;
|
|
584
|
+
if (newAssignee !== (story.assignee || '')) changed.assignee = newAssignee;
|
|
585
|
+
|
|
586
|
+
// A changed relationship flips a badge on the *other* story too, and the
|
|
587
|
+
// single-story PUT response can't carry the board-wide derived ends -
|
|
588
|
+
// reload so every reverse badge is fresh (BT-119). Deferred until after
|
|
589
|
+
// any stage move, so the reload can't race the move.
|
|
590
|
+
const needsReload = 'blocked_by' in changed || 'linked_to' in changed;
|
|
591
|
+
if (Object.keys(changed).length > 0) {
|
|
592
|
+
try {
|
|
593
|
+
const res = await api('PUT', API_BASE + `/api/stories/${story.id}`, changed);
|
|
594
|
+
if (res.ok) {
|
|
595
|
+
// The response is the re-parsed story - fresh title/body/path
|
|
596
|
+
// and derived fields (subtasks, subtask_lines) in one merge.
|
|
597
|
+
Object.assign(story, await res.json());
|
|
598
|
+
titleEl.textContent = story.title;
|
|
599
|
+
syncCard();
|
|
600
|
+
renderNextBar();
|
|
601
|
+
} else {
|
|
602
|
+
// Keep edit mode open with the user's text intact - a 400 for one
|
|
603
|
+
// mistyped id must not throw away a long body edit (BT-179).
|
|
604
|
+
const err = await res.json().catch(() => ({}));
|
|
605
|
+
alert(err.error || 'Failed to save story');
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
alert('Network error - failed to save story');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (stageSel.value !== stage && !(await moveStory(story.id, stage, stageSel.value))) return;
|
|
614
|
+
if (needsReload) load();
|
|
615
|
+
close();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
actions.querySelector('.btn-danger').addEventListener('click', async () => {
|
|
619
|
+
if (!confirm(`Delete ${story.id} - ${story.title}?`)) return;
|
|
620
|
+
try {
|
|
621
|
+
const res = await api('DELETE', API_BASE + `/api/stories/${story.id}`);
|
|
622
|
+
if (res.ok) {
|
|
623
|
+
div.remove();
|
|
624
|
+
const col = document.querySelector(`[data-stage="${stage}"]`);
|
|
625
|
+
const cnt = col.querySelector('.count');
|
|
626
|
+
cnt.textContent = Math.max(0, parseInt(cnt.textContent) - 1);
|
|
627
|
+
['icebox', 'backlog', 'started', 'done'].forEach((k) => {
|
|
628
|
+
if (data[k]) data[k] = data[k].filter((s) => s.id !== story.id);
|
|
629
|
+
});
|
|
630
|
+
if (stage === '4_done') renderDone(); // keeps pagination backfilled and clamped
|
|
631
|
+
updateTabCounts();
|
|
632
|
+
renderNextBar();
|
|
633
|
+
} else {
|
|
634
|
+
const err = await res.json().catch(() => ({}));
|
|
635
|
+
alert(err.error || 'Failed to delete story');
|
|
636
|
+
}
|
|
637
|
+
} catch {
|
|
638
|
+
alert('Network error - failed to delete story');
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
titleInput.addEventListener('keydown', (e) => {
|
|
643
|
+
if (e.key === 'Escape') close();
|
|
644
|
+
if (e.key === 'Enter') actions.querySelector('.btn-primary').click();
|
|
645
|
+
});
|
|
646
|
+
bodyEditor.addEventListener('keydown', (e) => {
|
|
647
|
+
if (e.key === 'Escape') close();
|
|
648
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
649
|
+
e.preventDefault();
|
|
650
|
+
actions.querySelector('.btn-primary').click();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
return { open, close };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
syncCard();
|
|
658
|
+
|
|
659
|
+
// Drag
|
|
660
|
+
div.addEventListener('dragstart', (e) => {
|
|
661
|
+
if (div.classList.contains('editing')) {
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
dragged = div;
|
|
666
|
+
draggedStage = stage;
|
|
667
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
668
|
+
e.dataTransfer.setData('text/plain', story.id);
|
|
669
|
+
e.dataTransfer.setData('text/x-source-stage', stage);
|
|
670
|
+
requestAnimationFrame(() => div.classList.add('dragging'));
|
|
671
|
+
});
|
|
672
|
+
div.addEventListener('dragend', () => {
|
|
673
|
+
div.classList.remove('dragging');
|
|
674
|
+
cleanupDrag();
|
|
675
|
+
dragged = null;
|
|
676
|
+
draggedStage = null;
|
|
677
|
+
insertBeforeCard = undefined;
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
div.append(viewMode);
|
|
681
|
+
return div;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Create form ───────────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
function setupCreateForm(col) {
|
|
687
|
+
const stage = col.dataset.stage;
|
|
688
|
+
const addBtn = col.querySelector('.btn-add');
|
|
689
|
+
if (!addBtn) return;
|
|
690
|
+
|
|
691
|
+
const formEl = col.querySelector('.create-form');
|
|
692
|
+
let activeType = 'feature';
|
|
693
|
+
const typeBtns = {};
|
|
694
|
+
const typeRow = el('div', { className: 'create-type-row' });
|
|
695
|
+
|
|
696
|
+
['feature', 'bug', 'chore'].forEach((t) => {
|
|
697
|
+
const b = el(
|
|
698
|
+
'button',
|
|
699
|
+
{ className: 'type-btn' + (t === 'feature' ? ' active' : ''), 'data-type': t },
|
|
700
|
+
t
|
|
701
|
+
);
|
|
702
|
+
b.addEventListener('click', () => {
|
|
703
|
+
activeType = t;
|
|
704
|
+
Object.values(typeBtns).forEach((x) => x.classList.remove('active'));
|
|
705
|
+
b.classList.add('active');
|
|
706
|
+
});
|
|
707
|
+
typeBtns[t] = b;
|
|
708
|
+
typeRow.appendChild(b);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const titleInput = el('input', {
|
|
712
|
+
className: 'create-title',
|
|
713
|
+
type: 'text',
|
|
714
|
+
placeholder: 'Story title...',
|
|
715
|
+
});
|
|
716
|
+
const inputRow = el('div', { className: 'create-input-row' }, [
|
|
717
|
+
titleInput,
|
|
718
|
+
el('button', { className: 'btn-create-submit' }, 'add'),
|
|
719
|
+
el('button', { className: 'btn-create-cancel' }, '✕'),
|
|
720
|
+
]);
|
|
721
|
+
formEl.append(typeRow, inputRow);
|
|
722
|
+
|
|
723
|
+
function openForm() {
|
|
724
|
+
formEl.style.display = 'flex';
|
|
725
|
+
addBtn.style.opacity = '0.4';
|
|
726
|
+
addBtn.style.pointerEvents = 'none';
|
|
727
|
+
titleInput.value = '';
|
|
728
|
+
titleInput.focus();
|
|
729
|
+
}
|
|
730
|
+
function closeForm() {
|
|
731
|
+
formEl.style.display = '';
|
|
732
|
+
addBtn.style.opacity = '';
|
|
733
|
+
addBtn.style.pointerEvents = '';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
addBtn.addEventListener('click', openForm);
|
|
737
|
+
inputRow.querySelector('.btn-create-cancel').addEventListener('click', closeForm);
|
|
738
|
+
titleInput.addEventListener('keydown', (e) => {
|
|
739
|
+
if (e.key === 'Escape') closeForm();
|
|
740
|
+
if (e.key === 'Enter') inputRow.querySelector('.btn-create-submit').click();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const submitBtn = inputRow.querySelector('.btn-create-submit');
|
|
744
|
+
submitBtn.addEventListener('click', async () => {
|
|
745
|
+
const title = titleInput.value.trim();
|
|
746
|
+
if (!title) {
|
|
747
|
+
titleInput.focus();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
// Disable while the POST is in flight so a double-click or Enter
|
|
751
|
+
// autorepeat can't create duplicate stories (BT-112). A disabled
|
|
752
|
+
// button also makes the Enter-key `.click()` above a no-op.
|
|
753
|
+
if (submitBtn.disabled) return;
|
|
754
|
+
submitBtn.disabled = true;
|
|
755
|
+
try {
|
|
756
|
+
const res = await api('POST', API_BASE + '/api/stories', { type: activeType, title, stage });
|
|
757
|
+
if (res.ok) {
|
|
758
|
+
// The 201 body is the created story - no need to refetch the board.
|
|
759
|
+
const story = await res.json();
|
|
760
|
+
const key = STAGE_KEYS[stage];
|
|
761
|
+
(data[key] ||= []).push(story);
|
|
762
|
+
closeForm();
|
|
763
|
+
renderColumn(stage);
|
|
764
|
+
renderNextBar();
|
|
765
|
+
updateTabCounts();
|
|
766
|
+
} else {
|
|
767
|
+
const err = await res.json().catch(() => ({}));
|
|
768
|
+
alert(err.error || 'Failed to create story');
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
alert('Network error - failed to create story');
|
|
772
|
+
} finally {
|
|
773
|
+
submitBtn.disabled = false;
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── Drop zones ────────────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
function setupDropZones() {
|
|
781
|
+
document.querySelectorAll('.column').forEach((col) => {
|
|
782
|
+
const stage = col.dataset.stage;
|
|
783
|
+
const cardsEl = col.querySelector('.cards');
|
|
784
|
+
|
|
785
|
+
col.addEventListener('dragover', (e) => {
|
|
786
|
+
if (!dragged) return;
|
|
787
|
+
e.preventDefault();
|
|
788
|
+
e.dataTransfer.dropEffect = 'move';
|
|
789
|
+
if (stage === '2_backlog' && draggedStage === '2_backlog') {
|
|
790
|
+
const tgt = cardAfter(cardsEl, e.clientY);
|
|
791
|
+
if (insertBeforeCard === tgt) return;
|
|
792
|
+
insertBeforeCard = tgt;
|
|
793
|
+
cleanupDrag();
|
|
794
|
+
const line = el('div', { className: 'drop-line' });
|
|
795
|
+
tgt ? cardsEl.insertBefore(line, tgt) : cardsEl.appendChild(line);
|
|
796
|
+
} else if (stage !== draggedStage) {
|
|
797
|
+
col.classList.add('drop-target');
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
col.addEventListener('dragleave', (e) => {
|
|
802
|
+
if (!col.contains(e.relatedTarget)) {
|
|
803
|
+
col.classList.remove('drop-target');
|
|
804
|
+
cleanupDrag();
|
|
805
|
+
insertBeforeCard = undefined;
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
col.addEventListener('drop', async (e) => {
|
|
810
|
+
e.preventDefault();
|
|
811
|
+
col.classList.remove('drop-target');
|
|
812
|
+
cleanupDrag();
|
|
813
|
+
const id = e.dataTransfer.getData('text/plain') || dragged?.dataset.id;
|
|
814
|
+
const fromStage = e.dataTransfer.getData('text/x-source-stage') || draggedStage;
|
|
815
|
+
if (!id) return;
|
|
816
|
+
|
|
817
|
+
if (stage === '2_backlog' && fromStage === '2_backlog') {
|
|
818
|
+
const draggedEl = dragged || document.querySelector(`.card[data-id="${id}"]`);
|
|
819
|
+
if (draggedEl)
|
|
820
|
+
insertBeforeCard
|
|
821
|
+
? cardsEl.insertBefore(draggedEl, insertBeforeCard)
|
|
822
|
+
: cardsEl.appendChild(draggedEl);
|
|
823
|
+
const newOrder = [...cardsEl.querySelectorAll('.card')].map((c) => c.dataset.id);
|
|
824
|
+
api('PUT', API_BASE + '/api/stories/backlog/order', { ids: newOrder })
|
|
825
|
+
.then((res) => {
|
|
826
|
+
if (!res.ok) throw new Error('rejected');
|
|
827
|
+
})
|
|
828
|
+
.catch(() => {
|
|
829
|
+
console.warn('[bacon-tracker] failed to sync backlog order - reloading');
|
|
830
|
+
load();
|
|
831
|
+
});
|
|
832
|
+
const byId = Object.fromEntries((data.backlog || []).map((s) => [s.id, s]));
|
|
833
|
+
data.backlog = newOrder.map((i) => byId[i]).filter(Boolean);
|
|
834
|
+
renderNextBar();
|
|
835
|
+
} else if (stage !== fromStage) {
|
|
836
|
+
await moveStory(id, fromStage, stage);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
setupCreateForm(col);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Move a story to another stage - the one path for a drop and for the edit
|
|
845
|
+
// form's stage picker. Updates the local stage arrays and re-renders only the
|
|
846
|
+
// two affected columns (no full-board refetch). Resolves true on success.
|
|
847
|
+
async function moveStory(id, fromStage, stage) {
|
|
848
|
+
try {
|
|
849
|
+
const res = await api('PUT', API_BASE + `/api/stories/${id}/stage`, { stage });
|
|
850
|
+
if (!res.ok) {
|
|
851
|
+
const err = await res.json().catch(() => ({}));
|
|
852
|
+
alert(err.error || 'Failed to move story');
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
} catch {
|
|
856
|
+
alert('Network error - failed to move story');
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
const fromKey = STAGE_KEYS[fromStage],
|
|
860
|
+
toKey = STAGE_KEYS[stage];
|
|
861
|
+
const idx = (data[fromKey] || []).findIndex((s) => s.id === id);
|
|
862
|
+
if (idx < 0) {
|
|
863
|
+
await load();
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
const [story] = data[fromKey].splice(idx, 1);
|
|
867
|
+
story.stage = stage;
|
|
868
|
+
if (story.path) story.path = story.path.replace(`/${fromStage}/`, `/${stage}/`);
|
|
869
|
+
if (toKey === 'done') data.done.unshift(story);
|
|
870
|
+
else if (toKey === 'backlog') data.backlog.push(story);
|
|
871
|
+
else {
|
|
872
|
+
data[toKey].push(story);
|
|
873
|
+
data[toKey].sort(BTLogic.byStoryNumber); // trailing number (matches server, BT-104)
|
|
874
|
+
}
|
|
875
|
+
renderColumn(fromStage);
|
|
876
|
+
renderColumn(stage);
|
|
877
|
+
renderNextBar();
|
|
878
|
+
updateTabCounts();
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function cardAfter(container, y) {
|
|
883
|
+
let best = null,
|
|
884
|
+
bestOffset = -Infinity;
|
|
885
|
+
[...container.querySelectorAll('.card:not(.dragging)')].forEach((c) => {
|
|
886
|
+
const { top, height } = c.getBoundingClientRect();
|
|
887
|
+
const offset = y - (top + height / 2);
|
|
888
|
+
if (offset < 0 && offset > bestOffset) {
|
|
889
|
+
bestOffset = offset;
|
|
890
|
+
best = c;
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
return best;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function cleanupDrag() {
|
|
897
|
+
document.querySelectorAll('.drop-line').forEach((l) => l.remove());
|
|
898
|
+
document.querySelectorAll('.column').forEach((c) => c.classList.remove('drop-target'));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function revealPath(path) {
|
|
902
|
+
// Match the other mutations: check res.ok and catch network errors, so a
|
|
903
|
+
// failed reveal surfaces instead of an unhandled promise rejection (BT-112).
|
|
904
|
+
try {
|
|
905
|
+
const res = await api('POST', API_BASE + '/api/reveal', { path });
|
|
906
|
+
if (!res.ok) {
|
|
907
|
+
const err = await res.json().catch(() => ({}));
|
|
908
|
+
alert(err.error || 'Failed to reveal the file');
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
alert('Network error - failed to reveal the file');
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function renderNextBar() {
|
|
916
|
+
const bar = document.getElementById('next-bar');
|
|
917
|
+
if (!bar) return;
|
|
918
|
+
const next = data && data.backlog && data.backlog[0];
|
|
919
|
+
if (next) {
|
|
920
|
+
document.getElementById('next-title-text').textContent = next.title;
|
|
921
|
+
bar.style.display = '';
|
|
922
|
+
} else {
|
|
923
|
+
bar.style.display = 'none';
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function updateTabCounts() {
|
|
928
|
+
if (!data) return;
|
|
929
|
+
document.getElementById('tc-icebox').textContent = (data.icebox || []).length;
|
|
930
|
+
document.getElementById('tc-backlog').textContent = (data.backlog || []).length;
|
|
931
|
+
document.getElementById('tc-started').textContent = (data.started || []).length;
|
|
932
|
+
document.getElementById('tc-done').textContent = (data.done || []).length;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ── Done collapse ─────────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
function setupDoneCollapse() {
|
|
938
|
+
const col = document.querySelector('[data-stage="4_done"]');
|
|
939
|
+
const btn = col && col.querySelector('.btn-collapse');
|
|
940
|
+
if (!btn) return;
|
|
941
|
+
if (localStorage.getItem('bt-done-collapsed') === 'true') {
|
|
942
|
+
col.classList.add('collapsed');
|
|
943
|
+
btn.textContent = '+';
|
|
944
|
+
btn.title = 'Expand done';
|
|
945
|
+
}
|
|
946
|
+
btn.addEventListener('click', () => {
|
|
947
|
+
const c = col.classList.toggle('collapsed');
|
|
948
|
+
localStorage.setItem('bt-done-collapsed', String(c));
|
|
949
|
+
btn.textContent = c ? '+' : '−';
|
|
950
|
+
btn.title = c ? 'Expand done' : 'Collapse done';
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ── Mobile tabs ───────────────────────────────────────────────────────────
|
|
955
|
+
|
|
956
|
+
function setupMobileTabs() {
|
|
957
|
+
const isMobile = () => window.innerWidth <= 640;
|
|
958
|
+
|
|
959
|
+
function setTab(stage) {
|
|
960
|
+
activeMobileStage = stage;
|
|
961
|
+
localStorage.setItem('bt-mobile-tab', stage);
|
|
962
|
+
document
|
|
963
|
+
.querySelectorAll('.mobile-tab')
|
|
964
|
+
.forEach((t) => t.classList.toggle('active', t.dataset.target === stage));
|
|
965
|
+
document
|
|
966
|
+
.querySelectorAll('.column')
|
|
967
|
+
.forEach((c) => c.classList.toggle('mobile-active', c.dataset.stage === stage));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function applyLayout() {
|
|
971
|
+
if (isMobile()) setTab(activeMobileStage);
|
|
972
|
+
else document.querySelectorAll('.column').forEach((c) => c.classList.remove('mobile-active'));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
document
|
|
976
|
+
.querySelectorAll('.mobile-tab')
|
|
977
|
+
.forEach((t) => t.addEventListener('click', () => setTab(t.dataset.target)));
|
|
978
|
+
applyLayout();
|
|
979
|
+
window.addEventListener('resize', applyLayout);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── Rendering ─────────────────────────────────────────────────────────────
|
|
983
|
+
|
|
984
|
+
function renderBody(story) {
|
|
985
|
+
if (!story.body || !story.body.trim())
|
|
986
|
+
return '<span class="empty-body">no content - click edit to add</span>';
|
|
987
|
+
if (story.type === 'feature')
|
|
988
|
+
return (
|
|
989
|
+
'<pre class="gherkin">' +
|
|
990
|
+
renderGherkin(story.body.trimEnd(), story.subtask_lines || []) +
|
|
991
|
+
'</pre>'
|
|
992
|
+
);
|
|
993
|
+
return renderMarkdown(story.body, story.subtask_lines || []);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Which body lines are subtasks (and their data-idx) comes from the
|
|
997
|
+
// server's subtask_lines - the client never re-derives fence rules.
|
|
998
|
+
function subtaskOrdinals(subtaskLines) {
|
|
999
|
+
const map = new Map();
|
|
1000
|
+
subtaskLines.forEach((ln, i) => map.set(ln, i));
|
|
1001
|
+
return map;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function renderGherkin(text, subtaskLines) {
|
|
1005
|
+
const sRe = /^(\s*)(Feature|Background|Scenario Outline|Scenario|Rule|Examples)(:)(.*)$/;
|
|
1006
|
+
const stRe = /^(\s*)(Given|When|Then|And|But)(\s.*)$/;
|
|
1007
|
+
const tgRe = /^(\s*)(@\S+.*)$/;
|
|
1008
|
+
const cmRe = /^(\s*)(#.*)$/;
|
|
1009
|
+
const tbRe = /^(\s*)(\|.*)$/;
|
|
1010
|
+
const dsRe = /^(\s*)(""".*)$/;
|
|
1011
|
+
const cbRe = /^(\s*)[-*] \[( |x|X)\] (.*)$/;
|
|
1012
|
+
const stOrd = subtaskOrdinals(subtaskLines);
|
|
1013
|
+
let inDs = false;
|
|
1014
|
+
return text
|
|
1015
|
+
.split('\n')
|
|
1016
|
+
.map((line, i) => {
|
|
1017
|
+
let m;
|
|
1018
|
+
if (stOrd.has(i) && (m = line.match(cbRe))) {
|
|
1019
|
+
const done = m[2] !== ' ';
|
|
1020
|
+
return (
|
|
1021
|
+
esc(m[1]) +
|
|
1022
|
+
`<span class="subtask${done ? ' done' : ''}" data-idx="${stOrd.get(i)}" role="checkbox" tabindex="0" aria-checked="${done}">` +
|
|
1023
|
+
`<span class="cb">${done ? '☑' : '☐'}</span> ` +
|
|
1024
|
+
esc(m[3]) +
|
|
1025
|
+
'</span>'
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if ((m = line.match(dsRe))) {
|
|
1029
|
+
inDs = !inDs;
|
|
1030
|
+
return esc(m[1]) + '<span class="gh-docstr">' + esc(m[2]) + '</span>';
|
|
1031
|
+
}
|
|
1032
|
+
if (inDs) return '<span class="gh-docstr">' + esc(line) + '</span>';
|
|
1033
|
+
if ((m = line.match(cmRe)))
|
|
1034
|
+
return esc(m[1]) + '<span class="gh-comment">' + esc(m[2]) + '</span>';
|
|
1035
|
+
if ((m = line.match(tgRe)))
|
|
1036
|
+
return esc(m[1]) + '<span class="gh-tag">' + esc(m[2]) + '</span>';
|
|
1037
|
+
if ((m = line.match(sRe)))
|
|
1038
|
+
return (
|
|
1039
|
+
esc(m[1]) +
|
|
1040
|
+
'<span class="gh-keyword">' +
|
|
1041
|
+
esc(m[2] + m[3]) +
|
|
1042
|
+
'</span>' +
|
|
1043
|
+
'<span class="gh-title">' +
|
|
1044
|
+
esc(m[4]) +
|
|
1045
|
+
'</span>'
|
|
1046
|
+
);
|
|
1047
|
+
if ((m = line.match(stRe)))
|
|
1048
|
+
return esc(m[1]) + '<span class="gh-step">' + esc(m[2]) + '</span>' + hParams(esc(m[3]));
|
|
1049
|
+
if ((m = line.match(tbRe)))
|
|
1050
|
+
return esc(m[1]) + '<span class="gh-table">' + esc(m[2]) + '</span>';
|
|
1051
|
+
return esc(line);
|
|
1052
|
+
})
|
|
1053
|
+
.join('\n');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function hParams(s) {
|
|
1057
|
+
return s
|
|
1058
|
+
.replace(/(<[^&]*>)/g, '<span class="gh-param">$1</span>')
|
|
1059
|
+
.replace(/("[^&]*")/g, '<span class="gh-param">$1</span>'); // esc() already encoded every quote
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function renderMarkdown(text, subtaskLines) {
|
|
1063
|
+
if (!text) return '';
|
|
1064
|
+
const blocks = [],
|
|
1065
|
+
inlines = [];
|
|
1066
|
+
// Mark the server-addressed subtask lines FIRST (by body-line number),
|
|
1067
|
+
// so no later pass - fences included - has to re-derive which lines are
|
|
1068
|
+
// toggleable. The item text stays inline for strong/em processing.
|
|
1069
|
+
const stOrd = subtaskOrdinals(subtaskLines || []);
|
|
1070
|
+
const cbRe = /^(\s*)[-*] \[( |x|X)\] (.*)$/;
|
|
1071
|
+
text = text
|
|
1072
|
+
.split('\n')
|
|
1073
|
+
.map((line, i) => {
|
|
1074
|
+
const m = stOrd.has(i) && line.match(cbRe);
|
|
1075
|
+
return m ? `\x00S${stOrd.get(i)}:${m[2] === ' ' ? 'o' : 'x'}\x00${m[3]}` : line;
|
|
1076
|
+
})
|
|
1077
|
+
.join('\n');
|
|
1078
|
+
const outLines = [];
|
|
1079
|
+
let fenced = null;
|
|
1080
|
+
for (const line of text.split('\n')) {
|
|
1081
|
+
if (line.trimStart().startsWith('```')) {
|
|
1082
|
+
if (fenced === null) {
|
|
1083
|
+
fenced = [];
|
|
1084
|
+
} else {
|
|
1085
|
+
blocks.push(fenced.join('\n').trim());
|
|
1086
|
+
outLines.push(`\x00B${blocks.length - 1}\x00`);
|
|
1087
|
+
fenced = null;
|
|
1088
|
+
}
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (fenced !== null) fenced.push(line);
|
|
1092
|
+
else outLines.push(line);
|
|
1093
|
+
}
|
|
1094
|
+
if (fenced !== null && fenced.length) {
|
|
1095
|
+
blocks.push(fenced.join('\n').trim());
|
|
1096
|
+
outLines.push(`\x00B${blocks.length - 1}\x00`);
|
|
1097
|
+
}
|
|
1098
|
+
text = outLines.join('\n');
|
|
1099
|
+
text = text.replace(/`([^`\n]+)`/g, (_, c) => {
|
|
1100
|
+
inlines.push(c);
|
|
1101
|
+
return `\x00I${inlines.length - 1}\x00`;
|
|
1102
|
+
});
|
|
1103
|
+
text = esc(text);
|
|
1104
|
+
text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
1105
|
+
text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
1106
|
+
text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
1107
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
1108
|
+
text = text.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
|
1109
|
+
text = text.replace(/^\x00S(\d+):(x|o)\x00(.*)$/gm, (_, idx, st, rest) => {
|
|
1110
|
+
const done = st === 'x';
|
|
1111
|
+
return `<li class="subtask${done ? ' done' : ''}" data-idx="${idx}" role="checkbox" tabindex="0" aria-checked="${done}"><span class="cb">${done ? '☑' : '☐'}</span> ${rest}</li>`;
|
|
1112
|
+
});
|
|
1113
|
+
text = text.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
|
|
1114
|
+
// Re-insert extracted code AFTER the line transforms so fenced content
|
|
1115
|
+
// is never rewritten into headings/checkboxes/lists.
|
|
1116
|
+
text = text.replace(/\x00B(\d+)\x00/g, (_, i) => `<pre><code>${esc(blocks[+i] ?? '')}</code></pre>`);
|
|
1117
|
+
text = text.replace(/\x00I(\d+)\x00/g, (_, i) => `<code>${esc(inlines[+i] ?? '')}</code>`);
|
|
1118
|
+
return text
|
|
1119
|
+
.split(/\n\n+/)
|
|
1120
|
+
.map((p) => {
|
|
1121
|
+
p = p.trim();
|
|
1122
|
+
if (!p) return '';
|
|
1123
|
+
// No list items: pass headings/pre through untouched, wrap the rest in <p>.
|
|
1124
|
+
if (!/<li[ >]/.test(p)) {
|
|
1125
|
+
if (/^<(h[1-3]|pre)/.test(p)) return p;
|
|
1126
|
+
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
|
|
1127
|
+
}
|
|
1128
|
+
// Mixed block - group consecutive <li> into a <ul>, keeping non-list
|
|
1129
|
+
// lines as their own <p>/heading. A checklist that follows a heading
|
|
1130
|
+
// with no blank line still lands in a real <ul>, so the subtask
|
|
1131
|
+
// hanging-indent has the list padding it relies on (else the -14px
|
|
1132
|
+
// margin pulls the checkbox off the left edge).
|
|
1133
|
+
const out = [];
|
|
1134
|
+
let li = [],
|
|
1135
|
+
txt = [];
|
|
1136
|
+
const flushTxt = () => {
|
|
1137
|
+
if (txt.length) {
|
|
1138
|
+
out.push('<p>' + txt.join('<br>') + '</p>');
|
|
1139
|
+
txt = [];
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
const flushLi = () => {
|
|
1143
|
+
if (li.length) {
|
|
1144
|
+
out.push('<ul>' + li.join('') + '</ul>');
|
|
1145
|
+
li = [];
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
for (const line of p.split('\n')) {
|
|
1149
|
+
const t = line.trim();
|
|
1150
|
+
if (/^<li[ >]/.test(t)) {
|
|
1151
|
+
flushTxt();
|
|
1152
|
+
li.push(line);
|
|
1153
|
+
} else if (/^<(h[1-3]|pre)/.test(t)) {
|
|
1154
|
+
flushTxt();
|
|
1155
|
+
flushLi();
|
|
1156
|
+
out.push(line);
|
|
1157
|
+
} else {
|
|
1158
|
+
flushLi();
|
|
1159
|
+
txt.push(line);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
flushTxt();
|
|
1163
|
+
flushLi();
|
|
1164
|
+
return out.join('');
|
|
1165
|
+
})
|
|
1166
|
+
.join('');
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function esc(s) {
|
|
1170
|
+
return s
|
|
1171
|
+
.replace(/&/g, '&')
|
|
1172
|
+
.replace(/</g, '<')
|
|
1173
|
+
.replace(/>/g, '>')
|
|
1174
|
+
.replace(/"/g, '"');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Flip one checkbox in the local body copy using the server-provided
|
|
1178
|
+
// body-line address - no fence rules re-derived client-side.
|
|
1179
|
+
function toggleSubtaskInBody(story, index, done) {
|
|
1180
|
+
const ln = (story.subtask_lines || [])[index];
|
|
1181
|
+
const lines = (story.body || '').split('\n');
|
|
1182
|
+
if (ln == null || lines[ln] == null) return story.body;
|
|
1183
|
+
lines[ln] = lines[ln].replace(
|
|
1184
|
+
/^(\s*[-*] \[)( |x|X)(\] )/,
|
|
1185
|
+
(_, pre, __, post) => pre + (done ? 'x' : ' ') + post
|
|
1186
|
+
);
|
|
1187
|
+
return lines.join('\n');
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ── Lookup & DOM helpers ──────────────────────────────────────────────────
|
|
1191
|
+
|
|
1192
|
+
function allStories() {
|
|
1193
|
+
return [].concat(data.icebox || [], data.backlog || [], data.started || [], data.done || []);
|
|
1194
|
+
}
|
|
1195
|
+
function storyById(id) {
|
|
1196
|
+
return allStories().find((s) => s.id === id);
|
|
1197
|
+
}
|
|
1198
|
+
function storyExists(id) {
|
|
1199
|
+
return !!storyById(id);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function flashCard(el) {
|
|
1203
|
+
if (!el) return;
|
|
1204
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1205
|
+
el.classList.remove('flash');
|
|
1206
|
+
void el.offsetWidth; // restart animation
|
|
1207
|
+
el.classList.add('flash');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function gotoStory(id) {
|
|
1211
|
+
let target = document.querySelector(`.card[data-id="${id}"]`);
|
|
1212
|
+
if (target) {
|
|
1213
|
+
flashCard(target);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
// Blocking story may be a done card on another page - flip to it.
|
|
1217
|
+
const idx = (data.done || []).findIndex((s) => s.id === id);
|
|
1218
|
+
if (idx >= 0) {
|
|
1219
|
+
donePage = Math.floor(idx / DONE_PER_PAGE) + 1;
|
|
1220
|
+
renderDone();
|
|
1221
|
+
flashCard(document.querySelector(`.card[data-id="${id}"]`));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function el(tag, attrs, children) {
|
|
1226
|
+
const node = document.createElement(tag);
|
|
1227
|
+
Object.entries(attrs || {}).forEach(([k, v]) => {
|
|
1228
|
+
if (k === 'className') node.className = v;
|
|
1229
|
+
else if (k === 'style') node.style.cssText = v;
|
|
1230
|
+
else node.setAttribute(k, v);
|
|
1231
|
+
});
|
|
1232
|
+
(Array.isArray(children) ? children : children != null ? [children] : []).forEach((c) => {
|
|
1233
|
+
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
1234
|
+
});
|
|
1235
|
+
return node;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function api(method, url, body) {
|
|
1239
|
+
return fetch(url, {
|
|
1240
|
+
method,
|
|
1241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1242
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
1247
|
+
|
|
1248
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1249
|
+
setupDropZones();
|
|
1250
|
+
setupDoneCollapse();
|
|
1251
|
+
setupMobileTabs();
|
|
1252
|
+
initTheme();
|
|
1253
|
+
document
|
|
1254
|
+
.querySelectorAll('.theme-toggle')
|
|
1255
|
+
.forEach((b) => b.addEventListener('click', toggleTheme));
|
|
1256
|
+
document.getElementById('load-retry').addEventListener('click', load);
|
|
1257
|
+
load();
|
|
1258
|
+
});
|