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.
- package/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
package/app/views/board.js
CHANGED
|
@@ -1,40 +1,83 @@
|
|
|
1
1
|
import { html, render } from 'lit-html';
|
|
2
|
-
import {
|
|
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 {{
|
|
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
|
|
12
|
-
*
|
|
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
|
-
* -
|
|
16
|
-
* -
|
|
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 {
|
|
21
|
-
* @param {(id: string) => void}
|
|
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(
|
|
25
|
-
|
|
26
|
-
|
|
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('
|
|
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
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
78
|
-
${
|
|
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
|
-
*
|
|
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 {
|
|
314
|
+
* @param {HTMLElement} from
|
|
315
|
+
* @param {HTMLElement} to
|
|
94
316
|
*/
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
*
|
|
330
|
+
* Recompute closed list from raw using the current filter and sort.
|
|
110
331
|
*/
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
436
|
+
refreshFromStores();
|
|
141
437
|
} catch {
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
}
|