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
@@ -3,6 +3,7 @@ import { runBdJson } from './bd.js';
3
3
  /**
4
4
  * Build concrete `bd` CLI args for a subscription type + params.
5
5
  * Always includes `--json` for parseable output.
6
+ *
6
7
  * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
7
8
  * @returns {string[]}
8
9
  */
@@ -43,9 +44,10 @@ export function mapSubscriptionToBdArgs(spec) {
43
44
 
44
45
  /**
45
46
  * Normalize bd list output to minimal Issue shape used by the registry.
46
- * - Ensures `id` is a string
47
- * - Coerces timestamps to numbers
48
- * - `closed_at` defaults to null when missing or invalid
47
+ * - Ensures `id` is a string.
48
+ * - Coerces timestamps to numbers.
49
+ * - `closed_at` defaults to null when missing or invalid.
50
+ *
49
51
  * @param {unknown} value
50
52
  * @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
51
53
  */
@@ -95,6 +97,7 @@ export function normalizeIssueList(value) {
95
97
  /**
96
98
  * Execute the mapped `bd` command for a subscription spec and return normalized items.
97
99
  * Errors do not throw; they are surfaced as a structured object.
100
+ *
98
101
  * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
99
102
  * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
100
103
  */
@@ -171,6 +174,7 @@ export async function fetchListForSubscription(spec) {
171
174
 
172
175
  /**
173
176
  * Create a `bad_request` error object.
177
+ *
174
178
  * @param {string} message
175
179
  */
176
180
  function badRequest(message) {
@@ -182,6 +186,7 @@ function badRequest(message) {
182
186
 
183
187
  /**
184
188
  * Normalize arbitrary thrown values to a structured error object.
189
+ *
185
190
  * @param {unknown} err
186
191
  * @returns {FetchListResultFailure['error']}
187
192
  */
@@ -199,6 +204,7 @@ function toErrorObject(err) {
199
204
  /**
200
205
  * Parse a bd timestamp string to epoch ms using Date.parse.
201
206
  * Falls back to numeric coercion when parsing fails.
207
+ *
202
208
  * @param {unknown} v
203
209
  * @returns {number}
204
210
  */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Debug logger helper for Node server/CLI.
3
+ */
4
+ import createDebug from 'debug';
5
+
6
+ /**
7
+ * Create a namespaced logger for Node runtime.
8
+ *
9
+ * @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
10
+ */
11
+ export function debug(ns) {
12
+ return createDebug(`beads-ui:${ns}`);
13
+ }
14
+
15
+ /**
16
+ * Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
17
+ * Safe to call multiple times.
18
+ */
19
+ export function enableAllDebug() {
20
+ // `debug` exposes a global enable/disable API.
21
+ // Enabling after loggers are created updates their `.enabled` state.
22
+ createDebug.enable(process.env.DEBUG || 'beads-ui:*');
23
+ }
@@ -36,6 +36,7 @@
36
36
 
37
37
  /**
38
38
  * Create a new, empty entry object.
39
+ *
39
40
  * @returns {Entry}
40
41
  */
41
42
  function createEntry() {
@@ -49,6 +50,7 @@ function createEntry() {
49
50
 
50
51
  /**
51
52
  * Generate a stable subscription key string from a spec. Sorts params keys.
53
+ *
52
54
  * @param {SubscriptionSpec} spec
53
55
  * @returns {string}
54
56
  */
@@ -69,6 +71,7 @@ export function keyOf(spec) {
69
71
 
70
72
  /**
71
73
  * Compute a delta between previous and next item maps.
74
+ *
72
75
  * @param {Map<string, ItemMeta>} prev
73
76
  * @param {Map<string, ItemMeta>} next
74
77
  * @returns {{ added: string[], updated: string[], removed: string[] }}
@@ -101,6 +104,7 @@ export function computeDelta(prev, next) {
101
104
 
102
105
  /**
103
106
  * Normalize array of issue-like objects into an itemsById map.
107
+ *
104
108
  * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
105
109
  * @returns {Map<string, ItemMeta>}
106
110
  */
@@ -136,6 +140,7 @@ export class SubscriptionRegistry {
136
140
 
137
141
  /**
138
142
  * Get an entry by key, or null if missing.
143
+ *
139
144
  * @param {string} key
140
145
  * @returns {Entry | null}
141
146
  */
@@ -145,6 +150,7 @@ export class SubscriptionRegistry {
145
150
 
146
151
  /**
147
152
  * Ensure an entry exists for a spec; returns the key and entry.
153
+ *
148
154
  * @param {SubscriptionSpec} spec
149
155
  * @returns {{ key: string, entry: Entry }}
150
156
  */
@@ -160,6 +166,7 @@ export class SubscriptionRegistry {
160
166
 
161
167
  /**
162
168
  * Attach a subscriber to a spec. Creates the entry if missing.
169
+ *
163
170
  * @param {SubscriptionSpec} spec
164
171
  * @param {WebSocket} ws
165
172
  * @returns {{ key: string, subscribed: true }}
@@ -173,6 +180,7 @@ export class SubscriptionRegistry {
173
180
  /**
174
181
  * Detach a subscriber from the spec. Keeps entry even if empty; eviction
175
182
  * is handled by `onDisconnect` sweep.
183
+ *
176
184
  * @param {SubscriptionSpec} spec
177
185
  * @param {WebSocket} ws
178
186
  * @returns {boolean} true when the subscriber was removed
@@ -189,6 +197,7 @@ export class SubscriptionRegistry {
189
197
  /**
190
198
  * On socket disconnect, remove it from all subscriber sets and evict any
191
199
  * entries that become empty as a result of this sweep.
200
+ *
192
201
  * @param {WebSocket} ws
193
202
  */
194
203
  onDisconnect(ws) {
@@ -207,6 +216,7 @@ export class SubscriptionRegistry {
207
216
 
208
217
  /**
209
218
  * Serialize a function against a key so only one runs at a time per key.
219
+ *
210
220
  * @template T
211
221
  * @param {string} key
212
222
  * @param {() => Promise<T>} fn
@@ -243,6 +253,7 @@ export class SubscriptionRegistry {
243
253
 
244
254
  /**
245
255
  * Replace items for a key and compute the delta, storing the new map.
256
+ *
246
257
  * @param {string} key
247
258
  * @param {Map<string, ItemMeta>} next_map
248
259
  * @returns {{ added: string[], updated: string[], removed: string[] }}
@@ -261,6 +272,7 @@ export class SubscriptionRegistry {
261
272
 
262
273
  /**
263
274
  * Convenience: update items from an array of objects with id/updated_at/closed_at.
275
+ *
264
276
  * @param {string} key
265
277
  * @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
266
278
  * @returns {{ added: string[], updated: string[], removed: string[] }}
@@ -6,6 +6,7 @@
6
6
 
7
7
  /**
8
8
  * Known subscription types supported by the server.
9
+ *
9
10
  * @type {Set<string>}
10
11
  */
11
12
  const SUBSCRIPTION_TYPES = new Set([
@@ -20,6 +21,7 @@ const SUBSCRIPTION_TYPES = new Set([
20
21
 
21
22
  /**
22
23
  * Validate a subscribe-list payload and normalize to a SubscriptionSpec.
24
+ *
23
25
  * @param {unknown} payload
24
26
  * @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
25
27
  */
package/server/watcher.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveDbPath } from './db.js';
4
+ import { debug } from './logging.js';
4
5
 
5
6
  /**
6
7
  * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
7
8
  * The DB path is resolved following beads precedence and can be overridden via options.
9
+ *
8
10
  * @param {string} root_dir - Project root directory (starting point for resolution).
9
11
  * @param {() => void} onChange - Called when changes are detected.
10
12
  * @param {{ debounce_ms?: number, explicit_db?: string }} [options]
@@ -12,6 +14,7 @@ import { resolveDbPath } from './db.js';
12
14
  */
13
15
  export function watchDb(root_dir, onChange, options = {}) {
14
16
  const debounce_ms = options.debounce_ms ?? 250;
17
+ const log = debug('watcher');
15
18
 
16
19
  /** @type {ReturnType<typeof setTimeout> | undefined} */
17
20
  let timer;
@@ -33,6 +36,7 @@ export function watchDb(root_dir, onChange, options = {}) {
33
36
 
34
37
  /**
35
38
  * Attach a watcher to the directory containing the resolved DB path.
39
+ *
36
40
  * @param {string} base_dir
37
41
  * @param {string | undefined} explicit_db
38
42
  */
@@ -42,10 +46,9 @@ export function watchDb(root_dir, onChange, options = {}) {
42
46
  current_dir = path.dirname(current_path);
43
47
  current_file = path.basename(current_path);
44
48
  if (!resolved.exists) {
45
- console.warn(
46
- 'watchDb: resolved DB does not exist yet:',
47
- current_path,
48
- '\nHint: set --db, export BEADS_DB, or run `bd init` in your workspace.'
49
+ log(
50
+ 'resolved DB missing: %s Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
51
+ current_path
49
52
  );
50
53
  }
51
54
 
@@ -59,12 +62,13 @@ export function watchDb(root_dir, onChange, options = {}) {
59
62
  return;
60
63
  }
61
64
  if (event_type === 'change' || event_type === 'rename') {
65
+ log('fs %s %s', event_type, filename || '');
62
66
  schedule();
63
67
  }
64
68
  }
65
69
  );
66
70
  } catch (err) {
67
- console.warn('watchDb: unable to watch directory', current_dir, err);
71
+ log('unable to watch directory %s %o', current_dir, err);
68
72
  }
69
73
  };
70
74
 
@@ -84,6 +88,7 @@ export function watchDb(root_dir, onChange, options = {}) {
84
88
  },
85
89
  /**
86
90
  * Re-resolve and reattach watcher when root_dir or explicit_db changes.
91
+ *
87
92
  * @param {{ root_dir?: string, explicit_db?: string }} [opts]
88
93
  */
89
94
  rebind(opts = {}) {
package/server/ws.js CHANGED
@@ -6,10 +6,13 @@
6
6
  import { WebSocketServer } from 'ws';
7
7
  import { runBd, runBdJson } from './bd.js';
8
8
  import { fetchListForSubscription } from './list-adapters.js';
9
+ import { debug } from './logging.js';
9
10
  import { isRequest, makeError, makeOk } from './protocol.js';
10
11
  import { keyOf, registry } from './subscriptions.js';
11
12
  import { validateSubscribeListPayload } from './validators.js';
12
13
 
14
+ const log = debug('ws');
15
+
13
16
  /**
14
17
  * Debounced refresh scheduling for active list subscriptions.
15
18
  * A trailing window coalesces rapid change bursts into a single refresh run.
@@ -40,6 +43,7 @@ let MUTATION_GATE = null;
40
43
  * suppressed during the window.
41
44
  *
42
45
  * Fire-and-forget; callers should not await this.
46
+ *
43
47
  * @param {number} [timeout_ms]
44
48
  */
45
49
  function triggerMutationRefreshOnce(timeout_ms = 500) {
@@ -76,6 +80,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
76
80
 
77
81
  // After resolution, run a single refresh across active subs and clear gate
78
82
  void p.then(async () => {
83
+ log('mutation window resolved → refresh active subs');
79
84
  try {
80
85
  await refreshAllActiveListSubscriptions();
81
86
  } catch {
@@ -95,6 +100,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
95
100
 
96
101
  /**
97
102
  * Collect unique active list subscription specs across all connected clients.
103
+ *
98
104
  * @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
99
105
  */
100
106
  function collectActiveListSpecs() {
@@ -181,6 +187,7 @@ let CURRENT_WSS = null;
181
187
 
182
188
  /**
183
189
  * Get or initialize the subscription state for a socket.
190
+ *
184
191
  * @param {WebSocket} ws
185
192
  * @returns {any}
186
193
  */
@@ -199,6 +206,7 @@ function ensureSubs(ws) {
199
206
 
200
207
  /**
201
208
  * Get next monotonically increasing revision for a subscription key on this connection.
209
+ *
202
210
  * @param {WebSocket} ws
203
211
  * @param {string} key
204
212
  */
@@ -306,6 +314,7 @@ function emitSubscriptionDelete(ws, client_id, key, issue_id) {
306
314
  /**
307
315
  * Refresh a subscription spec: fetch via adapter, apply to registry and emit
308
316
  * per-subscription full-issue envelopes to subscribers. Serialized per key.
317
+ *
309
318
  * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
310
319
  */
311
320
  async function refreshAndPublish(spec) {
@@ -316,17 +325,17 @@ async function refreshAndPublish(spec) {
316
325
  return;
317
326
  }
318
327
  const items = applyClosedIssuesFilter(spec, res.items);
319
- const prevSize = registry.get(key)?.itemsById.size || 0;
328
+ const prev_size = registry.get(key)?.itemsById.size || 0;
320
329
  const delta = registry.applyItems(key, items);
321
330
  const entry = registry.get(key);
322
331
  if (!entry || entry.subscribers.size === 0) {
323
332
  return;
324
333
  }
325
334
  /** @type {Map<string, any>} */
326
- const byId = new Map();
335
+ const by_id = new Map();
327
336
  for (const it of items) {
328
337
  if (it && typeof it.id === 'string') {
329
- byId.set(it.id, it);
338
+ by_id.set(it.id, it);
330
339
  }
331
340
  }
332
341
  for (const ws of entry.subscribers) {
@@ -334,20 +343,20 @@ async function refreshAndPublish(spec) {
334
343
  const s = ensureSubs(ws);
335
344
  const subs = s.list_subs || new Map();
336
345
  /** @type {string[]} */
337
- const clientIds = [];
346
+ const client_ids = [];
338
347
  for (const [cid, v] of subs.entries()) {
339
- if (v.key === key) clientIds.push(cid);
348
+ if (v.key === key) client_ids.push(cid);
340
349
  }
341
- if (clientIds.length === 0) continue;
342
- if (prevSize === 0) {
343
- for (const cid of clientIds) {
350
+ if (client_ids.length === 0) continue;
351
+ if (prev_size === 0) {
352
+ for (const cid of client_ids) {
344
353
  emitSubscriptionSnapshot(ws, cid, key, items);
345
354
  }
346
355
  continue;
347
356
  }
348
- for (const cid of clientIds) {
357
+ for (const cid of client_ids) {
349
358
  for (const id of [...delta.added, ...delta.updated]) {
350
- const issue = byId.get(id);
359
+ const issue = by_id.get(id);
351
360
  if (issue) {
352
361
  emitSubscriptionUpsert(ws, cid, key, issue);
353
362
  }
@@ -362,6 +371,7 @@ async function refreshAndPublish(spec) {
362
371
 
363
372
  /**
364
373
  * Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
374
+ *
365
375
  * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
366
376
  * @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
367
377
  */
@@ -387,6 +397,7 @@ function applyClosedIssuesFilter(spec, items) {
387
397
 
388
398
  /**
389
399
  * Attach a WebSocket server to an existing HTTP server.
400
+ *
390
401
  * @param {Server} http_server
391
402
  * @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
392
403
  * @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
@@ -406,6 +417,7 @@ export function attachWsServer(http_server, options = {}) {
406
417
 
407
418
  // Heartbeat: track if client answered the last ping
408
419
  wss.on('connection', (ws) => {
420
+ log('client connected');
409
421
  // @ts-expect-error add marker property
410
422
  ws.isAlive = true;
411
423
 
@@ -451,6 +463,7 @@ export function attachWsServer(http_server, options = {}) {
451
463
 
452
464
  /**
453
465
  * Broadcast a server-initiated event to all open clients.
466
+ *
454
467
  * @param {MessageType} type
455
468
  * @param {unknown} [payload]
456
469
  */
@@ -478,6 +491,7 @@ export function attachWsServer(http_server, options = {}) {
478
491
 
479
492
  /**
480
493
  * Handle an incoming message frame and respond to the same socket.
494
+ *
481
495
  * @param {WebSocket} ws
482
496
  * @param {RawData} data
483
497
  */
@@ -498,6 +512,7 @@ export async function handleMessage(ws, data) {
498
512
  }
499
513
 
500
514
  if (!isRequest(json)) {
515
+ log('invalid request');
501
516
  const reply = {
502
517
  id: 'unknown',
503
518
  ok: false,
@@ -518,6 +533,7 @@ export async function handleMessage(ws, data) {
518
533
 
519
534
  // subscribe-list: payload { id: string, type: string, params?: object }
520
535
  if (req.type === 'subscribe-list') {
536
+ log('subscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
521
537
  const validation = validateSubscribeListPayload(
522
538
  /** @type {any} */ (req.payload || {})
523
539
  );
@@ -553,6 +569,7 @@ export async function handleMessage(ws, data) {
553
569
 
554
570
  // unsubscribe-list: payload { id: string }
555
571
  if (req.type === 'unsubscribe-list') {
572
+ log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
556
573
  const { id: client_id } = /** @type {any} */ (req.payload || {});
557
574
  if (typeof client_id !== 'string' || client_id.length === 0) {
558
575
  ws.send(
@@ -637,6 +654,7 @@ export async function handleMessage(ws, data) {
637
654
 
638
655
  // update-status
639
656
  if (req.type === 'update-status') {
657
+ log('update-status');
640
658
  const { id, status } = /** @type {any} */ (req.payload);
641
659
  const allowed = new Set(['open', 'in_progress', 'closed']);
642
660
  if (
@@ -682,6 +700,7 @@ export async function handleMessage(ws, data) {
682
700
 
683
701
  // update-priority
684
702
  if (req.type === 'update-priority') {
703
+ log('update-priority');
685
704
  const { id, priority } = /** @type {any} */ (req.payload);
686
705
  if (
687
706
  typeof id !== 'string' ||
@@ -726,6 +745,7 @@ export async function handleMessage(ws, data) {
726
745
 
727
746
  // edit-text
728
747
  if (req.type === 'edit-text') {
748
+ log('edit-text');
729
749
  const { id, field, value } = /** @type {any} */ (req.payload);
730
750
  if (
731
751
  typeof id !== 'string' ||
@@ -789,6 +809,7 @@ export async function handleMessage(ws, data) {
789
809
 
790
810
  // create-issue
791
811
  if (req.type === 'create-issue') {
812
+ log('create-issue');
792
813
  const { title, type, priority, description } = /** @type {any} */ (
793
814
  req.payload || {}
794
815
  );
@@ -1,98 +0,0 @@
1
- /**
2
- * List selectors utility: compose subscription membership with issues entities
3
- * and apply view-specific sorting. Provides a lightweight `subscribe` that
4
- * triggers once per issues envelope to let views re-render.
5
- */
6
- /**
7
- * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
8
- */
9
- import { cmpClosedDesc, cmpPriorityThenCreated } from './sort.js';
10
-
11
- /**
12
- * Factory for list selectors.
13
- *
14
- * Source of truth is per-subscription stores providing snapshots for a given
15
- * client id. Central issues store fallback has been removed.
16
- * @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
17
- */
18
- export function createListSelectors(issue_stores = undefined) {
19
- // Sorting comparators are centralized in app/data/sort.js
20
-
21
- /**
22
- * Get entities for a subscription id with Issues List sort (priority asc → created asc).
23
- * @param {string} client_id
24
- * @returns {IssueLite[]}
25
- */
26
- function selectIssuesFor(client_id) {
27
- if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
28
- return [];
29
- }
30
- return issue_stores
31
- .snapshotFor(client_id)
32
- .slice()
33
- .sort(cmpPriorityThenCreated);
34
- }
35
-
36
- /**
37
- * Get entities for a Board column with column-specific sort.
38
- * @param {string} client_id
39
- * @param {'ready'|'blocked'|'in_progress'|'closed'} mode
40
- * @returns {IssueLite[]}
41
- */
42
- function selectBoardColumn(client_id, mode) {
43
- const arr =
44
- issue_stores && issue_stores.snapshotFor
45
- ? issue_stores.snapshotFor(client_id).slice()
46
- : [];
47
- if (mode === 'in_progress') {
48
- arr.sort(cmpPriorityThenCreated);
49
- } else if (mode === 'closed') {
50
- arr.sort(cmpClosedDesc);
51
- } else {
52
- // ready/blocked share the same sort
53
- arr.sort(cmpPriorityThenCreated);
54
- }
55
- return arr;
56
- }
57
-
58
- /**
59
- * Get children for an epic subscribed as client id `epic:${id}`.
60
- * Sorted as Issues List (priority asc → created asc).
61
- * @param {string} epic_id
62
- * @returns {IssueLite[]}
63
- */
64
- function selectEpicChildren(epic_id) {
65
- if (!issue_stores || typeof issue_stores.snapshotFor !== 'function') {
66
- return [];
67
- }
68
- // Epic detail subscription uses client id `detail:<id>` and contains the
69
- // epic entity with a `dependents` array. Render children from that list.
70
- const arr = /** @type {any[]} */ (
71
- issue_stores.snapshotFor(`detail:${epic_id}`) || []
72
- );
73
- const epic = arr.find((it) => String(it?.id || '') === String(epic_id));
74
- const dependents = Array.isArray(epic?.dependents) ? epic.dependents : [];
75
- return /** @type {IssueLite[]} */ (
76
- dependents.slice().sort(cmpPriorityThenCreated)
77
- );
78
- }
79
-
80
- /**
81
- * Subscribe for re-render; triggers once per issues envelope.
82
- * @param {() => void} fn
83
- * @returns {() => void}
84
- */
85
- function subscribe(fn) {
86
- if (issue_stores && typeof issue_stores.subscribe === 'function') {
87
- return issue_stores.subscribe(fn);
88
- }
89
- return () => {};
90
- }
91
-
92
- return {
93
- selectIssuesFor,
94
- selectBoardColumn,
95
- selectEpicChildren,
96
- subscribe
97
- };
98
- }
@@ -1,76 +0,0 @@
1
- /**
2
- * @import { MessageType } from '../protocol.js'
3
- */
4
- /**
5
- * Data layer: typed wrappers around the ws transport for mutations and
6
- * single-issue fetch. List reads have been removed in favor of push-only
7
- * stores and selectors (see docs/adr/001-push-only-lists.md).
8
- * @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
9
- * @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
10
- */
11
- export function createDataLayer(transport) {
12
- /**
13
- * Update issue fields by dispatching specific mutations.
14
- * Supported fields: title, acceptance, notes, design, status, priority, assignee.
15
- * Returns the updated issue on success.
16
- * @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
17
- * @returns {Promise<unknown>}
18
- */
19
- async function updateIssue(input) {
20
- const { id } = input;
21
- /** @type {unknown} */
22
- let last = null;
23
- if (typeof input.title === 'string') {
24
- last = await transport('edit-text', {
25
- id,
26
- field: 'title',
27
- value: input.title
28
- });
29
- }
30
- if (typeof input.acceptance === 'string') {
31
- last = await transport('edit-text', {
32
- id,
33
- field: 'acceptance',
34
- value: input.acceptance
35
- });
36
- }
37
- if (typeof input.notes === 'string') {
38
- last = await transport('edit-text', {
39
- id,
40
- field: 'notes',
41
- value: input.notes
42
- });
43
- }
44
- if (typeof input.design === 'string') {
45
- last = await transport('edit-text', {
46
- id,
47
- field: 'design',
48
- value: input.design
49
- });
50
- }
51
- if (typeof input.status === 'string') {
52
- last = await transport('update-status', {
53
- id,
54
- status: input.status
55
- });
56
- }
57
- if (typeof input.priority === 'number') {
58
- last = await transport('update-priority', {
59
- id,
60
- priority: input.priority
61
- });
62
- }
63
- // type updates are not supported via UI
64
- if (typeof input.assignee === 'string') {
65
- last = await transport('update-assignee', {
66
- id,
67
- assignee: input.assignee
68
- });
69
- }
70
- return last;
71
- }
72
-
73
- return {
74
- updateIssue
75
- };
76
- }
package/app/data/sort.js DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * Shared sort comparators for issues lists.
3
- * Centralizes sorting so views and stores stay consistent.
4
- */
5
-
6
- /**
7
- * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, created_at?: number, updated_at?: number, closed_at?: number }} IssueLite
8
- */
9
-
10
- /**
11
- * Compare by priority asc, then created_at asc, then id asc.
12
- * @param {IssueLite} a
13
- * @param {IssueLite} b
14
- */
15
- export function cmpPriorityThenCreated(a, b) {
16
- const pa = a.priority ?? 2;
17
- const pb = b.priority ?? 2;
18
- if (pa !== pb) {
19
- return pa - pb;
20
- }
21
- const ca = a.created_at ?? 0;
22
- const cb = b.created_at ?? 0;
23
- if (ca !== cb) {
24
- return ca < cb ? -1 : 1;
25
- }
26
- const ida = a.id;
27
- const idb = b.id;
28
- return ida < idb ? -1 : ida > idb ? 1 : 0;
29
- }
30
-
31
- /**
32
- * Compare by closed_at desc, then id asc for stability.
33
- * @param {IssueLite} a
34
- * @param {IssueLite} b
35
- */
36
- export function cmpClosedDesc(a, b) {
37
- const ca = a.closed_at ?? 0;
38
- const cb = b.closed_at ?? 0;
39
- if (ca !== cb) {
40
- return ca < cb ? 1 : -1;
41
- }
42
- const ida = a?.id;
43
- const idb = b?.id;
44
- return ida < idb ? -1 : ida > idb ? 1 : 0;
45
- }