beads-ui 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
package/app/views/list.js CHANGED
@@ -1,5 +1,6 @@
1
- /* global NodeListOf */
2
1
  import { html, render } from 'lit-html';
2
+ import { createListSelectors } from '../data/list-selectors.js';
3
+ import { cmpClosedDesc } from '../data/sort.js';
3
4
  import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
4
5
  import { issueHashFor } from '../utils/issue-url.js';
5
6
  // issueDisplayId not used directly in this file; rendered in shared row
@@ -9,18 +10,41 @@ import { createIssueRowRenderer } from './issue-row.js';
9
10
  // List view implementation; requires a transport send function.
10
11
 
11
12
  /**
12
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
13
+ * @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
13
14
  */
14
15
 
15
16
  /**
16
17
  * Create the Issues List view.
18
+ *
17
19
  * @param {HTMLElement} mount_element - Element to render into.
18
20
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
19
21
  * @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
20
22
  * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
23
+ * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
24
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
21
25
  * @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
22
26
  */
23
- export function createListView(mount_element, sendFn, navigate_fn, store) {
27
+ /**
28
+ * Create the Issues List view.
29
+ *
30
+ * @param {HTMLElement} mount_element
31
+ * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
32
+ * @param {(hash: string) => void} [navigateFn]
33
+ * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
34
+ * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
35
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
36
+ * @returns {{ load: () => Promise<void>, destroy: () => void }}
37
+ */
38
+ export function createListView(
39
+ mount_element,
40
+ sendFn,
41
+ navigateFn,
42
+ store,
43
+ _subscriptions = undefined,
44
+ issue_stores = undefined
45
+ ) {
46
+ // Touch unused param to satisfy lint rules without impacting behavior
47
+ /** @type {any} */ (void _subscriptions);
24
48
  /** @type {string} */
25
49
  let status_filter = 'all';
26
50
  /** @type {string} */
@@ -36,7 +60,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
36
60
  // Shared row renderer (used in template below)
37
61
  const row_renderer = createIssueRowRenderer({
38
62
  navigate: (id) => {
39
- const nav = navigate_fn || ((h) => (window.location.hash = h));
63
+ const nav = navigateFn || ((h) => (window.location.hash = h));
40
64
  /** @type {'issues'|'epics'|'board'} */
41
65
  const view = store ? store.getState().view : 'issues';
42
66
  nav(issueHashFor(view, id));
@@ -54,12 +78,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
54
78
  * @param {Event} ev
55
79
  */
56
80
  const onStatusChange = async (ev) => {
57
- /** @type {HTMLSelectElement} */
58
- const sel = /** @type {any} */ (ev.currentTarget);
81
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
59
82
  status_filter = sel.value;
60
83
  if (store) {
61
84
  store.setState({
62
- filters: { status: /** @type {any} */ (status_filter) }
85
+ filters: { status: status_filter }
63
86
  });
64
87
  }
65
88
  // Always reload on status changes
@@ -73,8 +96,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
73
96
  * @param {Event} ev
74
97
  */
75
98
  const onSearchInput = (ev) => {
76
- /** @type {HTMLInputElement} */
77
- const input = /** @type {any} */ (ev.currentTarget);
99
+ const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
78
100
  search_text = input.value;
79
101
  if (store) {
80
102
  store.setState({ filters: { search: search_text } });
@@ -84,11 +106,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
84
106
 
85
107
  /**
86
108
  * Event: type select change.
109
+ *
87
110
  * @param {Event} ev
88
111
  */
89
112
  const onTypeChange = (ev) => {
90
- /** @type {HTMLSelectElement} */
91
- const sel = /** @type {any} */ (ev.currentTarget);
113
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
92
114
  type_filter = sel.value || '';
93
115
  if (store) {
94
116
  store.setState({ filters: { type: type_filter } });
@@ -106,12 +128,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
106
128
  }
107
129
  }
108
130
  // Initial values are reflected via bound `.value` in the template
131
+ // Compose helpers: centralize membership + entity selection + sorting
132
+ const selectors = issue_stores ? createListSelectors(issue_stores) : null;
109
133
 
110
134
  /**
111
135
  * Build lit-html template for the list view.
112
136
  */
113
137
  function template() {
114
- /** @type {Issue[]} */
115
138
  let filtered = issues_cache;
116
139
  if (status_filter !== 'all' && status_filter !== 'ready') {
117
140
  filtered = filtered.filter(
@@ -131,6 +154,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
131
154
  (it) => String(it.issue_type || '') === String(type_filter)
132
155
  );
133
156
  }
157
+ // Sorting: closed list is a special case → sort by closed_at desc only
158
+ if (status_filter === 'closed') {
159
+ filtered = filtered.slice().sort(cmpClosedDesc);
160
+ }
134
161
 
135
162
  return html`
136
163
  <div class="panel__header">
@@ -212,40 +239,56 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
212
239
  // no separate ready checkbox when using select option
213
240
 
214
241
  /**
215
- * Load issues from backend and re-render.
242
+ * Update minimal fields inline via ws mutations and refresh that row's data.
243
+ *
244
+ * @param {string} id
245
+ * @param {{ [k: string]: any }} patch
246
+ */
247
+ async function updateInline(id, patch) {
248
+ try {
249
+ // Dispatch specific mutations based on provided keys
250
+ if (typeof patch.title === 'string') {
251
+ await sendFn('edit-text', { id, field: 'title', value: patch.title });
252
+ }
253
+ if (typeof patch.assignee === 'string') {
254
+ await sendFn('update-assignee', { id, assignee: patch.assignee });
255
+ }
256
+ if (typeof patch.status === 'string') {
257
+ await sendFn('update-status', { id, status: patch.status });
258
+ }
259
+ if (typeof patch.priority === 'number') {
260
+ await sendFn('update-priority', { id, priority: patch.priority });
261
+ }
262
+ } catch {
263
+ // ignore failures; UI state remains as-is
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Load issues from local push stores and re-render.
216
269
  */
217
270
  async function load() {
218
271
  // Preserve scroll position to avoid jarring jumps on live refresh
219
- /** @type {HTMLElement|null} */
220
- const beforeEl = /** @type {any} */ (
272
+ const beforeEl = /** @type {HTMLElement|null} */ (
221
273
  mount_element.querySelector('#list-root')
222
274
  );
223
275
  const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
224
- /** @type {any} */
225
- const filters = {};
226
- if (status_filter !== 'all' && status_filter !== 'ready') {
227
- filters.status = status_filter;
228
- }
229
- if (status_filter === 'ready') {
230
- filters.ready = true;
231
- }
232
- /** @type {unknown} */
233
- let result;
276
+ // Compose items from subscriptions membership and issues store entities
234
277
  try {
235
- result = await sendFn('list-issues', { filters });
278
+ if (selectors) {
279
+ issues_cache = /** @type {Issue[]} */ (
280
+ selectors.selectIssuesFor('tab:issues')
281
+ );
282
+ } else {
283
+ issues_cache = [];
284
+ }
236
285
  } catch {
237
- result = [];
238
- }
239
- if (!Array.isArray(result)) {
240
286
  issues_cache = [];
241
- } else {
242
- issues_cache = /** @type {Issue[]} */ (result);
243
287
  }
244
288
  doRender();
245
289
  // Restore scroll position if possible
246
290
  try {
247
- /** @type {HTMLElement|null} */
248
- const afterEl = /** @type {any} */ (
291
+ const afterEl = /** @type {HTMLElement|null} */ (
249
292
  mount_element.querySelector('#list-root')
250
293
  );
251
294
  if (afterEl && prevScroll > 0) {
@@ -262,12 +305,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
262
305
  // Grid cell Up/Down navigation when focus is inside the table and not within
263
306
  // an editable control (input/textarea/select). Preserves column position.
264
307
  if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
265
- /** @type {any} */
266
- const tgt = /** @type {any} */ (ev.target);
267
- /** @type {HTMLTableElement|null} */
308
+ const tgt = /** @type {HTMLElement} */ (ev.target);
268
309
  const table =
269
310
  tgt && typeof tgt.closest === 'function'
270
- ? /** @type {any} */ (tgt.closest('#list-root table.table'))
311
+ ? tgt.closest('#list-root table.table')
271
312
  : null;
272
313
  if (table) {
273
314
  // Do not intercept when inside native editable controls
@@ -279,34 +320,26 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
279
320
  tgt.closest('select'))
280
321
  );
281
322
  if (!in_editable) {
282
- /** @type {HTMLTableCellElement|null} */
283
323
  const cell =
284
- tgt && typeof tgt.closest === 'function'
285
- ? /** @type {any} */ (tgt.closest('td'))
286
- : null;
324
+ tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
287
325
  if (cell && cell.parentElement) {
288
- /** @type {HTMLTableRowElement} */
289
- const row = /** @type {any} */ (cell.parentElement);
290
- /** @type {HTMLTableSectionElement|null} */
291
- const tbody = /** @type {any} */ (row.parentElement);
326
+ const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
327
+ const tbody = /** @type {HTMLTableSectionElement|null} */ (
328
+ row.parentElement
329
+ );
292
330
  if (tbody && tbody.querySelectorAll) {
293
331
  const rows = Array.from(tbody.querySelectorAll('tr'));
294
332
  const row_idx = Math.max(0, rows.indexOf(row));
295
- const col_idx = /** @type {any} */ (cell).cellIndex || 0;
333
+ const col_idx = cell.cellIndex || 0;
296
334
  const next_idx =
297
335
  ev.key === 'ArrowDown'
298
336
  ? Math.min(row_idx + 1, rows.length - 1)
299
337
  : Math.max(row_idx - 1, 0);
300
- const next_row = /** @type {HTMLTableRowElement} */ (
301
- rows[next_idx]
302
- );
303
- /** @type {HTMLTableCellElement|null} */
304
- const next_cell = /** @type {any} */ (
305
- next_row && next_row.cells ? next_row.cells[col_idx] : null
306
- );
338
+ const next_row = rows[next_idx];
339
+ const next_cell =
340
+ next_row && next_row.cells ? next_row.cells[col_idx] : null;
307
341
  if (next_cell) {
308
- /** @type {HTMLElement|null} */
309
- const focusable = /** @type {any} */ (
342
+ const focusable = /** @type {HTMLElement|null} */ (
310
343
  next_cell.querySelector(
311
344
  'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
312
345
  )
@@ -323,14 +356,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
323
356
  }
324
357
  }
325
358
 
326
- /** @type {HTMLTableSectionElement|null} */
327
- const tbody = /** @type {any} */ (
359
+ const tbody = /** @type {HTMLTableSectionElement|null} */ (
328
360
  mount_element.querySelector('#list-root tbody')
329
361
  );
330
- /** @type {NodeListOf<HTMLTableRowElement>} */
331
- const items = tbody
332
- ? tbody.querySelectorAll('tr')
333
- : /** @type {any} */ ([]);
362
+ const items = tbody ? tbody.querySelectorAll('tr') : [];
334
363
  if (items.length === 0) {
335
364
  return;
336
365
  }
@@ -370,7 +399,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
370
399
  const current = items[idx];
371
400
  const id = current ? current.getAttribute('data-issue-id') : '';
372
401
  if (id) {
373
- const nav = navigate_fn || ((h) => (window.location.hash = h));
402
+ const nav = navigateFn || ((h) => (window.location.hash = h));
374
403
  /** @type {'issues'|'epics'|'board'} */
375
404
  const view = store ? store.getState().view : 'issues';
376
405
  nav(issueHashFor(view, id));
@@ -412,6 +441,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
412
441
  });
413
442
  }
414
443
 
444
+ // Live updates: recompose and re-render when issue stores change
445
+ if (selectors) {
446
+ selectors.subscribe(() => {
447
+ try {
448
+ issues_cache = /** @type {Issue[]} */ (
449
+ selectors.selectIssuesFor('tab:issues')
450
+ );
451
+ doRender();
452
+ } catch {
453
+ // ignore
454
+ }
455
+ });
456
+ }
457
+
415
458
  return {
416
459
  load,
417
460
  destroy() {
@@ -422,46 +465,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
422
465
  }
423
466
  }
424
467
  };
425
-
426
- /**
427
- * Update minimal fields inline via ws mutations and refresh that row's data.
428
- * @param {string} id
429
- * @param {{ [k: string]: any }} patch
430
- */
431
- async function updateInline(id, patch) {
432
- try {
433
- // Dispatch specific mutations based on provided keys
434
- if (typeof patch.title === 'string') {
435
- await sendFn('edit-text', { id, field: 'title', value: patch.title });
436
- }
437
- if (typeof patch.assignee === 'string') {
438
- await sendFn('update-assignee', { id, assignee: patch.assignee });
439
- }
440
- if (typeof patch.status === 'string') {
441
- await sendFn('update-status', { id, status: patch.status });
442
- }
443
- if (typeof patch.priority === 'number') {
444
- await sendFn('update-priority', { id, priority: patch.priority });
445
- }
446
- // Refresh the item from backend
447
- /** @type {any} */
448
- const full = await sendFn('show-issue', { id });
449
- // Replace in cache
450
- const idx = issues_cache.findIndex((x) => x.id === id);
451
- if (idx >= 0 && full && typeof full === 'object') {
452
- issues_cache[idx] = /** @type {Issue} */ ({
453
- id: full.id,
454
- title: full.title,
455
- status: full.status,
456
- priority: full.priority,
457
- issue_type: full.issue_type,
458
- assignee: full.assignee,
459
- labels: Array.isArray(full.labels) ? full.labels : []
460
- });
461
- }
462
- doRender();
463
- } catch {
464
- // ignore failures; UI state remains as-is
465
- }
466
- }
467
468
  }
package/app/views/nav.js CHANGED
@@ -2,6 +2,7 @@ import { html, render } from 'lit-html';
2
2
 
3
3
  /**
4
4
  * Render the top navigation with three tabs and handle route changes.
5
+ *
5
6
  * @param {HTMLElement} mount_element
6
7
  * @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
7
8
  * @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
@@ -3,6 +3,7 @@ import { priority_levels } from '../utils/priority.js';
3
3
 
4
4
  /**
5
5
  * Create and manage the New Issue dialog (native <dialog>).
6
+ *
6
7
  * @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
7
8
  * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
8
9
  * @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
@@ -10,8 +11,9 @@ import { priority_levels } from '../utils/priority.js';
10
11
  * @returns {{ open: () => void, close: () => void }}
11
12
  */
12
13
  export function createNewIssueDialog(mount_element, sendFn, router, store) {
13
- /** @type {HTMLDialogElement} */
14
- const dialog = /** @type {any} */ (document.createElement('dialog'));
14
+ const dialog = /** @type {HTMLDialogElement} */ (
15
+ document.createElement('dialog')
16
+ );
15
17
  dialog.id = 'new-issue-dialog';
16
18
  dialog.setAttribute('role', 'dialog');
17
19
  dialog.setAttribute('aria-modal', 'true');
@@ -52,32 +54,34 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
52
54
 
53
55
  mount_element.appendChild(dialog);
54
56
 
55
- /** @type {HTMLFormElement} */
56
- const form = /** @type {any} */ (dialog.querySelector('#new-issue-form'));
57
- /** @type {HTMLInputElement} */
58
- const input_title = /** @type {any} */ (dialog.querySelector('#new-title'));
59
- /** @type {HTMLSelectElement} */
60
- const sel_type = /** @type {any} */ (dialog.querySelector('#new-type'));
61
- /** @type {HTMLSelectElement} */
62
- const sel_priority = /** @type {any} */ (
57
+ const form = /** @type {HTMLFormElement} */ (
58
+ dialog.querySelector('#new-issue-form')
59
+ );
60
+ const input_title = /** @type {HTMLInputElement} */ (
61
+ dialog.querySelector('#new-title')
62
+ );
63
+ const sel_type = /** @type {HTMLSelectElement} */ (
64
+ dialog.querySelector('#new-type')
65
+ );
66
+ const sel_priority = /** @type {HTMLSelectElement} */ (
63
67
  dialog.querySelector('#new-priority')
64
68
  );
65
- /** @type {HTMLInputElement} */
66
- const input_labels = /** @type {any} */ (dialog.querySelector('#new-labels'));
67
- /** @type {HTMLTextAreaElement} */
68
- const input_description = /** @type {any} */ (
69
+ const input_labels = /** @type {HTMLInputElement} */ (
70
+ dialog.querySelector('#new-labels')
71
+ );
72
+ const input_description = /** @type {HTMLTextAreaElement} */ (
69
73
  dialog.querySelector('#new-description')
70
74
  );
71
- /** @type {HTMLDivElement} */
72
- const error_box = /** @type {any} */ (
75
+ const error_box = /** @type {HTMLDivElement} */ (
73
76
  dialog.querySelector('#new-issue-error')
74
77
  );
75
- /** @type {HTMLButtonElement} */
76
- const btn_cancel = /** @type {any} */ (dialog.querySelector('#btn-cancel'));
77
- /** @type {HTMLButtonElement} */
78
- const btn_create = /** @type {any} */ (dialog.querySelector('#btn-create'));
79
- /** @type {HTMLButtonElement} */
80
- const btn_close = /** @type {any} */ (
78
+ const btn_cancel = /** @type {HTMLButtonElement} */ (
79
+ dialog.querySelector('#btn-cancel')
80
+ );
81
+ const btn_create = /** @type {HTMLButtonElement} */ (
82
+ dialog.querySelector('#btn-create')
83
+ );
84
+ const btn_close = /** @type {HTMLButtonElement} */ (
81
85
  dialog.querySelector('.new-issue__close')
82
86
  );
83
87
 
@@ -181,6 +185,7 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
181
185
 
182
186
  /**
183
187
  * Extract numeric suffix from an id like "UI-123"; return -1 when absent.
188
+ *
184
189
  * @param {string} id
185
190
  */
186
191
  function idNumeric(id) {
@@ -190,29 +195,25 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
190
195
 
191
196
  /**
192
197
  * Submit handler: validate, create, then open the created issue details.
198
+ *
193
199
  * @returns {Promise<void>}
194
200
  */
195
201
  async function createNow() {
196
202
  clearError();
197
- /** @type {string} */
198
203
  const title = String(input_title.value || '').trim();
199
204
  if (title.length === 0) {
200
205
  setError('Title is required');
201
206
  input_title.focus();
202
207
  return;
203
208
  }
204
- /** @type {number} */
205
209
  const prio = Number(sel_priority.value || '2');
206
210
  if (!(prio >= 0 && prio <= 4)) {
207
211
  setError('Priority must be 0..4');
208
212
  sel_priority.focus();
209
213
  return;
210
214
  }
211
- /** @type {string} */
212
215
  const type = String(sel_type.value || '');
213
- /** @type {string} */
214
216
  const desc = String(input_description.value || '');
215
- /** @type {string[]} */
216
217
  const labels = String(input_labels.value || '')
217
218
  .split(',')
218
219
  .map((s) => s.trim())
@@ -251,10 +252,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
251
252
  } catch {
252
253
  list = null;
253
254
  }
254
- /** @type {string} */
255
255
  let created_id = '';
256
256
  if (Array.isArray(list)) {
257
- /** @type {any[]} */
258
257
  const matches = list.filter((it) => String(it.title || '') === title);
259
258
  if (matches.length > 0) {
260
259
  /** @type {any} */
@@ -326,11 +325,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
326
325
  clearError();
327
326
  loadDefaults();
328
327
  try {
329
- if (
330
- 'showModal' in dialog &&
331
- typeof (/** @type {any} */ (dialog).showModal) === 'function'
332
- ) {
333
- /** @type {any} */ (dialog).showModal();
328
+ if ('showModal' in dialog && typeof dialog.showModal === 'function') {
329
+ dialog.showModal();
334
330
  } else {
335
331
  dialog.setAttribute('open', '');
336
332
  }
package/app/ws.js CHANGED
@@ -1,4 +1,3 @@
1
- /* global Console */
2
1
  /**
3
2
  * @import { MessageType } from './protocol.js'
4
3
  */
@@ -9,7 +8,7 @@
9
8
  * Usage:
10
9
  * const ws = createWsClient();
11
10
  * const data = await ws.send('list-issues', { filters: {} });
12
- * const off = ws.on('issues-changed', (payload) => { ... });
11
+ * const off = ws.on('snapshot', (payload) => { <push event> });
13
12
  */
14
13
  import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
15
14
 
@@ -27,6 +26,7 @@ import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
27
26
 
28
27
  /**
29
28
  * Create a WebSocket client with auto-reconnect and message correlation.
29
+ *
30
30
  * @param {ClientOptions} [options]
31
31
  */
32
32
  export function createWsClient(options = {}) {
@@ -123,8 +123,6 @@ export function createWsClient(options = {}) {
123
123
  state = 'open';
124
124
  notifyConnection(state);
125
125
  attempts = 0;
126
- // subscribe first
127
- sendRaw(makeRequest('subscribe-updates', {}));
128
126
  // flush queue
129
127
  while (queue.length) {
130
128
  const req = queue.shift();
@@ -193,16 +191,15 @@ export function createWsClient(options = {}) {
193
191
  }
194
192
  const url = resolveUrl();
195
193
  try {
196
- ws = /** @type {any} */ (new WebSocket(url));
194
+ ws = new WebSocket(url);
197
195
  state = 'connecting';
198
196
  notifyConnection(state);
199
- const s = /** @type {any} */ (ws);
200
- s.addEventListener('open', onOpen);
201
- s.addEventListener('message', onMessage);
202
- s.addEventListener('error', () => {
197
+ ws.addEventListener('open', onOpen);
198
+ ws.addEventListener('message', onMessage);
199
+ ws.addEventListener('error', () => {
203
200
  // let close handler handle reconnect
204
201
  });
205
- s.addEventListener('close', onClose);
202
+ ws.addEventListener('close', onClose);
206
203
  } catch (err) {
207
204
  logger.error('ws connect failed', err);
208
205
  scheduleReconnect();
@@ -214,6 +211,7 @@ export function createWsClient(options = {}) {
214
211
  return {
215
212
  /**
216
213
  * Send a request and await its correlated reply payload.
214
+ *
217
215
  * @param {MessageType} type
218
216
  * @param {unknown} [payload]
219
217
  * @returns {Promise<any>}
@@ -236,6 +234,7 @@ export function createWsClient(options = {}) {
236
234
  /**
237
235
  * Register a handler for a server-initiated event type.
238
236
  * Returns an unsubscribe function.
237
+ *
239
238
  * @param {MessageType} type
240
239
  * @param {(payload: any) => void} handler
241
240
  * @returns {() => void}
@@ -252,6 +251,7 @@ export function createWsClient(options = {}) {
252
251
  },
253
252
  /**
254
253
  * Subscribe to connection state changes.
254
+ *
255
255
  * @param {(state: ConnectionState) => void} handler
256
256
  * @returns {() => void}
257
257
  */
package/bin/bdui.js CHANGED
@@ -10,7 +10,7 @@ const argv = process.argv.slice(2);
10
10
  try {
11
11
  const code = await main(argv);
12
12
  if (Number.isFinite(code)) {
13
- process.exitCode = /** @type {number} */ (code);
13
+ process.exitCode = code;
14
14
  }
15
15
  } catch (err) {
16
16
  console.error(String(/** @type {any} */ (err)?.message || err));