beads-ui 0.3.1 → 0.4.1

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.
Files changed (56) hide show
  1. package/CHANGES.md +24 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -14
  7. package/server/app.js +38 -36
  8. package/server/bd.js +2 -2
  9. package/server/cli/commands.js +8 -8
  10. package/server/cli/daemon.js +13 -5
  11. package/server/cli/index.js +17 -31
  12. package/server/cli/usage.js +3 -2
  13. package/server/db.js +6 -6
  14. package/server/index.js +10 -4
  15. package/server/logging.js +23 -0
  16. package/server/watcher.js +7 -5
  17. package/server/ws.js +23 -11
  18. package/app/data/list-selectors.js +0 -103
  19. package/app/data/providers.js +0 -78
  20. package/app/data/sort.js +0 -47
  21. package/app/data/subscription-issue-store.js +0 -161
  22. package/app/data/subscription-issue-stores.js +0 -128
  23. package/app/data/subscriptions-store.js +0 -227
  24. package/app/main.js +0 -706
  25. package/app/protocol.md +0 -66
  26. package/app/router.js +0 -117
  27. package/app/state.js +0 -105
  28. package/app/utils/issue-id-renderer.js +0 -72
  29. package/app/utils/issue-id.js +0 -11
  30. package/app/utils/issue-type.js +0 -29
  31. package/app/utils/issue-url.js +0 -10
  32. package/app/utils/markdown.js +0 -16
  33. package/app/utils/priority-badge.js +0 -48
  34. package/app/utils/priority.js +0 -1
  35. package/app/utils/status-badge.js +0 -33
  36. package/app/utils/status.js +0 -25
  37. package/app/utils/toast.js +0 -35
  38. package/app/utils/type-badge.js +0 -34
  39. package/app/views/board.js +0 -537
  40. package/app/views/detail.js +0 -1252
  41. package/app/views/epics.js +0 -281
  42. package/app/views/issue-dialog.js +0 -164
  43. package/app/views/issue-row.js +0 -191
  44. package/app/views/list.js +0 -468
  45. package/app/views/nav.js +0 -68
  46. package/app/views/new-issue-dialog.js +0 -348
  47. package/app/ws.js +0 -282
  48. package/docs/adr/001-push-only-lists.md +0 -134
  49. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  50. package/docs/architecture.md +0 -194
  51. package/docs/data-exchange-subscription-plan.md +0 -198
  52. package/docs/db-watching.md +0 -30
  53. package/docs/migration-v2.md +0 -54
  54. package/docs/protocol/issues-push-v2.md +0 -179
  55. package/docs/subscription-issue-store.md +0 -112
  56. package/server/protocol.js +0 -3
@@ -1,537 +0,0 @@
1
- import { html, render } from 'lit-html';
2
- import { createListSelectors } from '../data/list-selectors.js';
3
- import { cmpClosedDesc, cmpPriorityThenCreated } from '../data/sort.js';
4
- import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
5
- import { createPriorityBadge } from '../utils/priority-badge.js';
6
- import { createTypeBadge } from '../utils/type-badge.js';
7
-
8
- /**
9
- * @typedef {{
10
- * id: string,
11
- * title?: string,
12
- * status?: 'open'|'in_progress'|'closed',
13
- * priority?: number,
14
- * issue_type?: string,
15
- * created_at?: number,
16
- * updated_at?: number,
17
- * closed_at?: number
18
- * }} IssueLite
19
- */
20
-
21
- /**
22
- * Create the Board view with Blocked, Ready, In progress, Closed.
23
- * Push-only: derives items from per-subscription stores.
24
- *
25
- * Sorting rules:
26
- * - Ready/Blocked/In progress: priority asc, then created_at asc.
27
- * - Closed: closed_at desc.
28
- *
29
- * @param {HTMLElement} mount_element
30
- * @param {unknown} _data - Unused (legacy param retained for call-compat)
31
- * @param {(id: string) => void} gotoIssue - Navigate to issue detail.
32
- * @param {{ getState: () => any, setState: (patch: any) => void, subscribe?: (fn: (s:any)=>void)=>()=>void }} [store]
33
- * @param {{ selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
34
- * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
35
- * @returns {{ load: () => Promise<void>, clear: () => void }}
36
- */
37
- export function createBoardView(
38
- mount_element,
39
- _data,
40
- gotoIssue,
41
- store,
42
- subscriptions = undefined,
43
- issueStores = undefined
44
- ) {
45
- /** @type {IssueLite[]} */
46
- let list_ready = [];
47
- /** @type {IssueLite[]} */
48
- let list_blocked = [];
49
- /** @type {IssueLite[]} */
50
- let list_in_progress = [];
51
- /** @type {IssueLite[]} */
52
- let list_closed = [];
53
- /** @type {IssueLite[]} */
54
- let list_closed_raw = [];
55
- // Centralized selection helpers
56
- const selectors = issueStores ? createListSelectors(issueStores) : null;
57
-
58
- /**
59
- * Closed column filter mode.
60
- * 'today' → items with closed_at since local day start
61
- * '3' → last 3 days; '7' → last 7 days
62
- *
63
- * @type {'today'|'3'|'7'}
64
- */
65
- let closed_filter_mode = 'today';
66
- if (store) {
67
- try {
68
- const s = store.getState();
69
- const cf =
70
- s && s.board ? String(s.board.closed_filter || 'today') : 'today';
71
- if (cf === 'today' || cf === '3' || cf === '7') {
72
- closed_filter_mode = /** @type {any} */ (cf);
73
- }
74
- } catch {
75
- // ignore store init errors
76
- }
77
- }
78
-
79
- function template() {
80
- return html`
81
- <div class="panel__body board-root">
82
- ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
83
- ${columnTemplate('Ready', 'ready-col', list_ready)}
84
- ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
85
- ${columnTemplate('Closed', 'closed-col', list_closed)}
86
- </div>
87
- `;
88
- }
89
-
90
- /**
91
- * @param {string} title
92
- * @param {string} id
93
- * @param {IssueLite[]} items
94
- */
95
- function columnTemplate(title, id, items) {
96
- return html`
97
- <section class="board-column" id=${id}>
98
- <header
99
- class="board-column__header"
100
- id=${id + '-header'}
101
- role="heading"
102
- aria-level="2"
103
- >
104
- <span>${title}</span>
105
- ${id === 'closed-col'
106
- ? html`<label class="board-closed-filter">
107
- <span class="visually-hidden">Filter closed issues</span>
108
- <select
109
- id="closed-filter"
110
- aria-label="Filter closed issues"
111
- @change=${onClosedFilterChange}
112
- >
113
- <option
114
- value="today"
115
- ?selected=${closed_filter_mode === 'today'}
116
- >
117
- Today
118
- </option>
119
- <option value="3" ?selected=${closed_filter_mode === '3'}>
120
- Last 3 days
121
- </option>
122
- <option value="7" ?selected=${closed_filter_mode === '7'}>
123
- Last 7 days
124
- </option>
125
- </select>
126
- </label>`
127
- : ''}
128
- </header>
129
- <div
130
- class="board-column__body"
131
- role="list"
132
- aria-labelledby=${id + '-header'}
133
- >
134
- ${items.map((it) => cardTemplate(it))}
135
- </div>
136
- </section>
137
- `;
138
- }
139
-
140
- /**
141
- * @param {IssueLite} it
142
- */
143
- function cardTemplate(it) {
144
- return html`
145
- <article
146
- class="board-card"
147
- data-issue-id=${it.id}
148
- role="listitem"
149
- tabindex="-1"
150
- @click=${() => gotoIssue(it.id)}
151
- >
152
- <div class="board-card__title text-truncate">
153
- ${it.title || '(no title)'}
154
- </div>
155
- <div class="board-card__meta">
156
- ${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
157
- ${createIssueIdRenderer(it.id, { class_name: 'mono' })}
158
- </div>
159
- </article>
160
- `;
161
- }
162
-
163
- function doRender() {
164
- render(template(), mount_element);
165
- postRenderEnhance();
166
- }
167
-
168
- /**
169
- * Enhance rendered board with a11y and keyboard navigation.
170
- * - Roving tabindex per column (first card tabbable).
171
- * - ArrowUp/ArrowDown within column.
172
- * - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card).
173
- * - Enter/Space to open details for focused card.
174
- */
175
- function postRenderEnhance() {
176
- try {
177
- /** @type {HTMLElement[]} */
178
- const columns = Array.from(
179
- mount_element.querySelectorAll('.board-column')
180
- );
181
- for (const col of columns) {
182
- const body = /** @type {HTMLElement|null} */ (
183
- col.querySelector('.board-column__body')
184
- );
185
- if (!body) {
186
- continue;
187
- }
188
- /** @type {HTMLElement[]} */
189
- const cards = Array.from(body.querySelectorAll('.board-card'));
190
- // Assign aria-label using column header for screen readers
191
- const header = /** @type {HTMLElement|null} */ (
192
- col.querySelector('.board-column__header')
193
- );
194
- const col_name = header ? header.textContent?.trim() || '' : '';
195
- for (const card of cards) {
196
- const title_el = /** @type {HTMLElement|null} */ (
197
- card.querySelector('.board-card__title')
198
- );
199
- const t = title_el ? title_el.textContent?.trim() || '' : '';
200
- card.setAttribute(
201
- 'aria-label',
202
- `Issue ${t || '(no title)'} — Column ${col_name}`
203
- );
204
- // Default roving setup
205
- card.tabIndex = -1;
206
- }
207
- if (cards.length > 0) {
208
- cards[0].tabIndex = 0;
209
- }
210
- }
211
- } catch {
212
- // non-fatal
213
- }
214
- }
215
-
216
- // Delegate keyboard handling from mount_element
217
- mount_element.addEventListener('keydown', (ev) => {
218
- const target = ev.target;
219
- if (!target || !(target instanceof HTMLElement)) {
220
- return;
221
- }
222
- // Do not intercept keys inside editable controls
223
- const tag = String(target.tagName || '').toLowerCase();
224
- if (
225
- tag === 'input' ||
226
- tag === 'textarea' ||
227
- tag === 'select' ||
228
- target.isContentEditable === true
229
- ) {
230
- return;
231
- }
232
- const card = target.closest('.board-card');
233
- if (!card) {
234
- return;
235
- }
236
- const key = String(ev.key || '');
237
- if (key === 'Enter' || key === ' ') {
238
- ev.preventDefault();
239
- const id = card.getAttribute('data-issue-id');
240
- if (id) {
241
- gotoIssue(id);
242
- }
243
- return;
244
- }
245
- if (
246
- key !== 'ArrowUp' &&
247
- key !== 'ArrowDown' &&
248
- key !== 'ArrowLeft' &&
249
- key !== 'ArrowRight'
250
- ) {
251
- return;
252
- }
253
- ev.preventDefault();
254
- // Column context
255
- const col = /** @type {HTMLElement|null} */ (card.closest('.board-column'));
256
- if (!col) {
257
- return;
258
- }
259
- const body = col.querySelector('.board-column__body');
260
- if (!body) {
261
- return;
262
- }
263
- /** @type {HTMLElement[]} */
264
- const cards = Array.from(body.querySelectorAll('.board-card'));
265
- const idx = cards.indexOf(/** @type {HTMLElement} */ (card));
266
- if (idx === -1) {
267
- return;
268
- }
269
- if (key === 'ArrowDown' && idx < cards.length - 1) {
270
- moveFocus(cards[idx], cards[idx + 1]);
271
- return;
272
- }
273
- if (key === 'ArrowUp' && idx > 0) {
274
- moveFocus(cards[idx], cards[idx - 1]);
275
- return;
276
- }
277
- if (key === 'ArrowRight' || key === 'ArrowLeft') {
278
- // Find adjacent column with at least one card
279
- /** @type {HTMLElement[]} */
280
- const cols = Array.from(mount_element.querySelectorAll('.board-column'));
281
- const col_idx = cols.indexOf(col);
282
- if (col_idx === -1) {
283
- return;
284
- }
285
- const dir = key === 'ArrowRight' ? 1 : -1;
286
- let next_idx = col_idx + dir;
287
- /** @type {HTMLElement|null} */
288
- let target_col = null;
289
- while (next_idx >= 0 && next_idx < cols.length) {
290
- const candidate = cols[next_idx];
291
- const c_body = /** @type {HTMLElement|null} */ (
292
- candidate.querySelector('.board-column__body')
293
- );
294
- const c_cards = c_body
295
- ? Array.from(c_body.querySelectorAll('.board-card'))
296
- : [];
297
- if (c_cards.length > 0) {
298
- target_col = candidate;
299
- break;
300
- }
301
- next_idx += dir;
302
- }
303
- if (target_col) {
304
- const first = /** @type {HTMLElement|null} */ (
305
- target_col.querySelector('.board-column__body .board-card')
306
- );
307
- if (first) {
308
- moveFocus(/** @type {HTMLElement} */ (card), first);
309
- }
310
- }
311
- return;
312
- }
313
- });
314
-
315
- /**
316
- * @param {HTMLElement} from
317
- * @param {HTMLElement} to
318
- */
319
- function moveFocus(from, to) {
320
- try {
321
- from.tabIndex = -1;
322
- to.tabIndex = 0;
323
- to.focus();
324
- } catch {
325
- // ignore focus errors
326
- }
327
- }
328
-
329
- // Sort helpers centralized in app/data/sort.js
330
-
331
- /**
332
- * Recompute closed list from raw using the current filter and sort.
333
- */
334
- function applyClosedFilter() {
335
- /** @type {IssueLite[]} */
336
- let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
337
- const now = new Date();
338
- let since_ts = 0;
339
- if (closed_filter_mode === 'today') {
340
- const start = new Date(
341
- now.getFullYear(),
342
- now.getMonth(),
343
- now.getDate(),
344
- 0,
345
- 0,
346
- 0,
347
- 0
348
- );
349
- since_ts = start.getTime();
350
- } else if (closed_filter_mode === '3') {
351
- since_ts = now.getTime() - 3 * 24 * 60 * 60 * 1000;
352
- } else if (closed_filter_mode === '7') {
353
- since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
354
- }
355
- items = items.filter((it) => {
356
- const s = Number.isFinite(it.closed_at)
357
- ? /** @type {number} */ (it.closed_at)
358
- : NaN;
359
- if (!Number.isFinite(s)) {
360
- return false;
361
- }
362
- return s >= since_ts;
363
- });
364
- items.sort(cmpClosedDesc);
365
- list_closed = items;
366
- }
367
-
368
- /**
369
- * @param {Event} ev
370
- */
371
- function onClosedFilterChange(ev) {
372
- try {
373
- const el = /** @type {HTMLSelectElement} */ (ev.target);
374
- const v = String(el.value || 'today');
375
- closed_filter_mode = v === '3' || v === '7' ? v : 'today';
376
- if (store) {
377
- try {
378
- store.setState({ board: { closed_filter: closed_filter_mode } });
379
- } catch {
380
- // ignore store errors
381
- }
382
- }
383
- applyClosedFilter();
384
- doRender();
385
- } catch {
386
- // ignore
387
- }
388
- }
389
-
390
- /**
391
- * Compose lists from subscriptions + issues store and render.
392
- */
393
- function refreshFromStores() {
394
- try {
395
- if (selectors) {
396
- const in_progress = selectors.selectBoardColumn(
397
- 'tab:board:in-progress',
398
- 'in_progress'
399
- );
400
- const blocked = selectors.selectBoardColumn(
401
- 'tab:board:blocked',
402
- 'blocked'
403
- );
404
- const ready_raw = selectors.selectBoardColumn(
405
- 'tab:board:ready',
406
- 'ready'
407
- );
408
- const closed = selectors.selectBoardColumn(
409
- 'tab:board:closed',
410
- 'closed'
411
- );
412
-
413
- // Ready excludes items that are in progress
414
- /** @type {Set<string>} */
415
- const in_prog_ids = new Set(in_progress.map((i) => i.id));
416
- const ready = ready_raw.filter((i) => !in_prog_ids.has(i.id));
417
-
418
- list_ready = ready;
419
- list_blocked = blocked;
420
- list_in_progress = in_progress;
421
- list_closed_raw = closed;
422
- }
423
- applyClosedFilter();
424
- doRender();
425
- } catch {
426
- list_ready = [];
427
- list_blocked = [];
428
- list_in_progress = [];
429
- list_closed = [];
430
- doRender();
431
- }
432
- }
433
-
434
- // Live updates: recompose on issue store envelopes
435
- if (selectors) {
436
- selectors.subscribe(() => {
437
- try {
438
- refreshFromStores();
439
- } catch {
440
- // ignore
441
- }
442
- });
443
- }
444
-
445
- return {
446
- async load() {
447
- // Compose lists from subscriptions + issues store
448
- refreshFromStores();
449
- // If nothing is present yet (e.g., immediately after switching back
450
- // to the Board and before list-delta arrives), fetch via data layer as
451
- // a fallback so the board is not empty on initial display.
452
- try {
453
- const has_subs = Boolean(subscriptions && subscriptions.selectors);
454
- /**
455
- * @param {string} id
456
- */
457
- const cnt = (id) => {
458
- if (!has_subs || !subscriptions) {
459
- return 0;
460
- }
461
- const sel = subscriptions.selectors;
462
- if (typeof sel.count === 'function') {
463
- return Number(sel.count(id) || 0);
464
- }
465
- try {
466
- const arr = sel.getIds(id);
467
- return Array.isArray(arr) ? arr.length : 0;
468
- } catch {
469
- return 0;
470
- }
471
- };
472
- const total_items =
473
- cnt('tab:board:ready') +
474
- cnt('tab:board:blocked') +
475
- cnt('tab:board:in-progress') +
476
- cnt('tab:board:closed');
477
- const data = /** @type {any} */ (_data);
478
- const can_fetch =
479
- data &&
480
- typeof data.getReady === 'function' &&
481
- typeof data.getBlocked === 'function' &&
482
- typeof data.getInProgress === 'function' &&
483
- typeof data.getClosed === 'function';
484
- if (total_items === 0 && can_fetch) {
485
- /** @type {[IssueLite[], IssueLite[], IssueLite[], IssueLite[]]} */
486
- const [ready_raw, blocked_raw, in_prog_raw, closed_raw] =
487
- await Promise.all([
488
- data.getReady().catch(() => []),
489
- data.getBlocked().catch(() => []),
490
- data.getInProgress().catch(() => []),
491
- data.getClosed().catch(() => [])
492
- ]);
493
- // Normalize and map unknowns to IssueLite shape
494
- /** @type {IssueLite[]} */
495
- let ready = Array.isArray(ready_raw) ? ready_raw.map((it) => it) : [];
496
- /** @type {IssueLite[]} */
497
- const blocked = Array.isArray(blocked_raw)
498
- ? blocked_raw.map((it) => it)
499
- : [];
500
- /** @type {IssueLite[]} */
501
- const in_prog = Array.isArray(in_prog_raw)
502
- ? in_prog_raw.map((it) => it)
503
- : [];
504
- /** @type {IssueLite[]} */
505
- const closed = Array.isArray(closed_raw)
506
- ? closed_raw.map((it) => it)
507
- : [];
508
-
509
- // Remove items from Ready that are already In Progress
510
- /** @type {Set<string>} */
511
- const in_progress_ids = new Set(in_prog.map((i) => i.id));
512
- ready = ready.filter((i) => !in_progress_ids.has(i.id));
513
-
514
- // Sort as per column rules
515
- ready.sort(cmpPriorityThenCreated);
516
- blocked.sort(cmpPriorityThenCreated);
517
- in_prog.sort(cmpPriorityThenCreated);
518
- list_ready = ready;
519
- list_blocked = blocked;
520
- list_in_progress = in_prog;
521
- list_closed_raw = closed;
522
- applyClosedFilter();
523
- doRender();
524
- }
525
- } catch {
526
- // ignore fallback errors
527
- }
528
- },
529
- clear() {
530
- mount_element.replaceChildren();
531
- list_ready = [];
532
- list_blocked = [];
533
- list_in_progress = [];
534
- list_closed = [];
535
- }
536
- };
537
- }