beads-ui 0.2.0 → 0.3.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.
Files changed (48) hide show
  1. package/CHANGES.md +5 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +5 -138
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/main.js +342 -66
  10. package/app/protocol.js +10 -14
  11. package/app/protocol.md +18 -15
  12. package/app/styles.css +222 -197
  13. package/app/utils/markdown.js +15 -194
  14. package/app/utils/priority-badge.js +0 -2
  15. package/app/utils/status-badge.js +0 -1
  16. package/app/utils/toast.js +0 -1
  17. package/app/utils/type-badge.js +0 -3
  18. package/app/views/board.js +166 -144
  19. package/app/views/detail.js +76 -66
  20. package/app/views/epics.js +126 -74
  21. package/app/views/issue-dialog.js +8 -15
  22. package/app/views/issue-row.js +1 -3
  23. package/app/views/list.js +101 -104
  24. package/app/views/new-issue-dialog.js +27 -34
  25. package/app/ws.js +6 -9
  26. package/bin/bdui.js +1 -1
  27. package/docs/adr/001-push-only-lists.md +134 -0
  28. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  29. package/docs/architecture.md +34 -84
  30. package/docs/data-exchange-subscription-plan.md +198 -0
  31. package/docs/db-watching.md +2 -1
  32. package/docs/migration-v2.md +54 -0
  33. package/docs/protocol/issues-push-v2.md +179 -0
  34. package/docs/subscription-issue-store.md +112 -0
  35. package/package.json +4 -2
  36. package/server/bd.js +0 -2
  37. package/server/cli/commands.js +1 -2
  38. package/server/cli/daemon.js +12 -5
  39. package/server/cli/index.js +0 -2
  40. package/server/cli/usage.js +1 -1
  41. package/server/config.js +12 -6
  42. package/server/db.js +0 -1
  43. package/server/index.js +9 -5
  44. package/server/list-adapters.js +218 -0
  45. package/server/subscriptions.js +277 -0
  46. package/server/validators.js +111 -0
  47. package/server/watcher.js +5 -8
  48. package/server/ws.js +449 -230
@@ -1,201 +1,22 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { html } from 'lit-html';
3
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
4
+ import { marked } from 'marked';
5
+
1
6
  /**
2
- * Minimal, safe Markdown renderer that builds a DOM fragment without innerHTML.
3
- * Supports headings, paragraphs, lists, code blocks, inline code, and links.
4
- * This is intentionally conservative to avoid XSS.
7
+ * Render Markdown safely as HTML using marked and DOMPurify.
8
+ * Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
9
+ * embedded directly in templates.
5
10
  * @function renderMarkdown
6
11
  * @param {string} src Markdown source text
7
- * @returns {DocumentFragment}
12
+ * @returns {import('lit-html').TemplateResult}
8
13
  */
9
14
  export function renderMarkdown(src) {
10
- /** @type {DocumentFragment} */
11
- const frag = document.createDocumentFragment();
12
-
13
- /** @type {string[]} */
14
- const lines = (src || '').replace(/\r\n?/g, '\n').split('\n');
15
-
16
- /** @type {boolean} */
17
- let in_code = false;
18
- /** @type {string[]} */
19
- let code_acc = [];
20
-
21
- /** @type {HTMLElement|null} */
22
- let list_el = null;
23
15
  /** @type {string} */
24
- let list_type = '';
25
-
26
- /**
27
- * Flush current paragraph buffer to <p> if any.
28
- * @param {string[]} buf
29
- */
30
- function flushParagraph(buf) {
31
- if (buf.length === 0) {
32
- return;
33
- }
34
- const p = document.createElement('p');
35
- appendInline(p, buf.join(' '));
36
- frag.appendChild(p);
37
- buf.length = 0;
38
- }
39
-
40
- /** @type {string[]} */
41
- const para = [];
42
-
43
- for (let i = 0; i < lines.length; i++) {
44
- const raw = lines[i];
45
- const line = raw;
46
-
47
- // fenced code blocks
48
- if (/^```/.test(line)) {
49
- if (!in_code) {
50
- in_code = true;
51
- code_acc = [];
52
- } else {
53
- // flush code block
54
- const pre = document.createElement('pre');
55
- const code = document.createElement('code');
56
- code.textContent = code_acc.join('\n');
57
- pre.appendChild(code);
58
- frag.appendChild(pre);
59
- in_code = false;
60
- code_acc = [];
61
- }
62
- continue;
63
- }
64
- if (in_code) {
65
- code_acc.push(raw);
66
- continue;
67
- }
68
-
69
- // blank line -> paragraph / list break
70
- if (/^\s*$/.test(line)) {
71
- flushParagraph(para);
72
- if (list_el !== null) {
73
- frag.appendChild(list_el);
74
- list_el = null;
75
- list_type = '';
76
- }
77
- continue;
78
- }
79
-
80
- // heading
81
- const hx = /^(#{1,6})\s+(.*)$/.exec(line);
82
- if (hx) {
83
- flushParagraph(para);
84
- if (list_el !== null) {
85
- frag.appendChild(list_el);
86
- list_el = null;
87
- list_type = '';
88
- }
89
- const level = Math.min(6, hx[1].length);
90
- const h = /** @type {HTMLHeadingElement} */ (
91
- document.createElement('h' + String(level))
92
- );
93
- appendInline(h, hx[2]);
94
- frag.appendChild(h);
95
- continue;
96
- }
97
-
98
- // list item
99
- let m = /^\s*[-*]\s+(.*)$/.exec(line);
100
- if (m) {
101
- if (list_el === null || list_type !== 'ul') {
102
- flushParagraph(para);
103
- if (list_el !== null) {
104
- frag.appendChild(list_el);
105
- }
106
- list_el = document.createElement('ul');
107
- list_type = 'ul';
108
- }
109
- const li = document.createElement('li');
110
- appendInline(li, m[1]);
111
- list_el.appendChild(li);
112
- continue;
113
- }
114
- m = /^\s*(\d+)\.\s+(.*)$/.exec(line);
115
- if (m) {
116
- if (list_el === null || list_type !== 'ol') {
117
- flushParagraph(para);
118
- if (list_el !== null) {
119
- frag.appendChild(list_el);
120
- }
121
- list_el = document.createElement('ol');
122
- list_type = 'ol';
123
- }
124
- const li = document.createElement('li');
125
- appendInline(li, m[2]);
126
- list_el.appendChild(li);
127
- continue;
128
- }
129
-
130
- // otherwise: paragraph text; accumulate to join with spaces
131
- para.push(line.trim());
132
- }
133
-
134
- // flush leftovers
135
- if (in_code) {
136
- const pre = document.createElement('pre');
137
- const code = document.createElement('code');
138
- code.textContent = code_acc.join('\n');
139
- pre.appendChild(code);
140
- frag.appendChild(pre);
141
- }
142
- flushParagraph(para);
143
- if (list_el !== null) {
144
- frag.appendChild(list_el);
145
- }
146
-
147
- return frag;
148
-
149
- /**
150
- * Append inline content parsing backticks and links safely.
151
- * - Inline code: `text`
152
- * - Links: [text](url) where url starts with http, https, or mailto
153
- * @param {HTMLElement} el
154
- * @param {string} text
155
- */
156
- function appendInline(el, text) {
157
- /** @type {RegExp} */
158
- const re = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))/g;
159
- /** @type {number} */
160
- let last = 0;
161
- /** @type {any} */
162
- let match = re.exec(text);
163
- while (match) {
164
- const idx = match.index;
165
- if (idx > last) {
166
- el.appendChild(document.createTextNode(text.slice(last, idx)));
167
- }
168
- const token = match[0];
169
- if (token[0] === '\u0060') {
170
- const code = document.createElement('code');
171
- code.textContent = token.slice(1, -1);
172
- el.appendChild(code);
173
- } else if (token[0] === '[') {
174
- // parse [text](url)
175
- const m2 = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
176
- if (m2) {
177
- const a = document.createElement('a');
178
- const href = m2[2].trim();
179
- const allowed = /^(https?:|mailto:)/i.test(href);
180
- if (allowed) {
181
- a.setAttribute('href', href);
182
- } else {
183
- // if not allowed, render as text to avoid XSS vectors
184
- a.remove();
185
- el.appendChild(document.createTextNode(m2[1] + ' (' + href + ')'));
186
- last = re.lastIndex;
187
- match = re.exec(text);
188
- continue;
189
- }
190
- a.appendChild(document.createTextNode(m2[1]));
191
- el.appendChild(a);
192
- }
193
- }
194
- last = re.lastIndex;
195
- match = re.exec(text);
196
- }
197
- if (last < text.length) {
198
- el.appendChild(document.createTextNode(text.slice(last)));
199
- }
200
- }
16
+ const markdown = String(src || '');
17
+ /** @type {string} */
18
+ const parsed = /** @type {string} */ (marked.parse(markdown));
19
+ /** @type {string} */
20
+ const html_string = DOMPurify.sanitize(parsed);
21
+ return html`${unsafeHTML(html_string)}`;
201
22
  }
@@ -6,9 +6,7 @@ import { priority_levels } from './priority.js';
6
6
  * @returns {HTMLSpanElement}
7
7
  */
8
8
  export function createPriorityBadge(priority) {
9
- /** @type {number} */
10
9
  const p = typeof priority === 'number' ? priority : 2;
11
- /** @type {HTMLSpanElement} */
12
10
  const el = document.createElement('span');
13
11
  el.className = 'priority-badge';
14
12
  el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
@@ -4,7 +4,6 @@
4
4
  * @returns {HTMLSpanElement}
5
5
  */
6
6
  export function createStatusBadge(status) {
7
- /** @type {HTMLSpanElement} */
8
7
  const el = document.createElement('span');
9
8
  el.className = 'status-badge';
10
9
  const s = String(status || 'open');
@@ -5,7 +5,6 @@
5
5
  * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
6
6
  */
7
7
  export function showToast(text, variant = 'info', duration_ms = 2800) {
8
- /** @type {HTMLDivElement} */
9
8
  const el = document.createElement('div');
10
9
  el.className = 'toast';
11
10
  el.textContent = text;
@@ -4,13 +4,10 @@
4
4
  * @returns {HTMLSpanElement}
5
5
  */
6
6
  export function createTypeBadge(issue_type) {
7
- /** @type {HTMLSpanElement} */
8
7
  const el = document.createElement('span');
9
8
  el.className = 'type-badge';
10
9
 
11
- /** @type {string} */
12
10
  const t = (issue_type || '').toString().toLowerCase();
13
- /** @type {Set<string>} */
14
11
  const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
15
12
  const kind = KNOWN.has(t) ? t : 'neutral';
16
13
  el.classList.add(`type-badge--${kind}`);
@@ -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,35 @@ 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
27
28
  * @param {HTMLElement} mount_element
28
- * @param {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getBlocked?: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: (limit?: number) => Promise<any[]> }} data
29
+ * @param {unknown} _data - Unused (legacy param retained for call-compat)
29
30
  * @param {(id: string) => void} gotoIssue - Navigate to issue detail.
30
31
  * @param {{ getState: () => any, setState: (patch: any) => void, subscribe?: (fn: (s:any)=>void)=>()=>void }} [store]
32
+ * @param {{ selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
33
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
31
34
  * @returns {{ load: () => Promise<void>, clear: () => void }}
32
35
  */
33
- export function createBoardView(mount_element, data, gotoIssue, store) {
34
- /** @type {IssueLite[]} */
35
- let list_open = [];
36
+ export function createBoardView(
37
+ mount_element,
38
+ _data,
39
+ gotoIssue,
40
+ store,
41
+ subscriptions = undefined,
42
+ issueStores = undefined
43
+ ) {
36
44
  /** @type {IssueLite[]} */
37
45
  let list_ready = [];
38
46
  /** @type {IssueLite[]} */
@@ -43,6 +51,8 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
43
51
  let list_closed = [];
44
52
  /** @type {IssueLite[]} */
45
53
  let list_closed_raw = [];
54
+ // Centralized selection helpers
55
+ const selectors = issueStores ? createListSelectors(issueStores) : null;
46
56
 
47
57
  /**
48
58
  * Closed column filter mode.
@@ -67,10 +77,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
67
77
  function template() {
68
78
  return html`
69
79
  <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>
80
+ ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
74
81
  ${columnTemplate('Ready', 'ready-col', list_ready)}
75
82
  ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
76
83
  ${columnTemplate('Closed', 'closed-col', list_closed)}
@@ -144,8 +151,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
144
151
  ${it.title || '(no title)'}
145
152
  </div>
146
153
  <div class="board-card__meta">
147
- ${createTypeBadge(/** @type {any} */ (it).issue_type)}
148
- ${createPriorityBadge(/** @type {any} */ (it).priority)}
154
+ ${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
149
155
  ${createIssueIdRenderer(it.id, { class_name: 'mono' })}
150
156
  </div>
151
157
  </article>
@@ -171,8 +177,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
171
177
  mount_element.querySelectorAll('.board-column')
172
178
  );
173
179
  for (const col of columns) {
174
- /** @type {HTMLElement|null} */
175
- const body = /** @type {any} */ (
180
+ const body = /** @type {HTMLElement|null} */ (
176
181
  col.querySelector('.board-column__body')
177
182
  );
178
183
  if (!body) {
@@ -208,8 +213,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
208
213
 
209
214
  // Delegate keyboard handling from mount_element
210
215
  mount_element.addEventListener('keydown', (ev) => {
211
- /** @type {HTMLElement} */
212
- const target = /** @type {any} */ (ev.target);
216
+ const target = ev.target;
213
217
  if (!target || !(target instanceof HTMLElement)) {
214
218
  return;
215
219
  }
@@ -219,7 +223,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
219
223
  tag === 'input' ||
220
224
  tag === 'textarea' ||
221
225
  tag === 'select' ||
222
- /** @type {any} */ (target).isContentEditable === true
226
+ target.isContentEditable === true
223
227
  ) {
224
228
  return;
225
229
  }
@@ -230,9 +234,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
230
234
  const key = String(ev.key || '');
231
235
  if (key === 'Enter' || key === ' ') {
232
236
  ev.preventDefault();
233
- const id = /** @type {HTMLElement} */ (card).getAttribute(
234
- 'data-issue-id'
235
- );
237
+ const id = card.getAttribute('data-issue-id');
236
238
  if (id) {
237
239
  gotoIssue(id);
238
240
  }
@@ -252,9 +254,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
252
254
  if (!col) {
253
255
  return;
254
256
  }
255
- const body = /** @type {HTMLElement|null} */ (
256
- col.querySelector('.board-column__body')
257
- );
257
+ const body = col.querySelector('.board-column__body');
258
258
  if (!body) {
259
259
  return;
260
260
  }
@@ -324,47 +324,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
324
324
  }
325
325
  }
326
326
 
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
- }
327
+ // Sort helpers centralized in app/data/sort.js
368
328
 
369
329
  /**
370
330
  * Recompute closed list from raw using the current filter and sort.
@@ -373,7 +333,6 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
373
333
  /** @type {IssueLite[]} */
374
334
  let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
375
335
  const now = new Date();
376
- /** @type {number} */
377
336
  let since_ts = 0;
378
337
  if (closed_filter_mode === 'today') {
379
338
  const start = new Date(
@@ -392,14 +351,15 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
392
351
  since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
393
352
  }
394
353
  items = items.filter((it) => {
395
- const s = it.closed_at || '';
396
- if (!s || isNaN(Date.parse(s))) {
354
+ const s = Number.isFinite(it.closed_at)
355
+ ? /** @type {number} */ (it.closed_at)
356
+ : NaN;
357
+ if (!Number.isFinite(s)) {
397
358
  return false;
398
359
  }
399
- const t = Date.parse(s);
400
- return t >= since_ts;
360
+ return s >= since_ts;
401
361
  });
402
- sortByClosedDesc(items);
362
+ items.sort(cmpClosedDesc);
403
363
  list_closed = items;
404
364
  }
405
365
 
@@ -425,85 +385,147 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
425
385
  }
426
386
  }
427
387
 
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
- }
388
+ /**
389
+ * Compose lists from subscriptions + issues store and render.
390
+ */
391
+ function refreshFromStores() {
392
+ try {
393
+ if (selectors) {
394
+ const in_progress = selectors.selectBoardColumn(
395
+ 'tab:board:in-progress',
396
+ 'in_progress'
397
+ );
398
+ const blocked = selectors.selectBoardColumn(
399
+ 'tab:board:blocked',
400
+ 'blocked'
401
+ );
402
+ const ready_raw = selectors.selectBoardColumn(
403
+ 'tab:board:ready',
404
+ 'ready'
405
+ );
406
+ const closed = selectors.selectBoardColumn(
407
+ 'tab:board:closed',
408
+ 'closed'
409
+ );
467
410
 
468
- // Remove items from Open that are already in Ready by id
469
- if (o.length > 0 && r.length > 0) {
411
+ // Ready excludes items that are in progress
470
412
  /** @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
- }
413
+ const in_prog_ids = new Set(in_progress.map((i) => i.id));
414
+ const ready = ready_raw.filter((i) => !in_prog_ids.has(i.id));
474
415
 
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));
416
+ list_ready = ready;
417
+ list_blocked = blocked;
418
+ list_in_progress = in_progress;
419
+ list_closed_raw = closed;
480
420
  }
421
+ applyClosedFilter();
422
+ doRender();
423
+ } catch {
424
+ list_ready = [];
425
+ list_blocked = [];
426
+ list_in_progress = [];
427
+ list_closed = [];
428
+ doRender();
429
+ }
430
+ }
481
431
 
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));
432
+ // Live updates: recompose on issue store envelopes
433
+ if (selectors) {
434
+ selectors.subscribe(() => {
435
+ try {
436
+ refreshFromStores();
437
+ } catch {
438
+ // ignore
487
439
  }
440
+ });
441
+ }
488
442
 
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
443
+ return {
444
+ async load() {
445
+ // Compose lists from subscriptions + issues store
446
+ refreshFromStores();
447
+ // If nothing is present yet (e.g., immediately after switching back
448
+ // to the Board and before list-delta arrives), fetch via data layer as
449
+ // a fallback so the board is not empty on initial display.
450
+ try {
451
+ const has_subs = Boolean(subscriptions && subscriptions.selectors);
452
+ /**
453
+ * @param {string} id
454
+ */
455
+ const cnt = (id) => {
456
+ if (!has_subs || !subscriptions) {
457
+ return 0;
458
+ }
459
+ const sel = subscriptions.selectors;
460
+ if (typeof sel.count === 'function') {
461
+ return Number(sel.count(id) || 0);
462
+ }
463
+ try {
464
+ const arr = sel.getIds(id);
465
+ return Array.isArray(arr) ? arr.length : 0;
466
+ } catch {
467
+ return 0;
468
+ }
469
+ };
470
+ const total_items =
471
+ cnt('tab:board:ready') +
472
+ cnt('tab:board:blocked') +
473
+ cnt('tab:board:in-progress') +
474
+ cnt('tab:board:closed');
475
+ const data = /** @type {any} */ (_data);
476
+ const can_fetch =
477
+ data &&
478
+ typeof data.getReady === 'function' &&
479
+ typeof data.getBlocked === 'function' &&
480
+ typeof data.getInProgress === 'function' &&
481
+ typeof data.getClosed === 'function';
482
+ if (total_items === 0 && can_fetch) {
483
+ /** @type {[IssueLite[], IssueLite[], IssueLite[], IssueLite[]]} */
484
+ const [ready_raw, blocked_raw, in_prog_raw, closed_raw] =
485
+ await Promise.all([
486
+ data.getReady().catch(() => []),
487
+ data.getBlocked().catch(() => []),
488
+ data.getInProgress().catch(() => []),
489
+ data.getClosed().catch(() => [])
490
+ ]);
491
+ // Normalize and map unknowns to IssueLite shape
492
+ /** @type {IssueLite[]} */
493
+ let ready = Array.isArray(ready_raw) ? ready_raw.map((it) => it) : [];
494
+ /** @type {IssueLite[]} */
495
+ const blocked = Array.isArray(blocked_raw)
496
+ ? blocked_raw.map((it) => it)
497
+ : [];
498
+ /** @type {IssueLite[]} */
499
+ const in_prog = Array.isArray(in_prog_raw)
500
+ ? in_prog_raw.map((it) => it)
501
+ : [];
502
+ /** @type {IssueLite[]} */
503
+ const closed = Array.isArray(closed_raw)
504
+ ? closed_raw.map((it) => it)
505
+ : [];
495
506
 
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();
507
+ // Remove items from Ready that are already In Progress
508
+ /** @type {Set<string>} */
509
+ const in_progress_ids = new Set(in_prog.map((i) => i.id));
510
+ ready = ready.filter((i) => !in_progress_ids.has(i.id));
511
+
512
+ // Sort as per column rules
513
+ ready.sort(cmpPriorityThenCreated);
514
+ blocked.sort(cmpPriorityThenCreated);
515
+ in_prog.sort(cmpPriorityThenCreated);
516
+ list_ready = ready;
517
+ list_blocked = blocked;
518
+ list_in_progress = in_prog;
519
+ list_closed_raw = closed;
520
+ applyClosedFilter();
521
+ doRender();
522
+ }
523
+ } catch {
524
+ // ignore fallback errors
525
+ }
503
526
  },
504
527
  clear() {
505
528
  mount_element.replaceChildren();
506
- list_open = [];
507
529
  list_ready = [];
508
530
  list_blocked = [];
509
531
  list_in_progress = [];