beads-ui 0.3.0 → 0.4.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 (61) hide show
  1. package/CHANGES.md +26 -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 -16
  7. package/server/app.js +39 -35
  8. package/server/bd.js +6 -2
  9. package/server/cli/commands.js +12 -8
  10. package/server/cli/daemon.js +20 -5
  11. package/server/cli/index.js +19 -31
  12. package/server/cli/open.js +3 -0
  13. package/server/cli/usage.js +4 -2
  14. package/server/config.js +3 -2
  15. package/server/db.js +9 -6
  16. package/server/index.js +10 -4
  17. package/server/list-adapters.js +9 -3
  18. package/server/logging.js +23 -0
  19. package/server/subscriptions.js +12 -0
  20. package/server/validators.js +2 -0
  21. package/server/watcher.js +10 -5
  22. package/server/ws.js +31 -10
  23. package/app/data/list-selectors.js +0 -98
  24. package/app/data/providers.js +0 -76
  25. package/app/data/sort.js +0 -45
  26. package/app/data/subscription-issue-store.js +0 -161
  27. package/app/data/subscription-issue-stores.js +0 -102
  28. package/app/data/subscriptions-store.js +0 -219
  29. package/app/main.js +0 -702
  30. package/app/protocol.js +0 -196
  31. package/app/protocol.md +0 -66
  32. package/app/router.js +0 -114
  33. package/app/state.js +0 -103
  34. package/app/utils/issue-id-renderer.js +0 -71
  35. package/app/utils/issue-id.js +0 -10
  36. package/app/utils/issue-type.js +0 -27
  37. package/app/utils/issue-url.js +0 -9
  38. package/app/utils/markdown.js +0 -22
  39. package/app/utils/priority-badge.js +0 -47
  40. package/app/utils/priority.js +0 -1
  41. package/app/utils/status-badge.js +0 -32
  42. package/app/utils/status.js +0 -23
  43. package/app/utils/toast.js +0 -34
  44. package/app/utils/type-badge.js +0 -33
  45. package/app/views/board.js +0 -535
  46. package/app/views/detail.js +0 -1249
  47. package/app/views/epics.js +0 -280
  48. package/app/views/issue-dialog.js +0 -163
  49. package/app/views/issue-row.js +0 -190
  50. package/app/views/list.js +0 -464
  51. package/app/views/nav.js +0 -67
  52. package/app/views/new-issue-dialog.js +0 -345
  53. package/app/ws.js +0 -279
  54. package/docs/adr/001-push-only-lists.md +0 -134
  55. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  56. package/docs/architecture.md +0 -194
  57. package/docs/data-exchange-subscription-plan.md +0 -198
  58. package/docs/db-watching.md +0 -30
  59. package/docs/migration-v2.md +0 -54
  60. package/docs/protocol/issues-push-v2.md +0 -179
  61. package/docs/subscription-issue-store.md +0 -112
@@ -1,32 +0,0 @@
1
- /**
2
- * Create a colored badge for a status value.
3
- * @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
4
- * @returns {HTMLSpanElement}
5
- */
6
- export function createStatusBadge(status) {
7
- const el = document.createElement('span');
8
- el.className = 'status-badge';
9
- const s = String(status || 'open');
10
- el.classList.add(`is-${s}`);
11
- el.setAttribute('role', 'img');
12
- el.setAttribute('title', labelForStatus(s));
13
- el.setAttribute('aria-label', `Status: ${labelForStatus(s)}`);
14
- el.textContent = labelForStatus(s);
15
- return el;
16
- }
17
-
18
- /**
19
- * @param {string} s
20
- */
21
- function labelForStatus(s) {
22
- switch (s) {
23
- case 'open':
24
- return 'Open';
25
- case 'in_progress':
26
- return 'In progress';
27
- case 'closed':
28
- return 'Closed';
29
- default:
30
- return 'Unknown';
31
- }
32
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * Known status values in canonical order.
3
- * @type {Array<'open'|'in_progress'|'closed'>}
4
- */
5
- export const STATUSES = ['open', 'in_progress', 'closed'];
6
-
7
- /**
8
- * Map canonical status to display label.
9
- * @param {string | null | undefined} status
10
- * @returns {string}
11
- */
12
- export function statusLabel(status) {
13
- switch ((status || '').toString()) {
14
- case 'open':
15
- return 'Open';
16
- case 'in_progress':
17
- return 'In progress';
18
- case 'closed':
19
- return 'Closed';
20
- default:
21
- return (status || '').toString() || 'Open';
22
- }
23
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Show a transient global toast message anchored to the viewport.
3
- * @param {string} text - Message text.
4
- * @param {'info'|'success'|'error'} [variant] - Visual variant.
5
- * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
6
- */
7
- export function showToast(text, variant = 'info', duration_ms = 2800) {
8
- const el = document.createElement('div');
9
- el.className = 'toast';
10
- el.textContent = text;
11
- el.style.position = 'fixed';
12
- el.style.right = '12px';
13
- el.style.bottom = '12px';
14
- el.style.zIndex = '1000';
15
- el.style.color = '#fff';
16
- el.style.padding = '8px 10px';
17
- el.style.borderRadius = '4px';
18
- el.style.fontSize = '12px';
19
- if (variant === 'success') {
20
- el.style.background = '#156d36';
21
- } else if (variant === 'error') {
22
- el.style.background = '#9f2011';
23
- } else {
24
- el.style.background = 'rgba(0,0,0,0.85)';
25
- }
26
- (document.body || document.documentElement).appendChild(el);
27
- setTimeout(() => {
28
- try {
29
- el.remove();
30
- } catch {
31
- /* ignore */
32
- }
33
- }, duration_ms);
34
- }
@@ -1,33 +0,0 @@
1
- /**
2
- * Create a compact, colored badge for an issue type.
3
- * @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
4
- * @returns {HTMLSpanElement}
5
- */
6
- export function createTypeBadge(issue_type) {
7
- const el = document.createElement('span');
8
- el.className = 'type-badge';
9
-
10
- const t = (issue_type || '').toString().toLowerCase();
11
- const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
12
- const kind = KNOWN.has(t) ? t : 'neutral';
13
- el.classList.add(`type-badge--${kind}`);
14
- el.setAttribute('role', 'img');
15
- const label = KNOWN.has(t)
16
- ? t === 'bug'
17
- ? 'Bug'
18
- : t === 'feature'
19
- ? 'Feature'
20
- : t === 'task'
21
- ? 'Task'
22
- : t === 'epic'
23
- ? 'Epic'
24
- : 'Chore'
25
- : '—';
26
- el.setAttribute(
27
- 'aria-label',
28
- KNOWN.has(t) ? `Issue type: ${label}` : 'Issue type: unknown'
29
- );
30
- el.setAttribute('title', KNOWN.has(t) ? `Type: ${label}` : 'Type: unknown');
31
- el.textContent = label;
32
- return el;
33
- }
@@ -1,535 +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
- * @param {HTMLElement} mount_element
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]
34
- * @returns {{ load: () => Promise<void>, clear: () => void }}
35
- */
36
- export function createBoardView(
37
- mount_element,
38
- _data,
39
- gotoIssue,
40
- store,
41
- subscriptions = undefined,
42
- issueStores = undefined
43
- ) {
44
- /** @type {IssueLite[]} */
45
- let list_ready = [];
46
- /** @type {IssueLite[]} */
47
- let list_blocked = [];
48
- /** @type {IssueLite[]} */
49
- let list_in_progress = [];
50
- /** @type {IssueLite[]} */
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
- }
76
-
77
- function template() {
78
- return html`
79
- <div class="panel__body board-root">
80
- ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
81
- ${columnTemplate('Ready', 'ready-col', list_ready)}
82
- ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
83
- ${columnTemplate('Closed', 'closed-col', list_closed)}
84
- </div>
85
- `;
86
- }
87
-
88
- /**
89
- * @param {string} title
90
- * @param {string} id
91
- * @param {IssueLite[]} items
92
- */
93
- function columnTemplate(title, id, items) {
94
- return html`
95
- <section class="board-column" id=${id}>
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
- : ''}
126
- </header>
127
- <div
128
- class="board-column__body"
129
- role="list"
130
- aria-labelledby=${id + '-header'}
131
- >
132
- ${items.map((it) => cardTemplate(it))}
133
- </div>
134
- </section>
135
- `;
136
- }
137
-
138
- /**
139
- * @param {IssueLite} it
140
- */
141
- function cardTemplate(it) {
142
- return html`
143
- <article
144
- class="board-card"
145
- data-issue-id=${it.id}
146
- role="listitem"
147
- tabindex="-1"
148
- @click=${() => gotoIssue(it.id)}
149
- >
150
- <div class="board-card__title text-truncate">
151
- ${it.title || '(no title)'}
152
- </div>
153
- <div class="board-card__meta">
154
- ${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
155
- ${createIssueIdRenderer(it.id, { class_name: 'mono' })}
156
- </div>
157
- </article>
158
- `;
159
- }
160
-
161
- function doRender() {
162
- render(template(), mount_element);
163
- postRenderEnhance();
164
- }
165
-
166
- /**
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
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
-
313
- /**
314
- * @param {HTMLElement} from
315
- * @param {HTMLElement} to
316
- */
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
- }
325
- }
326
-
327
- // Sort helpers centralized in app/data/sort.js
328
-
329
- /**
330
- * Recompute closed list from raw using the current filter and sort.
331
- */
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;
361
- });
362
- items.sort(cmpClosedDesc);
363
- list_closed = items;
364
- }
365
-
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
- }
380
- }
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;
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(() => {
435
- try {
436
- refreshFromStores();
437
- } catch {
438
- // ignore
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.
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
- : [];
506
-
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
- }
526
- },
527
- clear() {
528
- mount_element.replaceChildren();
529
- list_ready = [];
530
- list_blocked = [];
531
- list_in_progress = [];
532
- list_closed = [];
533
- }
534
- };
535
- }