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