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,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
|
+
})();
|