beads-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGES.md +5 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +5 -138
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/main.js +342 -66
  10. package/app/protocol.js +10 -14
  11. package/app/protocol.md +18 -15
  12. package/app/styles.css +222 -197
  13. package/app/utils/markdown.js +15 -194
  14. package/app/utils/priority-badge.js +0 -2
  15. package/app/utils/status-badge.js +0 -1
  16. package/app/utils/toast.js +0 -1
  17. package/app/utils/type-badge.js +0 -3
  18. package/app/views/board.js +166 -144
  19. package/app/views/detail.js +76 -66
  20. package/app/views/epics.js +126 -74
  21. package/app/views/issue-dialog.js +8 -15
  22. package/app/views/issue-row.js +1 -3
  23. package/app/views/list.js +101 -104
  24. package/app/views/new-issue-dialog.js +27 -34
  25. package/app/ws.js +6 -9
  26. package/bin/bdui.js +1 -1
  27. package/docs/adr/001-push-only-lists.md +134 -0
  28. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  29. package/docs/architecture.md +34 -84
  30. package/docs/data-exchange-subscription-plan.md +198 -0
  31. package/docs/db-watching.md +2 -1
  32. package/docs/migration-v2.md +54 -0
  33. package/docs/protocol/issues-push-v2.md +179 -0
  34. package/docs/subscription-issue-store.md +112 -0
  35. package/package.json +4 -2
  36. package/server/bd.js +0 -2
  37. package/server/cli/commands.js +1 -2
  38. package/server/cli/daemon.js +12 -5
  39. package/server/cli/index.js +0 -2
  40. package/server/cli/usage.js +1 -1
  41. package/server/config.js +12 -6
  42. package/server/db.js +0 -1
  43. package/server/index.js +9 -5
  44. package/server/list-adapters.js +218 -0
  45. package/server/subscriptions.js +277 -0
  46. package/server/validators.js +111 -0
  47. package/server/watcher.js +5 -8
  48. package/server/ws.js +449 -230
@@ -28,6 +28,7 @@ import { createTypeBadge } from '../utils/type-badge.js';
28
28
  * @property {string} [acceptance]
29
29
  * @property {string} [notes]
30
30
  * @property {string} [status]
31
+ * @property {string} [assignee]
31
32
  * @property {number} [priority]
32
33
  * @property {string[]} [labels]
33
34
  * @property {Dependency[]} [dependencies]
@@ -46,15 +47,19 @@ function defaultNavigateFn(hash) {
46
47
  * @param {HTMLElement} mount_element - Element to render into.
47
48
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
48
49
  * @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
50
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
49
51
  * @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
50
52
  */
51
53
  export function createDetailView(
52
54
  mount_element,
53
55
  sendFn,
54
- navigateFn = defaultNavigateFn
56
+ navigateFn = defaultNavigateFn,
57
+ issue_stores = undefined
55
58
  ) {
56
59
  /** @type {IssueDetail | null} */
57
60
  let current = null;
61
+ /** @type {string | null} */
62
+ let current_id = null;
58
63
  /** @type {boolean} */
59
64
  let pending = false;
60
65
  /** @type {boolean} */
@@ -97,6 +102,46 @@ export function createDetailView(
97
102
  );
98
103
  }
99
104
 
105
+ /**
106
+ * Refresh current from subscription store snapshot if available.
107
+ */
108
+ function refreshFromStore() {
109
+ if (
110
+ !current_id ||
111
+ !issue_stores ||
112
+ typeof issue_stores.snapshotFor !== 'function'
113
+ ) {
114
+ return;
115
+ }
116
+ const arr = /** @type {IssueDetail[]} */ (
117
+ issue_stores.snapshotFor(`detail:${current_id}`)
118
+ );
119
+ if (Array.isArray(arr) && arr.length > 0) {
120
+ // First item is the issue for this subscription
121
+ const found =
122
+ arr.find((it) => String(it.id) === String(current_id)) || arr[0];
123
+ current = /** @type {IssueDetail} */ (found);
124
+ }
125
+ }
126
+
127
+ // Live updates: re-render when issue stores change
128
+ if (issue_stores && typeof issue_stores.subscribe === 'function') {
129
+ issue_stores.subscribe(() => {
130
+ try {
131
+ const prev_id = current && current.id ? String(current.id) : null;
132
+ refreshFromStore();
133
+ // Only re-render when the current entity changed or when we were loading
134
+ if (!prev_id || (current && String(current.id) === prev_id)) {
135
+ doRender();
136
+ } else {
137
+ doRender();
138
+ }
139
+ } catch {
140
+ // ignore
141
+ }
142
+ });
143
+ }
144
+
100
145
  // Handlers
101
146
  const onTitleSpanClick = () => {
102
147
  edit_title = true;
@@ -118,8 +163,9 @@ export function createDetailView(
118
163
  if (!current || pending) {
119
164
  return;
120
165
  }
121
- /** @type {HTMLInputElement|null} */
122
- const input = /** @type {any} */ (mount_element.querySelector('h2 input'));
166
+ const input = /** @type {HTMLInputElement|null} */ (
167
+ mount_element.querySelector('h2 input')
168
+ );
123
169
  const prev = current.title || '';
124
170
  const next = input ? input.value : '';
125
171
  if (next === prev) {
@@ -178,14 +224,11 @@ export function createDetailView(
178
224
  if (!current || pending) {
179
225
  return;
180
226
  }
181
- /** @type {HTMLInputElement|null} */
182
- const input = /** @type {any} */ (
227
+ const input = /** @type {HTMLInputElement|null} */ (
183
228
  mount_element.querySelector('#detail-root .prop.assignee input')
184
229
  );
185
- const prev = String(
186
- (current && /** @type {any} */ (current).assignee) || ''
187
- );
188
- const next = input ? String(input.value || '') : '';
230
+ const prev = current?.assignee ?? '';
231
+ const next = input?.value ?? '';
189
232
  if (next === prev) {
190
233
  edit_assignee = false;
191
234
  doRender();
@@ -207,7 +250,7 @@ export function createDetailView(
207
250
  }
208
251
  } catch {
209
252
  // revert visually
210
- /** @type {any} */ (current).assignee = prev;
253
+ current.assignee = prev;
211
254
  edit_assignee = false;
212
255
  doRender();
213
256
  showToast('Failed to update assignee', 'error');
@@ -225,8 +268,7 @@ export function createDetailView(
225
268
  * @param {Event} ev
226
269
  */
227
270
  const onLabelInput = (ev) => {
228
- /** @type {HTMLInputElement} */
229
- const el = /** @type {any} */ (ev.currentTarget);
271
+ const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
230
272
  new_label_text = el.value || '';
231
273
  };
232
274
  /**
@@ -294,8 +336,7 @@ export function createDetailView(
294
336
  doRender();
295
337
  return;
296
338
  }
297
- /** @type {HTMLSelectElement} */
298
- const sel = /** @type {any} */ (ev.currentTarget);
339
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
299
340
  const prev = current.status || 'open';
300
341
  const next = sel.value;
301
342
  if (next === prev) {
@@ -329,8 +370,7 @@ export function createDetailView(
329
370
  doRender();
330
371
  return;
331
372
  }
332
- /** @type {HTMLSelectElement} */
333
- const sel = /** @type {any} */ (ev.currentTarget);
373
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
334
374
  const prev = typeof current.priority === 'number' ? current.priority : 2;
335
375
  const next = Number(sel.value);
336
376
  if (next === prev) {
@@ -368,10 +408,7 @@ export function createDetailView(
368
408
  if (ev.key === 'Escape') {
369
409
  edit_desc = false;
370
410
  doRender();
371
- } else if (
372
- ev.key === 'Enter' &&
373
- /** @type {KeyboardEvent} */ (ev).ctrlKey
374
- ) {
411
+ } else if (ev.key === 'Enter' && ev.ctrlKey) {
375
412
  const btn = /** @type {HTMLButtonElement|null} */ (
376
413
  mount_element.querySelector('#detail-root .editable-actions button')
377
414
  );
@@ -384,8 +421,7 @@ export function createDetailView(
384
421
  if (!current || pending) {
385
422
  return;
386
423
  }
387
- /** @type {HTMLTextAreaElement|null} */
388
- const ta = /** @type {any} */ (
424
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
389
425
  mount_element.querySelector('#detail-root textarea')
390
426
  );
391
427
  const prev = current.description || '';
@@ -461,8 +497,7 @@ export function createDetailView(
461
497
  if (!current || pending) {
462
498
  return;
463
499
  }
464
- /** @type {HTMLTextAreaElement|null} */
465
- const ta = /** @type {any} */ (
500
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
466
501
  mount_element.querySelector('#detail-root .design textarea')
467
502
  );
468
503
  const prev = current.design || '';
@@ -528,8 +563,7 @@ export function createDetailView(
528
563
  if (!current || pending) {
529
564
  return;
530
565
  }
531
- /** @type {HTMLTextAreaElement|null} */
532
- const ta = /** @type {any} */ (
566
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
533
567
  mount_element.querySelector('#detail-root .notes textarea')
534
568
  );
535
569
  const prev = current.notes || '';
@@ -594,8 +628,7 @@ export function createDetailView(
594
628
  if (!current || pending) {
595
629
  return;
596
630
  }
597
- /** @type {HTMLTextAreaElement|null} */
598
- const ta = /** @type {any} */ (
631
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
599
632
  mount_element.querySelector('#detail-root .acceptance textarea')
600
633
  );
601
634
  const prev = current.acceptance || '';
@@ -971,12 +1004,7 @@ export function createDetailView(
971
1004
  .value=${/** @type {any} */ (issue).assignee || ''}
972
1005
  size=${Math.min(
973
1006
  40,
974
- Math.max(
975
- 12,
976
- String(
977
- /** @type {any} */ (issue).assignee || ''
978
- ).length + 3
979
- )
1007
+ Math.max(12, (issue.assignee || '').length + 3)
980
1008
  )}
981
1009
  @keydown=${
982
1010
  /** @param {KeyboardEvent} e */ (e) => {
@@ -1005,9 +1033,7 @@ export function createDetailView(
1005
1033
  Cancel
1006
1034
  </button>`
1007
1035
  : html`${(() => {
1008
- const raw = String(
1009
- /** @type {any} */ (issue).assignee || ''
1010
- );
1036
+ const raw = issue.assignee || '';
1011
1037
  const has = raw.trim().length > 0;
1012
1038
  const text = has ? raw : 'Unassigned';
1013
1039
  const cls = has ? 'editable' : 'editable muted';
@@ -1036,7 +1062,7 @@ export function createDetailView(
1036
1062
 
1037
1063
  function doRender() {
1038
1064
  if (!current) {
1039
- renderPlaceholder('No issue selected');
1065
+ renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
1040
1066
  return;
1041
1067
  }
1042
1068
  render(detailTemplate(current), mount_element);
@@ -1051,9 +1077,7 @@ export function createDetailView(
1051
1077
  */
1052
1078
  function makeDepRemoveClick(did, title) {
1053
1079
  return async (ev) => {
1054
- /** @type {Event} */
1055
- const e = ev;
1056
- e.stopPropagation();
1080
+ ev.stopPropagation();
1057
1081
  if (!current || pending) {
1058
1082
  return;
1059
1083
  }
@@ -1099,10 +1123,10 @@ export function createDetailView(
1099
1123
  if (!current || pending) {
1100
1124
  return;
1101
1125
  }
1102
- /** @type {HTMLButtonElement} */
1103
- const btn = /** @type {any} */ (ev.currentTarget);
1104
- /** @type {HTMLInputElement|null} */
1105
- const input = /** @type {any} */ (btn.previousElementSibling);
1126
+ const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
1127
+ const input = /** @type {HTMLInputElement|null} */ (
1128
+ btn.previousElementSibling
1129
+ );
1106
1130
  const target = input ? input.value.trim() : '';
1107
1131
  if (!target || target === current.id) {
1108
1132
  showToast('Enter a different issue id');
@@ -1204,28 +1228,14 @@ export function createDetailView(
1204
1228
  renderPlaceholder('No issue selected');
1205
1229
  return;
1206
1230
  }
1207
- /** @type {unknown} */
1208
- let result;
1209
- try {
1210
- result = await sendFn('show-issue', { id });
1211
- } catch {
1212
- result = null;
1213
- }
1214
- if (!result || typeof result !== 'object') {
1215
- renderPlaceholder('Issue not found');
1216
- return;
1217
- }
1218
- const issue = /** @type {IssueDetail} */ (result);
1219
- // Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
1220
- // Treat IDs case-insensitively to avoid false negatives on deep links.
1221
- if (
1222
- !issue ||
1223
- String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
1224
- ) {
1225
- renderPlaceholder('Issue not found');
1226
- return;
1231
+ current_id = String(id);
1232
+ // Try from store first; show placeholder while waiting for snapshot
1233
+ current = null;
1234
+ refreshFromStore();
1235
+ if (!current) {
1236
+ renderPlaceholder('Loading…');
1227
1237
  }
1228
- current = issue;
1238
+ // Render from current (if available) or keep placeholder until push arrives
1229
1239
  pending = false;
1230
1240
  doRender();
1231
1241
  },
@@ -1,28 +1,57 @@
1
1
  import { html, render } from 'lit-html';
2
+ import { createListSelectors } from '../data/list-selectors.js';
2
3
  import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
4
  import { createIssueRowRenderer } from './issue-row.js';
4
5
 
5
6
  /**
6
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, updated_at?: string }} IssueLite
7
+ * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
7
8
  */
8
9
 
9
10
  /**
10
- * Epics view: grouped table using `bd epic status --json`. Expanding a group loads
11
- * the epic via `getIssue(id)` and then loads each dependent issue to filter out
12
- * closed items. Provides inline editing for type, title, priority, status, assignee.
11
+ * Epics view (push-only):
12
+ * - Derives epic groups from the local issues store (no RPC reads)
13
+ * - Subscribes to `tab:epics` for top-level membership
14
+ * - On expand, subscribes to `detail:{id}` (issue-detail) for the epic
15
+ * - Renders children from the epic detail's `dependents` list
16
+ * - Provides inline edits via mutations; UI re-renders on push
13
17
  * @param {HTMLElement} mount_element
14
- * @param {{ getEpicStatus: () => Promise<any[]>, getIssue: (id: string) => Promise<any>, updateIssue: (input: any) => Promise<any> }} data
18
+ * @param {{ updateIssue: (input: any) => Promise<any> }} data
15
19
  * @param {(id: string) => void} goto_issue - Navigate to issue detail.
20
+ * @param {{ subscribeList: (client_id: string, spec: { type: string, params?: Record<string, string|number|boolean> }) => Promise<() => Promise<void>>, selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
21
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
16
22
  */
17
- export function createEpicsView(mount_element, data, goto_issue) {
23
+ export function createEpicsView(
24
+ mount_element,
25
+ data,
26
+ goto_issue,
27
+ subscriptions = undefined,
28
+ issue_stores = undefined
29
+ ) {
18
30
  /** @type {any[]} */
19
31
  let groups = [];
20
32
  /** @type {Set<string>} */
21
33
  const expanded = new Set();
22
- /** @type {Map<string, IssueLite[]>} */
23
- const children = new Map();
24
34
  /** @type {Set<string>} */
25
35
  const loading = new Set();
36
+ /** @type {Map<string, () => Promise<void>>} */
37
+ const epic_unsubs = new Map();
38
+ // Centralized selection helpers
39
+ const selectors = issue_stores ? createListSelectors(issue_stores) : null;
40
+ // Live re-render on pushes: recompute groups when stores change
41
+ if (selectors) {
42
+ selectors.subscribe(() => {
43
+ const had_none = groups.length === 0;
44
+ groups = buildGroupsFromSnapshot();
45
+ doRender();
46
+ // Auto-expand first epic when transitioning from empty to non-empty
47
+ if (had_none && groups.length > 0) {
48
+ const first_id = String(groups[0].epic?.id || '');
49
+ if (first_id && !expanded.has(first_id)) {
50
+ void toggle(first_id);
51
+ }
52
+ }
53
+ });
54
+ }
26
55
 
27
56
  // Shared row renderer used for children rows
28
57
  const renderRow = createIssueRowRenderer({
@@ -51,7 +80,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
51
80
  const epic = g.epic || {};
52
81
  const id = String(epic.id || '');
53
82
  const is_open = expanded.has(id);
54
- const list = children.get(id) || [];
83
+ // Compose children via selectors
84
+ const list = selectors ? selectors.selectEpicChildren(id) : [];
55
85
  const is_loading = loading.has(id);
56
86
  return html`
57
87
  <div class="epic-group" data-epic-id=${id}>
@@ -84,7 +114,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
84
114
  ${is_loading
85
115
  ? html`<div class="muted">Loading…</div>`
86
116
  : list.length === 0
87
- ? html`<div class="muted">No open issues</div>`
117
+ ? html`<div class="muted">No issues found</div>`
88
118
  : html`<table class="table">
89
119
  <colgroup>
90
120
  <col style="width: 100px" />
@@ -121,24 +151,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
121
151
  async function updateInline(id, patch) {
122
152
  try {
123
153
  await data.updateIssue({ id, ...patch });
124
- // Opportunistic refresh for that row
125
- const full = await data.getIssue(id);
126
- /** @type {IssueLite} */
127
- const lite = {
128
- id: full.id,
129
- title: full.title,
130
- status: full.status,
131
- priority: full.priority,
132
- issue_type: full.issue_type,
133
- assignee: full.assignee
134
- };
135
- // Replace in children map
136
- for (const arr of children.values()) {
137
- const idx = arr.findIndex((x) => x.id === id);
138
- if (idx >= 0) {
139
- arr[idx] = lite;
140
- }
141
- }
154
+ // Re-render; view will update on subsequent push
142
155
  doRender();
143
156
  } catch {
144
157
  // swallow; UI remains
@@ -151,70 +164,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
151
164
  async function toggle(epic_id) {
152
165
  if (!expanded.has(epic_id)) {
153
166
  expanded.add(epic_id);
154
- // Load children if not present
155
- if (!children.has(epic_id)) {
156
- loading.add(epic_id);
157
- doRender();
167
+ loading.add(epic_id);
168
+ doRender();
169
+ // Subscribe to epic detail; children are rendered from `dependents`
170
+ if (subscriptions && typeof subscriptions.subscribeList === 'function') {
158
171
  try {
159
- const epic = await data.getIssue(epic_id);
160
- // Children for the Epics view come from dependents: issues that list
161
- // the epic as a dependency. This matches how progress is tracked.
162
- /** @type {{ id: string }[]} */
163
- const deps = Array.isArray(epic.dependents) ? epic.dependents : [];
164
- /** @type {IssueLite[]} */
165
- const list = [];
166
- for (const d of deps) {
167
- try {
168
- const full = await data.getIssue(d.id);
169
- if (full.status !== 'closed') {
170
- list.push({
171
- id: full.id,
172
- title: full.title,
173
- status: full.status,
174
- priority: full.priority,
175
- issue_type: full.issue_type,
176
- assignee: full.assignee,
177
- // include updated_at for secondary sort within same priority
178
- updated_at: /** @type {any} */ (full).updated_at
179
- });
180
- }
181
- } catch {
182
- // ignore individual failures
172
+ // Register store first to avoid dropping the initial snapshot
173
+ try {
174
+ if (issue_stores && /** @type {any} */ (issue_stores).register) {
175
+ /** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
176
+ type: 'issue-detail',
177
+ params: { id: epic_id }
178
+ });
183
179
  }
180
+ } catch {
181
+ // ignore
184
182
  }
185
- // Sort by priority then updated_at (if present)
186
- list.sort((a, b) => {
187
- const pa = a.priority ?? 2;
188
- const pb = b.priority ?? 2;
189
- if (pa !== pb) {
190
- return pa - pb;
191
- }
192
- // @ts-ignore optional updated_at if present
193
- const ua = a.updated_at || '';
194
- // @ts-ignore
195
- const ub = b.updated_at || '';
196
- return ua < ub ? 1 : ua > ub ? -1 : 0;
183
+ const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
184
+ type: 'issue-detail',
185
+ params: { id: epic_id }
197
186
  });
198
- children.set(epic_id, list);
199
- } finally {
200
- loading.delete(epic_id);
187
+ epic_unsubs.set(epic_id, u);
188
+ } catch {
189
+ // ignore subscription failures
201
190
  }
202
191
  }
192
+ // Mark as not loading after subscribe attempt; membership will stream in
193
+ loading.delete(epic_id);
203
194
  } else {
204
195
  expanded.delete(epic_id);
196
+ // Unsubscribe when collapsing
197
+ if (epic_unsubs.has(epic_id)) {
198
+ try {
199
+ const u = epic_unsubs.get(epic_id);
200
+ if (u) {
201
+ await u();
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+ epic_unsubs.delete(epic_id);
207
+ try {
208
+ if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
209
+ /** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
210
+ }
211
+ } catch {
212
+ // ignore
213
+ }
214
+ }
205
215
  }
206
216
  doRender();
207
217
  }
208
218
 
219
+ /** Build groups from the current `tab:epics` snapshot. */
220
+ function buildGroupsFromSnapshot() {
221
+ /** @type {IssueLite[]} */
222
+ const epic_entities =
223
+ issue_stores && issue_stores.snapshotFor
224
+ ? /** @type {IssueLite[]} */ (
225
+ issue_stores.snapshotFor('tab:epics') || []
226
+ )
227
+ : [];
228
+ const next_groups = [];
229
+ for (const epic of epic_entities) {
230
+ const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
231
+ ? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
232
+ : [];
233
+ // Prefer explicit counters when provided by server; otherwise derive
234
+ const has_total = Number.isFinite(
235
+ /** @type {any} */ (epic).total_children
236
+ );
237
+ const has_closed = Number.isFinite(
238
+ /** @type {any} */ (epic).closed_children
239
+ );
240
+ const total = has_total
241
+ ? Number(/** @type {any} */ (epic).total_children) || 0
242
+ : dependents.length;
243
+ let closed = has_closed
244
+ ? Number(/** @type {any} */ (epic).closed_children) || 0
245
+ : 0;
246
+ if (!has_closed) {
247
+ for (const d of dependents) {
248
+ if (String(d.status || '') === 'closed') {
249
+ closed++;
250
+ }
251
+ }
252
+ }
253
+ next_groups.push({
254
+ epic,
255
+ total_children: total,
256
+ closed_children: closed
257
+ });
258
+ }
259
+ return next_groups;
260
+ }
261
+
209
262
  return {
210
263
  async load() {
211
- const res = await data.getEpicStatus();
212
- groups = Array.isArray(res) ? res : [];
264
+ groups = buildGroupsFromSnapshot();
213
265
  doRender();
214
266
  // Auto-expand first epic on screen
215
267
  try {
216
268
  if (groups.length > 0) {
217
- const first_id = String((groups[0].epic && groups[0].epic.id) || '');
269
+ const first_id = String(groups[0].epic?.id || '');
218
270
  if (first_id && !expanded.has(first_id)) {
219
271
  // This will render and load children lazily
220
272
  await toggle(first_id);
@@ -16,8 +16,7 @@ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
16
16
  * @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
17
17
  */
18
18
  export function createIssueDialog(mount_element, store, onClose) {
19
- /** @type {HTMLDialogElement} */
20
- const dialog = /** @type {any} */ (document.createElement('dialog'));
19
+ const dialog = document.createElement('dialog');
21
20
  dialog.id = 'issue-dialog';
22
21
  dialog.setAttribute('role', 'dialog');
23
22
  dialog.setAttribute('aria-modal', 'true');
@@ -37,16 +36,13 @@ export function createIssueDialog(mount_element, store, onClose) {
37
36
 
38
37
  mount_element.appendChild(dialog);
39
38
 
40
- /** @type {HTMLElement} */
41
- const body_mount = /** @type {any} */ (
39
+ const body_mount = /** @type {HTMLElement} */ (
42
40
  dialog.querySelector('#issue-dialog-body')
43
41
  );
44
- /** @type {HTMLElement} */
45
- const title_el = /** @type {any} */ (
42
+ const title_el = /** @type {HTMLElement} */ (
46
43
  dialog.querySelector('#issue-dialog-title')
47
44
  );
48
- /** @type {HTMLButtonElement} */
49
- const btn_close = /** @type {any} */ (
45
+ const btn_close = /** @type {HTMLButtonElement} */ (
50
46
  dialog.querySelector('.issue-dialog__close')
51
47
  );
52
48
 
@@ -56,7 +52,7 @@ export function createIssueDialog(mount_element, store, onClose) {
56
52
  function setTitle(id) {
57
53
  // Use copyable ID renderer but keep visible text as raw id for tests/clarity
58
54
  title_el.replaceChildren();
59
- title_el.appendChild(createIssueIdRenderer(String(id || '')));
55
+ title_el.appendChild(createIssueIdRenderer(id));
60
56
  }
61
57
 
62
58
  // Backdrop click: when clicking the dialog itself (outside container), close
@@ -102,7 +98,7 @@ export function createIssueDialog(mount_element, store, onClose) {
102
98
  function open(id) {
103
99
  // Capture currently focused element to restore after closing
104
100
  try {
105
- const ae = /** @type {any} */ (document.activeElement);
101
+ const ae = document.activeElement;
106
102
  if (ae && ae instanceof HTMLElement) {
107
103
  last_focus = ae;
108
104
  } else {
@@ -113,11 +109,8 @@ export function createIssueDialog(mount_element, store, onClose) {
113
109
  }
114
110
  setTitle(id);
115
111
  try {
116
- if (
117
- 'showModal' in dialog &&
118
- typeof (/** @type {any} */ (dialog).showModal) === 'function'
119
- ) {
120
- /** @type {any} */ (dialog).showModal();
112
+ if ('showModal' in dialog && typeof dialog.showModal === 'function') {
113
+ dialog.showModal();
121
114
  } else {
122
115
  dialog.setAttribute('open', '');
123
116
  }
@@ -38,7 +38,6 @@ export function createIssueRowRenderer(options) {
38
38
  * @param {string} [placeholder]
39
39
  */
40
40
  function editableText(id, key, value, placeholder = '') {
41
- /** @type {string} */
42
41
  const k = `${id}:${key}`;
43
42
  const is_edit = editing.has(k);
44
43
  if (is_edit) {
@@ -111,8 +110,7 @@ export function createIssueRowRenderer(options) {
111
110
  */
112
111
  function makeSelectChange(id, key) {
113
112
  return async (ev) => {
114
- /** @type {HTMLSelectElement} */
115
- const sel = /** @type {any} */ (ev.currentTarget);
113
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
116
114
  const val = sel.value || '';
117
115
  /** @type {{ [k:string]: any }} */
118
116
  const patch = {};