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,382 @@
1
+ // Decisions board (BT-144): five columns, one per status directory, reusing
2
+ // the story board's structural components under data-status. Historical
3
+ // statuses collapse by default; accepted paginates the way done does.
4
+ (function () {
5
+ const API_BASE = window.BT_API_BASE || '';
6
+ const STATUSES = ['proposed', 'accepted', 'rejected', 'deprecated', 'superseded'];
7
+ const COLLAPSED = new Set(['rejected', 'deprecated', 'superseded']);
8
+ const PAGE_SIZE = 25;
9
+ const boardEl = document.getElementById('decision-board');
10
+ let records = [];
11
+ let acceptedPage = 0;
12
+ let dragged = null;
13
+
14
+ function el(tag, cls, text) {
15
+ const n = document.createElement(tag);
16
+ if (cls) n.className = cls;
17
+ if (text !== undefined) n.textContent = text;
18
+ return n;
19
+ }
20
+
21
+ function card(r) {
22
+ const c = el('div', 'card dcard');
23
+ c.dataset.id = r.id;
24
+ c.draggable = true;
25
+ c.addEventListener('dragstart', (e) => {
26
+ e.dataTransfer.setData('text/plain', r.id);
27
+ e.dataTransfer.effectAllowed = 'move';
28
+ dragged = { id: r.id, status: r.status, el: c };
29
+ });
30
+ c.addEventListener('dragend', () => {
31
+ dragged = null;
32
+ });
33
+ // Reorder within proposed (BT-147): the card follows the pointer live,
34
+ // and the drop commits the DOM order - position is the priority.
35
+ c.addEventListener('dragover', (e) => {
36
+ if (!dragged || dragged.status !== 'proposed' || r.status !== 'proposed' || dragged.el === c)
37
+ return;
38
+ e.preventDefault();
39
+ e.stopPropagation();
40
+ const before = e.offsetY < c.offsetHeight / 2;
41
+ c.parentNode.insertBefore(dragged.el, before ? c : c.nextSibling);
42
+ });
43
+ const idRow = el('div', 'card-id');
44
+ idRow.appendChild(el('span', null, r.id));
45
+ if (r.date) idRow.appendChild(el('span', 'd-date', r.date));
46
+ if (r.docs_path) {
47
+ // Inline in the id row, after the date - the absolute version sat on
48
+ // top of it (BT-175).
49
+ const rev = el('button', 'dcard-reveal', '↗');
50
+ rev.title = 'Reveal the file';
51
+ rev.addEventListener('click', (e) => {
52
+ e.stopPropagation();
53
+ revealRecord(r);
54
+ });
55
+ idRow.appendChild(rev);
56
+ }
57
+ c.appendChild(idRow);
58
+ c.appendChild(el('div', 'd-title', r.title));
59
+ if (r.superseded_by.length) {
60
+ const s = el('div', 'd-succ', 'superseded by ');
61
+ r.superseded_by.forEach((id) => {
62
+ const a = el('a', null, id);
63
+ a.addEventListener('click', (e) => {
64
+ e.stopPropagation();
65
+ jumpTo(id);
66
+ });
67
+ s.appendChild(a);
68
+ });
69
+ c.appendChild(s);
70
+ }
71
+ if (r.canonical) c.appendChild(el('div', 'd-succ', 'adopts ' + r.canonical));
72
+ if (r.stories.length) {
73
+ const row = el('div', 'd-succ');
74
+ r.stories.forEach((sid) => {
75
+ if (window.BTLogic.sameNamespace(r.id, sid)) {
76
+ const a = el('a', null, sid);
77
+ a.title = 'Jump to the story';
78
+ a.addEventListener('click', (e) => {
79
+ e.stopPropagation();
80
+ window.location = boardBase() + '#' + sid;
81
+ });
82
+ row.appendChild(a);
83
+ } else {
84
+ // Another namespace: plain text, never a link that lies (BT-ADR-0013).
85
+ row.appendChild(el('span', null, sid));
86
+ }
87
+ row.appendChild(document.createTextNode(' '));
88
+ });
89
+ c.appendChild(row);
90
+ }
91
+ c.addEventListener('click', () => openDetail(r));
92
+ return c;
93
+ }
94
+
95
+ function jumpTo(id) {
96
+ const target = records.find((r) => r.id === id);
97
+ if (target) openDetail(target);
98
+ }
99
+
100
+ function boardBase() {
101
+ return API_BASE || '/';
102
+ }
103
+
104
+ // 400 = scope refusal, 501 = no launcher on this platform - both say why.
105
+ async function revealRecord(r) {
106
+ try {
107
+ const res = await fetch(API_BASE + '/api/docs/reveal', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ path: r.docs_path }),
111
+ });
112
+ if (!res.ok) {
113
+ const err = (await res.json().catch(() => ({}))).error;
114
+ flashError(err || `reveal failed (HTTP ${res.status})`);
115
+ }
116
+ } catch {
117
+ flashError('network error - reveal failed');
118
+ }
119
+ }
120
+
121
+ async function openDetail(r) {
122
+ const pathEl = document.getElementById('doc-detail-path');
123
+ pathEl.textContent = r.id;
124
+ // The status directory is authoritative, so the overlay states it as the
125
+ // board does - a pill in the status' colour, not prose in the id line
126
+ // (BT-178).
127
+ const statusEl = document.getElementById('doc-detail-status');
128
+ statusEl.textContent = r.status;
129
+ statusEl.dataset.status = r.status;
130
+ // Reveal and supersession sit after the pill so the head reads
131
+ // id · status · where else to go.
132
+ const refsEl = document.getElementById('doc-detail-refs');
133
+ refsEl.textContent = '';
134
+ if (r.docs_path) {
135
+ const rev = el('a', null, '↗');
136
+ rev.title = 'Reveal the file';
137
+ rev.style.cursor = 'pointer';
138
+ rev.addEventListener('click', () => revealRecord(r));
139
+ refsEl.appendChild(rev);
140
+ }
141
+ // Supersession is navigable in both directions (BT-148).
142
+ [
143
+ ['supersedes', r.supersedes],
144
+ ['superseded by', r.superseded_by],
145
+ ].forEach(([label, ids]) => {
146
+ (ids || []).forEach((id) => {
147
+ refsEl.appendChild(document.createTextNode((refsEl.firstChild ? ' · ' : '') + label + ' '));
148
+ const a = el('a', null, id);
149
+ a.style.cursor = 'pointer';
150
+ a.addEventListener('click', () => jumpTo(id));
151
+ refsEl.appendChild(a);
152
+ });
153
+ });
154
+ document.getElementById('doc-detail-title').textContent = r.title;
155
+ const body = document.getElementById('doc-detail-body');
156
+ body.textContent = 'loading…';
157
+ document.getElementById('doc-detail').classList.add('open');
158
+ if (!r.docs_path) {
159
+ body.textContent = '(not reachable through the docs surface)';
160
+ return;
161
+ }
162
+ let page = null;
163
+ try {
164
+ const res = await fetch(API_BASE + '/api/docs/page?path=' + encodeURIComponent(r.docs_path));
165
+ if (res.ok) page = await res.json().catch(() => null);
166
+ } catch {
167
+ page = null;
168
+ }
169
+ if (!page || typeof page.html !== 'string') {
170
+ body.textContent = 'could not load';
171
+ return;
172
+ }
173
+ body.innerHTML = page.html; // sanitised server-side (render_markdown)
174
+ // The overlay head already carries id, status and title - the body's own
175
+ // # heading would say the title twice (BT-172).
176
+ const h1 = body.querySelector('h1');
177
+ if (h1) h1.remove();
178
+ }
179
+
180
+ function column(status) {
181
+ const col = el('section', 'column');
182
+ col.dataset.status = status;
183
+ // Drops render the primitive's rules (BT-ADR-0017): the transition is one
184
+ // PUT, the server validates, and the superseded column never accepts a
185
+ // bare drop - it opens the target picker instead.
186
+ col.addEventListener('dragover', (e) => {
187
+ e.preventDefault();
188
+ col.classList.add('drop-target');
189
+ });
190
+ col.addEventListener('dragleave', () => col.classList.remove('drop-target'));
191
+ col.addEventListener('drop', async (e) => {
192
+ e.preventDefault();
193
+ col.classList.remove('drop-target');
194
+ const id = e.dataTransfer.getData('text/plain');
195
+ if (!id) return;
196
+ if (status === 'proposed' && dragged && dragged.status === 'proposed') {
197
+ const ids = [...col.querySelectorAll('.dcard')].map((n) => n.dataset.id);
198
+ try {
199
+ const res = await fetch(API_BASE + '/api/decisions/proposed/order', {
200
+ method: 'PUT',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ ids }),
203
+ });
204
+ if (!res.ok) {
205
+ const err = (await res.json().catch(() => ({}))).error;
206
+ flashError(err || 'reorder refused');
207
+ }
208
+ } catch {
209
+ flashError('network error - reorder not saved');
210
+ }
211
+ await reload();
212
+ } else if (status === 'superseded') {
213
+ openPicker(id);
214
+ } else {
215
+ transition(id, status);
216
+ }
217
+ });
218
+ const inStatus = records.filter((r) => r.status === status);
219
+
220
+ const head = el('div', 'col-header');
221
+ const label = el('div', 'col-label');
222
+ const h2 = el('h2', null, status);
223
+ label.appendChild(h2);
224
+ label.appendChild(el('span', 'col-sublabel', String(inStatus.length)));
225
+ head.appendChild(label);
226
+ // No "+ new" on the board (BT-174): a record created from a prompt is
227
+ // boilerplate that goes stale - a decision starts in the editor, via
228
+ // `rake decision:new` or the skill. The API and Rake surfaces stay.
229
+ if (COLLAPSED.has(status)) {
230
+ const toggle = el('button', 'col-toggle', '⇔');
231
+ toggle.title = 'expand/collapse';
232
+ toggle.addEventListener('click', () => col.classList.toggle('collapsed'));
233
+ head.appendChild(toggle);
234
+ if (localStorage.getItem('bt-dec-' + status) !== 'open') col.classList.add('collapsed');
235
+ toggle.addEventListener('click', () =>
236
+ localStorage.setItem('bt-dec-' + status, col.classList.contains('collapsed') ? '' : 'open')
237
+ );
238
+ }
239
+ col.appendChild(head);
240
+
241
+ const cards = el('div', 'cards');
242
+ let visible = inStatus;
243
+ if (status === 'accepted' && inStatus.length > PAGE_SIZE) {
244
+ const pages = Math.ceil(inStatus.length / PAGE_SIZE);
245
+ acceptedPage = Math.min(acceptedPage, pages - 1);
246
+ visible = inStatus.slice(acceptedPage * PAGE_SIZE, (acceptedPage + 1) * PAGE_SIZE);
247
+ const pager = el('div', 'pagination');
248
+ const prev = el('button', null, '‹');
249
+ const next = el('button', null, '›');
250
+ prev.disabled = acceptedPage === 0;
251
+ next.disabled = acceptedPage >= pages - 1;
252
+ prev.addEventListener('click', () => {
253
+ acceptedPage--;
254
+ render();
255
+ });
256
+ next.addEventListener('click', () => {
257
+ acceptedPage++;
258
+ render();
259
+ });
260
+ pager.append(prev, el('span', null, acceptedPage + 1 + '/' + pages), next);
261
+ col.appendChild(cards);
262
+ col.appendChild(pager);
263
+ } else {
264
+ col.appendChild(cards);
265
+ }
266
+ visible.forEach((r) => cards.appendChild(card(r)));
267
+ return col;
268
+ }
269
+
270
+ function render() {
271
+ boardEl.textContent = '';
272
+ STATUSES.forEach((s) => boardEl.appendChild(column(s)));
273
+ }
274
+
275
+ function closeDetail() {
276
+ document.getElementById('doc-detail').classList.remove('open');
277
+ }
278
+ document.getElementById('doc-detail-close').addEventListener('click', closeDetail);
279
+ document.getElementById('doc-detail').addEventListener('click', (e) => {
280
+ if (e.target.id === 'doc-detail') closeDetail();
281
+ });
282
+ document.addEventListener('keydown', (e) => {
283
+ if (e.key !== 'Escape') return;
284
+ closePicker(); // the picker would otherwise trap the board until ✕
285
+ closeDetail();
286
+ });
287
+
288
+ async function transition(id, status, supersededBy) {
289
+ let res;
290
+ try {
291
+ res = await fetch(API_BASE + '/api/decisions/' + encodeURIComponent(id) + '/status', {
292
+ method: 'PUT',
293
+ headers: { 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({ status, superseded_by: supersededBy }),
295
+ });
296
+ } catch {
297
+ flashError('network error - transition not saved');
298
+ return;
299
+ }
300
+ const data = await res.json().catch(() => ({}));
301
+ if (!res.ok) {
302
+ flashError(data.error || 'transition refused');
303
+ return;
304
+ }
305
+ if (data.external && data.external.length) {
306
+ flashError(
307
+ `${data.external.join(', ')} is in another namespace - its supersedes side was NOT written; record it there`,
308
+ true
309
+ );
310
+ }
311
+ await reload();
312
+ }
313
+
314
+ function flashError(text, info) {
315
+ const bar = el('div', 'board-flash' + (info ? ' info' : ''), text);
316
+ document.body.appendChild(bar);
317
+ setTimeout(() => bar.remove(), info ? 9000 : 6000);
318
+ }
319
+
320
+ // The supersede target picker (BT-145): a drop on superseded is completed
321
+ // with a target or not at all. Candidates are the accepted records; the free
322
+ // field takes a cross-namespace id, whose other side the server will
323
+ // truthfully report as unwritten.
324
+ const pickerEl = document.getElementById('supersede-picker');
325
+ function openPicker(id) {
326
+ document.getElementById('picker-subject').textContent = id;
327
+ const list = document.getElementById('picker-list');
328
+ list.textContent = '';
329
+ window.BTLogic.supersedeCandidates(records, id).forEach((cand) => {
330
+ const b = el('button', 'picker-cand', cand);
331
+ b.addEventListener('click', () => {
332
+ closePicker();
333
+ transition(id, 'superseded', cand);
334
+ });
335
+ list.appendChild(b);
336
+ });
337
+ const input = document.getElementById('picker-free');
338
+ input.value = '';
339
+ pickerEl.classList.add('open');
340
+ input.focus();
341
+ }
342
+ function closePicker() {
343
+ pickerEl.classList.remove('open');
344
+ }
345
+ document.getElementById('picker-cancel').addEventListener('click', closePicker);
346
+ pickerEl.addEventListener('click', (e) => {
347
+ if (e.target === pickerEl) closePicker();
348
+ });
349
+ document.getElementById('picker-free').addEventListener('keydown', (e) => {
350
+ if (e.key !== 'Enter') return;
351
+ const v = e.target.value.trim();
352
+ if (!v) return;
353
+ const id = document.getElementById('picker-subject').textContent;
354
+ closePicker();
355
+ transition(id, 'superseded', v);
356
+ });
357
+
358
+ // A 500's {error} object or a dropped connection must not become
359
+ // "records.filter is not a function" - keep the last good board and say so.
360
+ async function reload() {
361
+ let next = null;
362
+ try {
363
+ const res = await fetch(API_BASE + '/api/decisions');
364
+ next = await res.json().catch(() => null);
365
+ } catch {
366
+ next = null;
367
+ }
368
+ if (!Array.isArray(next)) {
369
+ flashError((next && next.error) || 'could not load the decisions');
370
+ return false;
371
+ }
372
+ records = next;
373
+ render();
374
+ return true;
375
+ }
376
+
377
+ (async function load() {
378
+ if (!(await reload())) return;
379
+ const id = window.BTLogic.hashId(location.hash);
380
+ if (id) jumpTo(id);
381
+ })();
382
+ })();