beads-ui 0.2.0 → 0.3.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 (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
@@ -1,4 +1,6 @@
1
1
  import { html, render } from 'lit-html';
2
+ import { createListSelectors } from '../data/list-selectors.js';
3
+ import { cmpClosedDesc, cmpPriorityThenCreated } from '../data/sort.js';
2
4
  import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
5
  import { createPriorityBadge } from '../utils/priority-badge.js';
4
6
  import { createTypeBadge } from '../utils/type-badge.js';
@@ -10,29 +12,36 @@ import { createTypeBadge } from '../utils/type-badge.js';
10
12
  * status?: 'open'|'in_progress'|'closed',
11
13
  * priority?: number,
12
14
  * issue_type?: string,
13
- * updated_at?: string,
14
- * closed_at?: string
15
+ * created_at?: number,
16
+ * updated_at?: number,
17
+ * closed_at?: number
15
18
  * }} IssueLite
16
19
  */
17
20
 
18
21
  /**
19
- * Create the Board view with Open + Blocked stacked, then Ready, In progress, Closed.
20
- * Data providers are expected to return raw arrays; this view applies sorting.
22
+ * Create the Board view with Blocked, Ready, In progress, Closed.
23
+ * Push-only: derives items from per-subscription stores.
21
24
  *
22
25
  * Sorting rules:
23
- * - Open: priority asc, then updated_at desc when present
24
- * - Ready: priority asc, then updated_at desc when present
25
- * - In progress: updated_at desc
26
- * - Closed: closed_at desc (fallback to updated_at)
26
+ * - Ready/Blocked/In progress: priority asc, then created_at asc.
27
+ * - Closed: closed_at desc.
28
+ *
27
29
  * @param {HTMLElement} mount_element
28
- * @param {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getBlocked?: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: (limit?: number) => Promise<any[]> }} data
30
+ * @param {unknown} _data - Unused (legacy param retained for call-compat)
29
31
  * @param {(id: string) => void} gotoIssue - Navigate to issue detail.
30
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]
31
35
  * @returns {{ load: () => Promise<void>, clear: () => void }}
32
36
  */
33
- export function createBoardView(mount_element, data, gotoIssue, store) {
34
- /** @type {IssueLite[]} */
35
- let list_open = [];
37
+ export function createBoardView(
38
+ mount_element,
39
+ _data,
40
+ gotoIssue,
41
+ store,
42
+ subscriptions = undefined,
43
+ issueStores = undefined
44
+ ) {
36
45
  /** @type {IssueLite[]} */
37
46
  let list_ready = [];
38
47
  /** @type {IssueLite[]} */
@@ -43,11 +52,14 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
43
52
  let list_closed = [];
44
53
  /** @type {IssueLite[]} */
45
54
  let list_closed_raw = [];
55
+ // Centralized selection helpers
56
+ const selectors = issueStores ? createListSelectors(issueStores) : null;
46
57
 
47
58
  /**
48
59
  * Closed column filter mode.
49
60
  * 'today' → items with closed_at since local day start
50
61
  * '3' → last 3 days; '7' → last 7 days
62
+ *
51
63
  * @type {'today'|'3'|'7'}
52
64
  */
53
65
  let closed_filter_mode = 'today';
@@ -67,10 +79,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
67
79
  function template() {
68
80
  return html`
69
81
  <div class="panel__body board-root">
70
- <div class="board-stack-2">
71
- ${columnTemplate('Open', 'open-col', list_open)}
72
- ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
73
- </div>
82
+ ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
74
83
  ${columnTemplate('Ready', 'ready-col', list_ready)}
75
84
  ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
76
85
  ${columnTemplate('Closed', 'closed-col', list_closed)}
@@ -144,8 +153,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
144
153
  ${it.title || '(no title)'}
145
154
  </div>
146
155
  <div class="board-card__meta">
147
- ${createTypeBadge(/** @type {any} */ (it).issue_type)}
148
- ${createPriorityBadge(/** @type {any} */ (it).priority)}
156
+ ${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
149
157
  ${createIssueIdRenderer(it.id, { class_name: 'mono' })}
150
158
  </div>
151
159
  </article>
@@ -159,10 +167,10 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
159
167
 
160
168
  /**
161
169
  * Enhance rendered board with a11y and keyboard navigation.
162
- * - Roving tabindex per column (first card tabbable)
163
- * - ArrowUp/ArrowDown within column
164
- * - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card)
165
- * - Enter/Space to open details for focused card
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.
166
174
  */
167
175
  function postRenderEnhance() {
168
176
  try {
@@ -171,8 +179,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
171
179
  mount_element.querySelectorAll('.board-column')
172
180
  );
173
181
  for (const col of columns) {
174
- /** @type {HTMLElement|null} */
175
- const body = /** @type {any} */ (
182
+ const body = /** @type {HTMLElement|null} */ (
176
183
  col.querySelector('.board-column__body')
177
184
  );
178
185
  if (!body) {
@@ -208,8 +215,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
208
215
 
209
216
  // Delegate keyboard handling from mount_element
210
217
  mount_element.addEventListener('keydown', (ev) => {
211
- /** @type {HTMLElement} */
212
- const target = /** @type {any} */ (ev.target);
218
+ const target = ev.target;
213
219
  if (!target || !(target instanceof HTMLElement)) {
214
220
  return;
215
221
  }
@@ -219,7 +225,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
219
225
  tag === 'input' ||
220
226
  tag === 'textarea' ||
221
227
  tag === 'select' ||
222
- /** @type {any} */ (target).isContentEditable === true
228
+ target.isContentEditable === true
223
229
  ) {
224
230
  return;
225
231
  }
@@ -230,9 +236,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
230
236
  const key = String(ev.key || '');
231
237
  if (key === 'Enter' || key === ' ') {
232
238
  ev.preventDefault();
233
- const id = /** @type {HTMLElement} */ (card).getAttribute(
234
- 'data-issue-id'
235
- );
239
+ const id = card.getAttribute('data-issue-id');
236
240
  if (id) {
237
241
  gotoIssue(id);
238
242
  }
@@ -252,9 +256,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
252
256
  if (!col) {
253
257
  return;
254
258
  }
255
- const body = /** @type {HTMLElement|null} */ (
256
- col.querySelector('.board-column__body')
257
- );
259
+ const body = col.querySelector('.board-column__body');
258
260
  if (!body) {
259
261
  return;
260
262
  }
@@ -324,47 +326,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
324
326
  }
325
327
  }
326
328
 
327
- /**
328
- * Sort helpers.
329
- */
330
- /**
331
- * @param {IssueLite[]} arr
332
- */
333
- function sortReady(arr) {
334
- arr.sort((a, b) => {
335
- const pa = a.priority ?? 2;
336
- const pb = b.priority ?? 2;
337
- if (pa !== pb) {
338
- return pa - pb;
339
- }
340
- const ua = a.updated_at || '';
341
- const ub = b.updated_at || '';
342
- return ua < ub ? 1 : ua > ub ? -1 : 0;
343
- });
344
- }
345
-
346
- /**
347
- * @param {IssueLite[]} arr
348
- */
349
- function sortByUpdatedDesc(arr) {
350
- arr.sort((a, b) => {
351
- const ua = a.updated_at || '';
352
- const ub = b.updated_at || '';
353
- return ua < ub ? 1 : ua > ub ? -1 : 0;
354
- });
355
- }
356
-
357
- /**
358
- * Sort by closed_at desc with updated_at fallback.
359
- * @param {IssueLite[]} arr
360
- */
361
- function sortByClosedDesc(arr) {
362
- arr.sort((a, b) => {
363
- const ca = a.closed_at || a.updated_at || '';
364
- const cb = b.closed_at || b.updated_at || '';
365
- return ca < cb ? 1 : ca > cb ? -1 : 0;
366
- });
367
- }
329
+ // Sort helpers centralized in app/data/sort.js
368
330
 
369
331
  /**
370
332
  * Recompute closed list from raw using the current filter and sort.
@@ -373,7 +335,6 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
373
335
  /** @type {IssueLite[]} */
374
336
  let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
375
337
  const now = new Date();
376
- /** @type {number} */
377
338
  let since_ts = 0;
378
339
  if (closed_filter_mode === 'today') {
379
340
  const start = new Date(
@@ -392,14 +353,15 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
392
353
  since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
393
354
  }
394
355
  items = items.filter((it) => {
395
- const s = it.closed_at || '';
396
- if (!s || isNaN(Date.parse(s))) {
356
+ const s = Number.isFinite(it.closed_at)
357
+ ? /** @type {number} */ (it.closed_at)
358
+ : NaN;
359
+ if (!Number.isFinite(s)) {
397
360
  return false;
398
361
  }
399
- const t = Date.parse(s);
400
- return t >= since_ts;
362
+ return s >= since_ts;
401
363
  });
402
- sortByClosedDesc(items);
364
+ items.sort(cmpClosedDesc);
403
365
  list_closed = items;
404
366
  }
405
367
 
@@ -425,85 +387,147 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
425
387
  }
426
388
  }
427
389
 
428
- return {
429
- async load() {
430
- /** @type {IssueLite[]} */
431
- let o = [];
432
- /** @type {IssueLite[]} */
433
- let r = [];
434
- /** @type {IssueLite[]} */
435
- let b = [];
436
- /** @type {IssueLite[]} */
437
- let p = [];
438
- /** @type {IssueLite[]} */
439
- let c = [];
440
- try {
441
- o = /** @type {any} */ (await data.getOpen());
442
- } catch {
443
- o = [];
444
- }
445
- try {
446
- r = /** @type {any} */ (await data.getReady());
447
- } catch {
448
- r = [];
449
- }
450
- try {
451
- // getBlocked is optional for backward compatibility in tests
452
- const fn = /** @type {any} */ (data).getBlocked;
453
- b = typeof fn === 'function' ? /** @type {any} */ (await fn()) : [];
454
- } catch {
455
- b = [];
456
- }
457
- try {
458
- p = /** @type {any} */ (await data.getInProgress());
459
- } catch {
460
- p = [];
461
- }
462
- try {
463
- c = /** @type {any} */ (await data.getClosed());
464
- } catch {
465
- c = [];
466
- }
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
+ );
467
412
 
468
- // Remove items from Open that are already in Ready by id
469
- if (o.length > 0 && r.length > 0) {
413
+ // Ready excludes items that are in progress
470
414
  /** @type {Set<string>} */
471
- const ready_ids = new Set(r.map((it) => it.id));
472
- o = o.filter((it) => !ready_ids.has(it.id));
473
- }
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));
474
417
 
475
- // Remove items from Open that are blocked (UI-121)
476
- if (o.length > 0 && b.length > 0) {
477
- /** @type {Set<string>} */
478
- const blocked_ids = new Set(b.map((it) => it.id));
479
- o = o.filter((it) => !blocked_ids.has(it.id));
418
+ list_ready = ready;
419
+ list_blocked = blocked;
420
+ list_in_progress = in_progress;
421
+ list_closed_raw = closed;
480
422
  }
423
+ applyClosedFilter();
424
+ doRender();
425
+ } catch {
426
+ list_ready = [];
427
+ list_blocked = [];
428
+ list_in_progress = [];
429
+ list_closed = [];
430
+ doRender();
431
+ }
432
+ }
481
433
 
482
- // Remove items from Ready that are already In Progress by id
483
- if (r.length > 0 && p.length > 0) {
484
- /** @type {Set<string>} */
485
- const in_progress_ids = new Set(p.map((it) => it.id));
486
- r = r.filter((it) => !in_progress_ids.has(it.id));
434
+ // Live updates: recompose on issue store envelopes
435
+ if (selectors) {
436
+ selectors.subscribe(() => {
437
+ try {
438
+ refreshFromStores();
439
+ } catch {
440
+ // ignore
487
441
  }
442
+ });
443
+ }
488
444
 
489
- // UI-119: Open sorted like Ready (priority asc, then updated desc)
490
- sortReady(o);
491
- sortReady(r);
492
- sortReady(b);
493
- sortByUpdatedDesc(p);
494
- // Closed handled separately to use closed_at and filtering
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
+ : [];
495
508
 
496
- list_open = o;
497
- list_ready = r;
498
- list_blocked = b;
499
- list_in_progress = p;
500
- list_closed_raw = c;
501
- applyClosedFilter();
502
- doRender();
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
+ }
503
528
  },
504
529
  clear() {
505
530
  mount_element.replaceChildren();
506
- list_open = [];
507
531
  list_ready = [];
508
532
  list_blocked = [];
509
533
  list_in_progress = [];