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
@@ -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]
@@ -43,18 +44,23 @@ function defaultNavigateFn(hash) {
43
44
 
44
45
  /**
45
46
  * Create the Issue Detail view.
47
+ *
46
48
  * @param {HTMLElement} mount_element - Element to render into.
47
49
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
48
50
  * @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
51
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
49
52
  * @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
50
53
  */
51
54
  export function createDetailView(
52
55
  mount_element,
53
56
  sendFn,
54
- navigateFn = defaultNavigateFn
57
+ navigateFn = defaultNavigateFn,
58
+ issue_stores = undefined
55
59
  ) {
56
60
  /** @type {IssueDetail | null} */
57
61
  let current = null;
62
+ /** @type {string | null} */
63
+ let current_id = null;
58
64
  /** @type {boolean} */
59
65
  let pending = false;
60
66
  /** @type {boolean} */
@@ -97,6 +103,46 @@ export function createDetailView(
97
103
  );
98
104
  }
99
105
 
106
+ /**
107
+ * Refresh current from subscription store snapshot if available.
108
+ */
109
+ function refreshFromStore() {
110
+ if (
111
+ !current_id ||
112
+ !issue_stores ||
113
+ typeof issue_stores.snapshotFor !== 'function'
114
+ ) {
115
+ return;
116
+ }
117
+ const arr = /** @type {IssueDetail[]} */ (
118
+ issue_stores.snapshotFor(`detail:${current_id}`)
119
+ );
120
+ if (Array.isArray(arr) && arr.length > 0) {
121
+ // First item is the issue for this subscription
122
+ const found =
123
+ arr.find((it) => String(it.id) === String(current_id)) || arr[0];
124
+ current = /** @type {IssueDetail} */ (found);
125
+ }
126
+ }
127
+
128
+ // Live updates: re-render when issue stores change
129
+ if (issue_stores && typeof issue_stores.subscribe === 'function') {
130
+ issue_stores.subscribe(() => {
131
+ try {
132
+ const prev_id = current && current.id ? String(current.id) : null;
133
+ refreshFromStore();
134
+ // Only re-render when the current entity changed or when we were loading
135
+ if (!prev_id || (current && String(current.id) === prev_id)) {
136
+ doRender();
137
+ } else {
138
+ doRender();
139
+ }
140
+ } catch {
141
+ // ignore
142
+ }
143
+ });
144
+ }
145
+
100
146
  // Handlers
101
147
  const onTitleSpanClick = () => {
102
148
  edit_title = true;
@@ -118,8 +164,9 @@ export function createDetailView(
118
164
  if (!current || pending) {
119
165
  return;
120
166
  }
121
- /** @type {HTMLInputElement|null} */
122
- const input = /** @type {any} */ (mount_element.querySelector('h2 input'));
167
+ const input = /** @type {HTMLInputElement|null} */ (
168
+ mount_element.querySelector('h2 input')
169
+ );
123
170
  const prev = current.title || '';
124
171
  const next = input ? input.value : '';
125
172
  if (next === prev) {
@@ -178,14 +225,11 @@ export function createDetailView(
178
225
  if (!current || pending) {
179
226
  return;
180
227
  }
181
- /** @type {HTMLInputElement|null} */
182
- const input = /** @type {any} */ (
228
+ const input = /** @type {HTMLInputElement|null} */ (
183
229
  mount_element.querySelector('#detail-root .prop.assignee input')
184
230
  );
185
- const prev = String(
186
- (current && /** @type {any} */ (current).assignee) || ''
187
- );
188
- const next = input ? String(input.value || '') : '';
231
+ const prev = current?.assignee ?? '';
232
+ const next = input?.value ?? '';
189
233
  if (next === prev) {
190
234
  edit_assignee = false;
191
235
  doRender();
@@ -207,7 +251,7 @@ export function createDetailView(
207
251
  }
208
252
  } catch {
209
253
  // revert visually
210
- /** @type {any} */ (current).assignee = prev;
254
+ current.assignee = prev;
211
255
  edit_assignee = false;
212
256
  doRender();
213
257
  showToast('Failed to update assignee', 'error');
@@ -225,8 +269,7 @@ export function createDetailView(
225
269
  * @param {Event} ev
226
270
  */
227
271
  const onLabelInput = (ev) => {
228
- /** @type {HTMLInputElement} */
229
- const el = /** @type {any} */ (ev.currentTarget);
272
+ const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
230
273
  new_label_text = el.value || '';
231
274
  };
232
275
  /**
@@ -294,8 +337,7 @@ export function createDetailView(
294
337
  doRender();
295
338
  return;
296
339
  }
297
- /** @type {HTMLSelectElement} */
298
- const sel = /** @type {any} */ (ev.currentTarget);
340
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
299
341
  const prev = current.status || 'open';
300
342
  const next = sel.value;
301
343
  if (next === prev) {
@@ -329,8 +371,7 @@ export function createDetailView(
329
371
  doRender();
330
372
  return;
331
373
  }
332
- /** @type {HTMLSelectElement} */
333
- const sel = /** @type {any} */ (ev.currentTarget);
374
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
334
375
  const prev = typeof current.priority === 'number' ? current.priority : 2;
335
376
  const next = Number(sel.value);
336
377
  if (next === prev) {
@@ -368,10 +409,7 @@ export function createDetailView(
368
409
  if (ev.key === 'Escape') {
369
410
  edit_desc = false;
370
411
  doRender();
371
- } else if (
372
- ev.key === 'Enter' &&
373
- /** @type {KeyboardEvent} */ (ev).ctrlKey
374
- ) {
412
+ } else if (ev.key === 'Enter' && ev.ctrlKey) {
375
413
  const btn = /** @type {HTMLButtonElement|null} */ (
376
414
  mount_element.querySelector('#detail-root .editable-actions button')
377
415
  );
@@ -384,8 +422,7 @@ export function createDetailView(
384
422
  if (!current || pending) {
385
423
  return;
386
424
  }
387
- /** @type {HTMLTextAreaElement|null} */
388
- const ta = /** @type {any} */ (
425
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
389
426
  mount_element.querySelector('#detail-root textarea')
390
427
  );
391
428
  const prev = current.description || '';
@@ -461,8 +498,7 @@ export function createDetailView(
461
498
  if (!current || pending) {
462
499
  return;
463
500
  }
464
- /** @type {HTMLTextAreaElement|null} */
465
- const ta = /** @type {any} */ (
501
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
466
502
  mount_element.querySelector('#detail-root .design textarea')
467
503
  );
468
504
  const prev = current.design || '';
@@ -528,8 +564,7 @@ export function createDetailView(
528
564
  if (!current || pending) {
529
565
  return;
530
566
  }
531
- /** @type {HTMLTextAreaElement|null} */
532
- const ta = /** @type {any} */ (
567
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
533
568
  mount_element.querySelector('#detail-root .notes textarea')
534
569
  );
535
570
  const prev = current.notes || '';
@@ -594,8 +629,7 @@ export function createDetailView(
594
629
  if (!current || pending) {
595
630
  return;
596
631
  }
597
- /** @type {HTMLTextAreaElement|null} */
598
- const ta = /** @type {any} */ (
632
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
599
633
  mount_element.querySelector('#detail-root .acceptance textarea')
600
634
  );
601
635
  const prev = current.acceptance || '';
@@ -971,12 +1005,7 @@ export function createDetailView(
971
1005
  .value=${/** @type {any} */ (issue).assignee || ''}
972
1006
  size=${Math.min(
973
1007
  40,
974
- Math.max(
975
- 12,
976
- String(
977
- /** @type {any} */ (issue).assignee || ''
978
- ).length + 3
979
- )
1008
+ Math.max(12, (issue.assignee || '').length + 3)
980
1009
  )}
981
1010
  @keydown=${
982
1011
  /** @param {KeyboardEvent} e */ (e) => {
@@ -1005,9 +1034,7 @@ export function createDetailView(
1005
1034
  Cancel
1006
1035
  </button>`
1007
1036
  : html`${(() => {
1008
- const raw = String(
1009
- /** @type {any} */ (issue).assignee || ''
1010
- );
1037
+ const raw = issue.assignee || '';
1011
1038
  const has = raw.trim().length > 0;
1012
1039
  const text = has ? raw : 'Unassigned';
1013
1040
  const cls = has ? 'editable' : 'editable muted';
@@ -1036,7 +1063,7 @@ export function createDetailView(
1036
1063
 
1037
1064
  function doRender() {
1038
1065
  if (!current) {
1039
- renderPlaceholder('No issue selected');
1066
+ renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
1040
1067
  return;
1041
1068
  }
1042
1069
  render(detailTemplate(current), mount_element);
@@ -1045,15 +1072,14 @@ export function createDetailView(
1045
1072
 
1046
1073
  /**
1047
1074
  * Create a click handler for the remove button of a dependency row.
1075
+ *
1048
1076
  * @param {string} did
1049
1077
  * @param {'Dependencies'|'Dependents'} title
1050
1078
  * @returns {(ev: Event) => Promise<void>}
1051
1079
  */
1052
1080
  function makeDepRemoveClick(did, title) {
1053
1081
  return async (ev) => {
1054
- /** @type {Event} */
1055
- const e = ev;
1056
- e.stopPropagation();
1082
+ ev.stopPropagation();
1057
1083
  if (!current || pending) {
1058
1084
  return;
1059
1085
  }
@@ -1090,6 +1116,7 @@ export function createDetailView(
1090
1116
 
1091
1117
  /**
1092
1118
  * Create a click handler for the Add button in a dependency section.
1119
+ *
1093
1120
  * @param {Dependency[]} items
1094
1121
  * @param {'Dependencies'|'Dependents'} title
1095
1122
  * @returns {(ev: Event) => Promise<void>}
@@ -1099,10 +1126,10 @@ export function createDetailView(
1099
1126
  if (!current || pending) {
1100
1127
  return;
1101
1128
  }
1102
- /** @type {HTMLButtonElement} */
1103
- const btn = /** @type {any} */ (ev.currentTarget);
1104
- /** @type {HTMLInputElement|null} */
1105
- const input = /** @type {any} */ (btn.previousElementSibling);
1129
+ const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
1130
+ const input = /** @type {HTMLInputElement|null} */ (
1131
+ btn.previousElementSibling
1132
+ );
1106
1133
  const target = input ? input.value.trim() : '';
1107
1134
  if (!target || target === current.id) {
1108
1135
  showToast('Enter a different issue id');
@@ -1204,28 +1231,14 @@ export function createDetailView(
1204
1231
  renderPlaceholder('No issue selected');
1205
1232
  return;
1206
1233
  }
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;
1234
+ current_id = String(id);
1235
+ // Try from store first; show placeholder while waiting for snapshot
1236
+ current = null;
1237
+ refreshFromStore();
1238
+ if (!current) {
1239
+ renderPlaceholder('Loading…');
1227
1240
  }
1228
- current = issue;
1241
+ // Render from current (if available) or keep placeholder until push arrives
1229
1242
  pending = false;
1230
1243
  doRender();
1231
1244
  },
@@ -1,28 +1,58 @@
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.
17
+ *
13
18
  * @param {HTMLElement} mount_element
14
- * @param {{ getEpicStatus: () => Promise<any[]>, getIssue: (id: string) => Promise<any>, updateIssue: (input: any) => Promise<any> }} data
19
+ * @param {{ updateIssue: (input: any) => Promise<any> }} data
15
20
  * @param {(id: string) => void} goto_issue - Navigate to issue detail.
21
+ * @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]
22
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
16
23
  */
17
- export function createEpicsView(mount_element, data, goto_issue) {
24
+ export function createEpicsView(
25
+ mount_element,
26
+ data,
27
+ goto_issue,
28
+ subscriptions = undefined,
29
+ issue_stores = undefined
30
+ ) {
18
31
  /** @type {any[]} */
19
32
  let groups = [];
20
33
  /** @type {Set<string>} */
21
34
  const expanded = new Set();
22
- /** @type {Map<string, IssueLite[]>} */
23
- const children = new Map();
24
35
  /** @type {Set<string>} */
25
36
  const loading = new Set();
37
+ /** @type {Map<string, () => Promise<void>>} */
38
+ const epic_unsubs = new Map();
39
+ // Centralized selection helpers
40
+ const selectors = issue_stores ? createListSelectors(issue_stores) : null;
41
+ // Live re-render on pushes: recompute groups when stores change
42
+ if (selectors) {
43
+ selectors.subscribe(() => {
44
+ const had_none = groups.length === 0;
45
+ groups = buildGroupsFromSnapshot();
46
+ doRender();
47
+ // Auto-expand first epic when transitioning from empty to non-empty
48
+ if (had_none && groups.length > 0) {
49
+ const first_id = String(groups[0].epic?.id || '');
50
+ if (first_id && !expanded.has(first_id)) {
51
+ void toggle(first_id);
52
+ }
53
+ }
54
+ });
55
+ }
26
56
 
27
57
  // Shared row renderer used for children rows
28
58
  const renderRow = createIssueRowRenderer({
@@ -51,7 +81,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
51
81
  const epic = g.epic || {};
52
82
  const id = String(epic.id || '');
53
83
  const is_open = expanded.has(id);
54
- const list = children.get(id) || [];
84
+ // Compose children via selectors
85
+ const list = selectors ? selectors.selectEpicChildren(id) : [];
55
86
  const is_loading = loading.has(id);
56
87
  return html`
57
88
  <div class="epic-group" data-epic-id=${id}>
@@ -84,7 +115,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
84
115
  ${is_loading
85
116
  ? html`<div class="muted">Loading…</div>`
86
117
  : list.length === 0
87
- ? html`<div class="muted">No open issues</div>`
118
+ ? html`<div class="muted">No issues found</div>`
88
119
  : html`<table class="table">
89
120
  <colgroup>
90
121
  <col style="width: 100px" />
@@ -121,24 +152,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
121
152
  async function updateInline(id, patch) {
122
153
  try {
123
154
  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
- }
155
+ // Re-render; view will update on subsequent push
142
156
  doRender();
143
157
  } catch {
144
158
  // swallow; UI remains
@@ -151,70 +165,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
151
165
  async function toggle(epic_id) {
152
166
  if (!expanded.has(epic_id)) {
153
167
  expanded.add(epic_id);
154
- // Load children if not present
155
- if (!children.has(epic_id)) {
156
- loading.add(epic_id);
157
- doRender();
168
+ loading.add(epic_id);
169
+ doRender();
170
+ // Subscribe to epic detail; children are rendered from `dependents`
171
+ if (subscriptions && typeof subscriptions.subscribeList === 'function') {
158
172
  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
173
+ // Register store first to avoid dropping the initial snapshot
174
+ try {
175
+ if (issue_stores && /** @type {any} */ (issue_stores).register) {
176
+ /** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
177
+ type: 'issue-detail',
178
+ params: { id: epic_id }
179
+ });
183
180
  }
181
+ } catch {
182
+ // ignore
184
183
  }
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;
184
+ const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
185
+ type: 'issue-detail',
186
+ params: { id: epic_id }
197
187
  });
198
- children.set(epic_id, list);
199
- } finally {
200
- loading.delete(epic_id);
188
+ epic_unsubs.set(epic_id, u);
189
+ } catch {
190
+ // ignore subscription failures
201
191
  }
202
192
  }
193
+ // Mark as not loading after subscribe attempt; membership will stream in
194
+ loading.delete(epic_id);
203
195
  } else {
204
196
  expanded.delete(epic_id);
197
+ // Unsubscribe when collapsing
198
+ if (epic_unsubs.has(epic_id)) {
199
+ try {
200
+ const u = epic_unsubs.get(epic_id);
201
+ if (u) {
202
+ await u();
203
+ }
204
+ } catch {
205
+ // ignore
206
+ }
207
+ epic_unsubs.delete(epic_id);
208
+ try {
209
+ if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
210
+ /** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
211
+ }
212
+ } catch {
213
+ // ignore
214
+ }
215
+ }
205
216
  }
206
217
  doRender();
207
218
  }
208
219
 
220
+ /** Build groups from the current `tab:epics` snapshot. */
221
+ function buildGroupsFromSnapshot() {
222
+ /** @type {IssueLite[]} */
223
+ const epic_entities =
224
+ issue_stores && issue_stores.snapshotFor
225
+ ? /** @type {IssueLite[]} */ (
226
+ issue_stores.snapshotFor('tab:epics') || []
227
+ )
228
+ : [];
229
+ const next_groups = [];
230
+ for (const epic of epic_entities) {
231
+ const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
232
+ ? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
233
+ : [];
234
+ // Prefer explicit counters when provided by server; otherwise derive
235
+ const has_total = Number.isFinite(
236
+ /** @type {any} */ (epic).total_children
237
+ );
238
+ const has_closed = Number.isFinite(
239
+ /** @type {any} */ (epic).closed_children
240
+ );
241
+ const total = has_total
242
+ ? Number(/** @type {any} */ (epic).total_children) || 0
243
+ : dependents.length;
244
+ let closed = has_closed
245
+ ? Number(/** @type {any} */ (epic).closed_children) || 0
246
+ : 0;
247
+ if (!has_closed) {
248
+ for (const d of dependents) {
249
+ if (String(d.status || '') === 'closed') {
250
+ closed++;
251
+ }
252
+ }
253
+ }
254
+ next_groups.push({
255
+ epic,
256
+ total_children: total,
257
+ closed_children: closed
258
+ });
259
+ }
260
+ return next_groups;
261
+ }
262
+
209
263
  return {
210
264
  async load() {
211
- const res = await data.getEpicStatus();
212
- groups = Array.isArray(res) ? res : [];
265
+ groups = buildGroupsFromSnapshot();
213
266
  doRender();
214
267
  // Auto-expand first epic on screen
215
268
  try {
216
269
  if (groups.length > 0) {
217
- const first_id = String((groups[0].epic && groups[0].epic.id) || '');
270
+ const first_id = String(groups[0].epic?.id || '');
218
271
  if (first_id && !expanded.has(first_id)) {
219
272
  // This will render and load children lazily
220
273
  await toggle(first_id);
@@ -10,14 +10,14 @@ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
10
10
 
11
11
  /**
12
12
  * Create and manage the Issue Details dialog.
13
+ *
13
14
  * @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
14
15
  * @param {Store} store - Read-only access to app state
15
16
  * @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
16
17
  * @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
17
18
  */
18
19
  export function createIssueDialog(mount_element, store, onClose) {
19
- /** @type {HTMLDialogElement} */
20
- const dialog = /** @type {any} */ (document.createElement('dialog'));
20
+ const dialog = document.createElement('dialog');
21
21
  dialog.id = 'issue-dialog';
22
22
  dialog.setAttribute('role', 'dialog');
23
23
  dialog.setAttribute('aria-modal', 'true');
@@ -37,16 +37,13 @@ export function createIssueDialog(mount_element, store, onClose) {
37
37
 
38
38
  mount_element.appendChild(dialog);
39
39
 
40
- /** @type {HTMLElement} */
41
- const body_mount = /** @type {any} */ (
40
+ const body_mount = /** @type {HTMLElement} */ (
42
41
  dialog.querySelector('#issue-dialog-body')
43
42
  );
44
- /** @type {HTMLElement} */
45
- const title_el = /** @type {any} */ (
43
+ const title_el = /** @type {HTMLElement} */ (
46
44
  dialog.querySelector('#issue-dialog-title')
47
45
  );
48
- /** @type {HTMLButtonElement} */
49
- const btn_close = /** @type {any} */ (
46
+ const btn_close = /** @type {HTMLButtonElement} */ (
50
47
  dialog.querySelector('.issue-dialog__close')
51
48
  );
52
49
 
@@ -56,7 +53,7 @@ export function createIssueDialog(mount_element, store, onClose) {
56
53
  function setTitle(id) {
57
54
  // Use copyable ID renderer but keep visible text as raw id for tests/clarity
58
55
  title_el.replaceChildren();
59
- title_el.appendChild(createIssueIdRenderer(String(id || '')));
56
+ title_el.appendChild(createIssueIdRenderer(id));
60
57
  }
61
58
 
62
59
  // Backdrop click: when clicking the dialog itself (outside container), close
@@ -102,7 +99,7 @@ export function createIssueDialog(mount_element, store, onClose) {
102
99
  function open(id) {
103
100
  // Capture currently focused element to restore after closing
104
101
  try {
105
- const ae = /** @type {any} */ (document.activeElement);
102
+ const ae = document.activeElement;
106
103
  if (ae && ae instanceof HTMLElement) {
107
104
  last_focus = ae;
108
105
  } else {
@@ -113,11 +110,8 @@ export function createIssueDialog(mount_element, store, onClose) {
113
110
  }
114
111
  setTitle(id);
115
112
  try {
116
- if (
117
- 'showModal' in dialog &&
118
- typeof (/** @type {any} */ (dialog).showModal) === 'function'
119
- ) {
120
- /** @type {any} */ (dialog).showModal();
113
+ if ('showModal' in dialog && typeof dialog.showModal === 'function') {
114
+ dialog.showModal();
121
115
  } else {
122
116
  dialog.setAttribute('open', '');
123
117
  }
@@ -12,6 +12,7 @@ import { createTypeBadge } from '../utils/type-badge.js';
12
12
  /**
13
13
  * Create a reusable issue row renderer used by list and epics views.
14
14
  * Handles inline editing for title/assignee and selects for status/priority.
15
+ *
15
16
  * @param {{
16
17
  * navigate: (id: string) => void,
17
18
  * onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
@@ -38,7 +39,6 @@ export function createIssueRowRenderer(options) {
38
39
  * @param {string} [placeholder]
39
40
  */
40
41
  function editableText(id, key, value, placeholder = '') {
41
- /** @type {string} */
42
42
  const k = `${id}:${key}`;
43
43
  const is_edit = editing.has(k);
44
44
  if (is_edit) {
@@ -111,8 +111,7 @@ export function createIssueRowRenderer(options) {
111
111
  */
112
112
  function makeSelectChange(id, key) {
113
113
  return async (ev) => {
114
- /** @type {HTMLSelectElement} */
115
- const sel = /** @type {any} */ (ev.currentTarget);
114
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
116
115
  const val = sel.value || '';
117
116
  /** @type {{ [k:string]: any }} */
118
117
  const patch = {};