beads-ui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGES.md +26 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -16
  7. package/server/app.js +39 -35
  8. package/server/bd.js +6 -2
  9. package/server/cli/commands.js +12 -8
  10. package/server/cli/daemon.js +20 -5
  11. package/server/cli/index.js +19 -31
  12. package/server/cli/open.js +3 -0
  13. package/server/cli/usage.js +4 -2
  14. package/server/config.js +3 -2
  15. package/server/db.js +9 -6
  16. package/server/index.js +10 -4
  17. package/server/list-adapters.js +9 -3
  18. package/server/logging.js +23 -0
  19. package/server/subscriptions.js +12 -0
  20. package/server/validators.js +2 -0
  21. package/server/watcher.js +10 -5
  22. package/server/ws.js +31 -10
  23. package/app/data/list-selectors.js +0 -98
  24. package/app/data/providers.js +0 -76
  25. package/app/data/sort.js +0 -45
  26. package/app/data/subscription-issue-store.js +0 -161
  27. package/app/data/subscription-issue-stores.js +0 -102
  28. package/app/data/subscriptions-store.js +0 -219
  29. package/app/main.js +0 -702
  30. package/app/protocol.js +0 -196
  31. package/app/protocol.md +0 -66
  32. package/app/router.js +0 -114
  33. package/app/state.js +0 -103
  34. package/app/utils/issue-id-renderer.js +0 -71
  35. package/app/utils/issue-id.js +0 -10
  36. package/app/utils/issue-type.js +0 -27
  37. package/app/utils/issue-url.js +0 -9
  38. package/app/utils/markdown.js +0 -22
  39. package/app/utils/priority-badge.js +0 -47
  40. package/app/utils/priority.js +0 -1
  41. package/app/utils/status-badge.js +0 -32
  42. package/app/utils/status.js +0 -23
  43. package/app/utils/toast.js +0 -34
  44. package/app/utils/type-badge.js +0 -33
  45. package/app/views/board.js +0 -535
  46. package/app/views/detail.js +0 -1249
  47. package/app/views/epics.js +0 -280
  48. package/app/views/issue-dialog.js +0 -163
  49. package/app/views/issue-row.js +0 -190
  50. package/app/views/list.js +0 -464
  51. package/app/views/nav.js +0 -67
  52. package/app/views/new-issue-dialog.js +0 -345
  53. package/app/ws.js +0 -279
  54. package/docs/adr/001-push-only-lists.md +0 -134
  55. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  56. package/docs/architecture.md +0 -194
  57. package/docs/data-exchange-subscription-plan.md +0 -198
  58. package/docs/db-watching.md +0 -30
  59. package/docs/migration-v2.md +0 -54
  60. package/docs/protocol/issues-push-v2.md +0 -179
  61. package/docs/subscription-issue-store.md +0 -112
@@ -1,345 +0,0 @@
1
- import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
2
- import { priority_levels } from '../utils/priority.js';
3
-
4
- /**
5
- * Create and manage the New Issue dialog (native <dialog>).
6
- * @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
7
- * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
8
- * @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
9
- * @param {{ setState: (patch: any) => void, getState: () => any }} [store]
10
- * @returns {{ open: () => void, close: () => void }}
11
- */
12
- export function createNewIssueDialog(mount_element, sendFn, router, store) {
13
- const dialog = /** @type {HTMLDialogElement} */ (
14
- document.createElement('dialog')
15
- );
16
- dialog.id = 'new-issue-dialog';
17
- dialog.setAttribute('role', 'dialog');
18
- dialog.setAttribute('aria-modal', 'true');
19
-
20
- dialog.innerHTML = `
21
- <div class="new-issue__container" part="container">
22
- <header class="new-issue__header">
23
- <div class="new-issue__title">New Issue</div>
24
- <button type="button" class="new-issue__close" aria-label="Close">×</button>
25
- </header>
26
- <div class="new-issue__body">
27
- <form id="new-issue-form" class="new-issue__form">
28
- <label for="new-title">Title</label>
29
- <input id="new-title" name="title" type="text" required placeholder="Short summary" />
30
-
31
- <label for="new-type">Type</label>
32
- <select id="new-type" name="type" aria-label="Issue type"></select>
33
-
34
- <label for="new-priority">Priority</label>
35
- <select id="new-priority" name="priority" aria-label="Priority"></select>
36
-
37
- <label for="new-labels">Labels</label>
38
- <input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
39
-
40
- <label for="new-description">Description</label>
41
- <textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
42
-
43
- <div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
44
-
45
- <div class="new-issue__actions" style="grid-column: 1 / -1">
46
- <button type="button" id="btn-cancel">Cancel (Esc)</button>
47
- <button type="submit" id="btn-create">Create</button>
48
- </div>
49
- </form>
50
- </div>
51
- </div>
52
- `;
53
-
54
- mount_element.appendChild(dialog);
55
-
56
- const form = /** @type {HTMLFormElement} */ (
57
- dialog.querySelector('#new-issue-form')
58
- );
59
- const input_title = /** @type {HTMLInputElement} */ (
60
- dialog.querySelector('#new-title')
61
- );
62
- const sel_type = /** @type {HTMLSelectElement} */ (
63
- dialog.querySelector('#new-type')
64
- );
65
- const sel_priority = /** @type {HTMLSelectElement} */ (
66
- dialog.querySelector('#new-priority')
67
- );
68
- const input_labels = /** @type {HTMLInputElement} */ (
69
- dialog.querySelector('#new-labels')
70
- );
71
- const input_description = /** @type {HTMLTextAreaElement} */ (
72
- dialog.querySelector('#new-description')
73
- );
74
- const error_box = /** @type {HTMLDivElement} */ (
75
- dialog.querySelector('#new-issue-error')
76
- );
77
- const btn_cancel = /** @type {HTMLButtonElement} */ (
78
- dialog.querySelector('#btn-cancel')
79
- );
80
- const btn_create = /** @type {HTMLButtonElement} */ (
81
- dialog.querySelector('#btn-create')
82
- );
83
- const btn_close = /** @type {HTMLButtonElement} */ (
84
- dialog.querySelector('.new-issue__close')
85
- );
86
-
87
- // Populate selects
88
- function populateSelects() {
89
- sel_type.replaceChildren();
90
- // Empty option to allow leaving type unspecified
91
- const optEmpty = document.createElement('option');
92
- optEmpty.value = '';
93
- optEmpty.textContent = '— Select —';
94
- sel_type.appendChild(optEmpty);
95
- for (const t of ISSUE_TYPES) {
96
- const o = document.createElement('option');
97
- o.value = t;
98
- o.textContent = typeLabel(t);
99
- sel_type.appendChild(o);
100
- }
101
-
102
- sel_priority.replaceChildren();
103
- for (let i = 0; i <= 4; i += 1) {
104
- const o = document.createElement('option');
105
- o.value = String(i);
106
- const label = priority_levels[i] || 'Medium';
107
- o.textContent = `${i} – ${label}`;
108
- sel_priority.appendChild(o);
109
- }
110
- }
111
- populateSelects();
112
-
113
- function requestClose() {
114
- try {
115
- if (typeof dialog.close === 'function') {
116
- dialog.close();
117
- } else {
118
- dialog.removeAttribute('open');
119
- }
120
- } catch {
121
- dialog.removeAttribute('open');
122
- }
123
- }
124
-
125
- /**
126
- * @param {boolean} is_busy
127
- */
128
- function setBusy(is_busy) {
129
- input_title.disabled = is_busy;
130
- sel_type.disabled = is_busy;
131
- sel_priority.disabled = is_busy;
132
- input_labels.disabled = is_busy;
133
- input_description.disabled = is_busy;
134
- btn_cancel.disabled = is_busy;
135
- btn_create.disabled = is_busy;
136
- btn_create.textContent = is_busy ? 'Creating…' : 'Create';
137
- }
138
-
139
- function clearError() {
140
- error_box.textContent = '';
141
- }
142
-
143
- /**
144
- * @param {string} msg
145
- */
146
- function setError(msg) {
147
- error_box.textContent = msg;
148
- }
149
-
150
- function loadDefaults() {
151
- try {
152
- const t = window.localStorage.getItem('beads-ui.new.type');
153
- if (t) {
154
- sel_type.value = t;
155
- } else {
156
- sel_type.value = '';
157
- }
158
- const p = window.localStorage.getItem('beads-ui.new.priority');
159
- if (p && /^\d$/.test(p)) {
160
- sel_priority.value = p;
161
- } else {
162
- sel_priority.value = '2';
163
- }
164
- } catch {
165
- sel_type.value = '';
166
- sel_priority.value = '2';
167
- }
168
- }
169
-
170
- function saveDefaults() {
171
- try {
172
- const t = sel_type.value || '';
173
- const p = sel_priority.value || '';
174
- if (t.length > 0) {
175
- window.localStorage.setItem('beads-ui.new.type', t);
176
- }
177
- if (p.length > 0) {
178
- window.localStorage.setItem('beads-ui.new.priority', p);
179
- }
180
- } catch {
181
- // ignore persistence errors
182
- }
183
- }
184
-
185
- /**
186
- * Extract numeric suffix from an id like "UI-123"; return -1 when absent.
187
- * @param {string} id
188
- */
189
- function idNumeric(id) {
190
- const m = /-(\d+)$/.exec(String(id || ''));
191
- return m && m[1] ? Number(m[1]) : -1;
192
- }
193
-
194
- /**
195
- * Submit handler: validate, create, then open the created issue details.
196
- * @returns {Promise<void>}
197
- */
198
- async function createNow() {
199
- clearError();
200
- const title = String(input_title.value || '').trim();
201
- if (title.length === 0) {
202
- setError('Title is required');
203
- input_title.focus();
204
- return;
205
- }
206
- const prio = Number(sel_priority.value || '2');
207
- if (!(prio >= 0 && prio <= 4)) {
208
- setError('Priority must be 0..4');
209
- sel_priority.focus();
210
- return;
211
- }
212
- const type = String(sel_type.value || '');
213
- const desc = String(input_description.value || '');
214
- const labels = String(input_labels.value || '')
215
- .split(',')
216
- .map((s) => s.trim())
217
- .filter((s) => s.length > 0);
218
-
219
- /** @type {{ title: string, type?: string, priority?: number, description?: string }} */
220
- const payload = { title };
221
- if (type.length > 0) {
222
- payload.type = type;
223
- }
224
- if (String(prio).length > 0) {
225
- payload.priority = prio;
226
- }
227
- if (desc.length > 0) {
228
- payload.description = desc;
229
- }
230
-
231
- setBusy(true);
232
- try {
233
- await sendFn('create-issue', payload);
234
- } catch {
235
- setBusy(false);
236
- setError('Failed to create issue');
237
- return;
238
- }
239
-
240
- saveDefaults();
241
-
242
- // Best-effort: find the created id by matching title among open issues and picking the highest numeric id
243
- /** @type {any} */
244
- let list = null;
245
- try {
246
- list = await sendFn('list-issues', {
247
- filters: { status: 'open', limit: 50 }
248
- });
249
- } catch {
250
- list = null;
251
- }
252
- let created_id = '';
253
- if (Array.isArray(list)) {
254
- const matches = list.filter((it) => String(it.title || '') === title);
255
- if (matches.length > 0) {
256
- /** @type {any} */
257
- let best = matches[0];
258
- for (const it of matches) {
259
- const ai = idNumeric(best.id || '');
260
- const bi = idNumeric(it.id || '');
261
- if (bi > ai) {
262
- best = it;
263
- }
264
- }
265
- created_id = String(best.id || '');
266
- }
267
- }
268
-
269
- // Apply labels if any
270
- if (created_id && labels.length > 0) {
271
- for (const label of labels) {
272
- try {
273
- await sendFn('label-add', { id: created_id, label });
274
- } catch {
275
- // ignore label failures
276
- }
277
- }
278
- }
279
-
280
- // Navigate to created issue if found
281
- if (created_id) {
282
- try {
283
- router.gotoIssue(created_id);
284
- } catch {
285
- // ignore routing errors
286
- }
287
- // Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
288
- try {
289
- if (store) {
290
- store.setState({ selected_id: created_id });
291
- }
292
- } catch {
293
- // ignore
294
- }
295
- }
296
-
297
- setBusy(false);
298
- requestClose();
299
- }
300
-
301
- // Events
302
- dialog.addEventListener('cancel', (ev) => {
303
- ev.preventDefault();
304
- requestClose();
305
- });
306
- btn_close.addEventListener('click', () => requestClose());
307
- btn_cancel.addEventListener('click', () => requestClose());
308
- dialog.addEventListener('keydown', (ev) => {
309
- if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
310
- ev.preventDefault();
311
- void createNow();
312
- }
313
- });
314
- form.addEventListener('submit', (ev) => {
315
- ev.preventDefault();
316
- void createNow();
317
- });
318
-
319
- return {
320
- open() {
321
- form.reset();
322
- clearError();
323
- loadDefaults();
324
- try {
325
- if ('showModal' in dialog && typeof dialog.showModal === 'function') {
326
- dialog.showModal();
327
- } else {
328
- dialog.setAttribute('open', '');
329
- }
330
- } catch {
331
- dialog.setAttribute('open', '');
332
- }
333
- setTimeout(() => {
334
- try {
335
- input_title.focus();
336
- } catch {
337
- // ignore
338
- }
339
- }, 0);
340
- },
341
- close() {
342
- requestClose();
343
- }
344
- };
345
- }
package/app/ws.js DELETED
@@ -1,279 +0,0 @@
1
- /* global Console */
2
- /**
3
- * @import { MessageType } from './protocol.js'
4
- */
5
- /**
6
- * Persistent WebSocket client with reconnect, request/response correlation,
7
- * and simple event dispatching.
8
- *
9
- * Usage:
10
- * const ws = createWsClient();
11
- * const data = await ws.send('list-issues', { filters: {} });
12
- * const off = ws.on('snapshot', (payload) => { <push event> });
13
- */
14
- import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
15
-
16
- /**
17
- * @typedef {'connecting'|'open'|'closed'|'reconnecting'} ConnectionState
18
- */
19
-
20
- /**
21
- * @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitterRatio?: number }} BackoffOptions
22
- */
23
-
24
- /**
25
- * @typedef {{ url?: string, backoff?: BackoffOptions, logger?: Console }} ClientOptions
26
- */
27
-
28
- /**
29
- * Create a WebSocket client with auto-reconnect and message correlation.
30
- * @param {ClientOptions} [options]
31
- */
32
- export function createWsClient(options = {}) {
33
- /** @type {Console} */
34
- const logger = options.logger || console;
35
-
36
- /** @type {BackoffOptions} */
37
- const backoff = {
38
- initialMs: options.backoff?.initialMs ?? 1000,
39
- maxMs: options.backoff?.maxMs ?? 30000,
40
- factor: options.backoff?.factor ?? 2,
41
- jitterRatio: options.backoff?.jitterRatio ?? 0.2
42
- };
43
-
44
- /** @type {() => string} */
45
- const resolveUrl = () => {
46
- if (options.url && options.url.length > 0) {
47
- return options.url;
48
- }
49
- if (typeof location !== 'undefined') {
50
- return (
51
- (location.protocol === 'https:' ? 'wss://' : 'ws://') +
52
- location.host +
53
- '/ws'
54
- );
55
- }
56
- return 'ws://localhost/ws';
57
- };
58
-
59
- /** @type {WebSocket | null} */
60
- let ws = null;
61
- /** @type {ConnectionState} */
62
- let state = 'closed';
63
- /** @type {number} */
64
- let attempts = 0;
65
- /** @type {ReturnType<typeof setTimeout> | null} */
66
- let reconnect_timer = null;
67
- /** @type {boolean} */
68
- let should_reconnect = true;
69
-
70
- /** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void, type: string }>} */
71
- const pending = new Map();
72
- /** @type {Array<ReturnType<typeof makeRequest>>} */
73
- const queue = [];
74
- /** @type {Map<string, Set<(payload: any) => void>>} */
75
- const handlers = new Map();
76
- /** @type {Set<(s: ConnectionState) => void>} */
77
- const connection_handlers = new Set();
78
-
79
- /**
80
- * @param {ConnectionState} s
81
- */
82
- function notifyConnection(s) {
83
- for (const fn of Array.from(connection_handlers)) {
84
- try {
85
- fn(s);
86
- } catch {
87
- // ignore listener errors
88
- }
89
- }
90
- }
91
-
92
- function scheduleReconnect() {
93
- if (!should_reconnect || reconnect_timer) {
94
- return;
95
- }
96
- state = 'reconnecting';
97
- notifyConnection(state);
98
- const base = Math.min(
99
- backoff.maxMs || 0,
100
- (backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
101
- );
102
- const jitter = (backoff.jitterRatio || 0) * base;
103
- const delay = Math.max(
104
- 0,
105
- Math.round(base + (Math.random() * 2 - 1) * jitter)
106
- );
107
- reconnect_timer = setTimeout(() => {
108
- reconnect_timer = null;
109
- connect();
110
- }, delay);
111
- }
112
-
113
- /** @param {ReturnType<typeof makeRequest>} req */
114
- function sendRaw(req) {
115
- try {
116
- ws?.send(JSON.stringify(req));
117
- } catch (err) {
118
- logger.error('ws send failed', err);
119
- }
120
- }
121
-
122
- function onOpen() {
123
- state = 'open';
124
- notifyConnection(state);
125
- attempts = 0;
126
- // flush queue
127
- while (queue.length) {
128
- const req = queue.shift();
129
- if (req) {
130
- sendRaw(req);
131
- }
132
- }
133
- }
134
-
135
- /** @param {MessageEvent} ev */
136
- function onMessage(ev) {
137
- /** @type {any} */
138
- let msg;
139
- try {
140
- msg = JSON.parse(String(ev.data));
141
- } catch {
142
- logger.warn('ws received non-JSON message');
143
- return;
144
- }
145
- if (!msg || typeof msg.id !== 'string' || typeof msg.type !== 'string') {
146
- logger.warn('ws received invalid envelope');
147
- return;
148
- }
149
-
150
- if (pending.has(msg.id)) {
151
- const entry = pending.get(msg.id);
152
- pending.delete(msg.id);
153
- if (msg.ok) {
154
- entry?.resolve(msg.payload);
155
- } else {
156
- entry?.reject(msg.error || new Error('ws error'));
157
- }
158
- return;
159
- }
160
-
161
- // Treat as server-initiated event
162
- const set = handlers.get(msg.type);
163
- if (set && set.size > 0) {
164
- for (const fn of Array.from(set)) {
165
- try {
166
- fn(msg.payload);
167
- } catch (err) {
168
- logger.error('ws event handler error', err);
169
- }
170
- }
171
- } else {
172
- logger.warn(`ws received unhandled message type: ${msg.type}`);
173
- }
174
- }
175
-
176
- function onClose() {
177
- state = 'closed';
178
- notifyConnection(state);
179
- // fail all pending
180
- for (const [id, p] of pending.entries()) {
181
- p.reject(new Error('ws disconnected'));
182
- pending.delete(id);
183
- }
184
- attempts += 1;
185
- scheduleReconnect();
186
- }
187
-
188
- function connect() {
189
- if (!should_reconnect) {
190
- return;
191
- }
192
- const url = resolveUrl();
193
- try {
194
- ws = new WebSocket(url);
195
- state = 'connecting';
196
- notifyConnection(state);
197
- ws.addEventListener('open', onOpen);
198
- ws.addEventListener('message', onMessage);
199
- ws.addEventListener('error', () => {
200
- // let close handler handle reconnect
201
- });
202
- ws.addEventListener('close', onClose);
203
- } catch (err) {
204
- logger.error('ws connect failed', err);
205
- scheduleReconnect();
206
- }
207
- }
208
-
209
- connect();
210
-
211
- return {
212
- /**
213
- * Send a request and await its correlated reply payload.
214
- * @param {MessageType} type
215
- * @param {unknown} [payload]
216
- * @returns {Promise<any>}
217
- */
218
- send(type, payload) {
219
- if (!MESSAGE_TYPES.includes(type)) {
220
- return Promise.reject(new Error(`unknown message type: ${type}`));
221
- }
222
- const id = nextId();
223
- const req = makeRequest(type, payload, id);
224
- return new Promise((resolve, reject) => {
225
- pending.set(id, { resolve, reject, type });
226
- if (ws && ws.readyState === ws.OPEN) {
227
- sendRaw(req);
228
- } else {
229
- queue.push(req);
230
- }
231
- });
232
- },
233
- /**
234
- * Register a handler for a server-initiated event type.
235
- * Returns an unsubscribe function.
236
- * @param {MessageType} type
237
- * @param {(payload: any) => void} handler
238
- * @returns {() => void}
239
- */
240
- on(type, handler) {
241
- if (!handlers.has(type)) {
242
- handlers.set(type, new Set());
243
- }
244
- const set = handlers.get(type);
245
- set?.add(handler);
246
- return () => {
247
- set?.delete(handler);
248
- };
249
- },
250
- /**
251
- * Subscribe to connection state changes.
252
- * @param {(state: ConnectionState) => void} handler
253
- * @returns {() => void}
254
- */
255
- onConnection(handler) {
256
- connection_handlers.add(handler);
257
- return () => {
258
- connection_handlers.delete(handler);
259
- };
260
- },
261
- /** Close and stop reconnecting. */
262
- close() {
263
- should_reconnect = false;
264
- if (reconnect_timer) {
265
- clearTimeout(reconnect_timer);
266
- reconnect_timer = null;
267
- }
268
- try {
269
- ws?.close();
270
- } catch {
271
- /* ignore */
272
- }
273
- },
274
- /** For diagnostics in tests or UI. */
275
- getState() {
276
- return state;
277
- }
278
- };
279
- }