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.
@@ -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(/(&lt;[^&]*&gt;)/g, '<span class="gh-param">$1</span>')
1059
+ .replace(/(&#34;[^&]*&#34;)/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, '&amp;')
1172
+ .replace(/</g, '&lt;')
1173
+ .replace(/>/g, '&gt;')
1174
+ .replace(/"/g, '&#34;');
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
+ });