beads-ui 0.1.2 β 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.
- package/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
package/CHANGES.md
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- π Rewrite data-exchange layer to push-only updates via WebSocket.
|
|
6
|
+
- π Heaps of bug fixes.
|
|
7
|
+
|
|
8
|
+
## 0.2.0
|
|
9
|
+
|
|
10
|
+
- π Add "Blocked" column to board
|
|
11
|
+
- π Support `design` in issue details
|
|
12
|
+
- π Add filter to closed column and improve sorting
|
|
13
|
+
- π Unblock issue description editing
|
|
14
|
+
- π CLI: require --open to launch browser, also on restart
|
|
15
|
+
- π Up/down/left/right keyboard navigation on board
|
|
16
|
+
- π Up/down keyboard navigation on issues list
|
|
17
|
+
- π CLI: require --open to launch browser
|
|
18
|
+
- π Make issue notes editable
|
|
19
|
+
- π Show toast on disconnect/reconnect
|
|
20
|
+
- π Support creating a new issue via "New" dialog
|
|
21
|
+
- π Copy issue IDs to clipboard
|
|
22
|
+
- π Open issue details in dialog
|
|
23
|
+
- π Remove --limit 10 when fetching closed issues
|
|
24
|
+
- β¨ Events: coalesce issues-changed to avoid redundant full refresh
|
|
25
|
+
- β¨ Update issues
|
|
26
|
+
- β¨ Align callback function naming
|
|
27
|
+
- π Improve README
|
|
28
|
+
- π Add package description, homepage and repo
|
|
29
|
+
|
|
3
30
|
## 0.1.2
|
|
4
31
|
|
|
5
|
-
- π¦ Specify files to package
|
|
32
|
+
- π¦ Specify files to package
|
|
6
33
|
|
|
7
34
|
## 0.1.1
|
|
8
35
|
|
|
9
|
-
- π Make screenshot src absolute and add license
|
|
36
|
+
- π Make screenshot src absolute and add license
|
|
10
37
|
|
|
11
38
|
## 0.1.0
|
|
12
39
|
|
package/README.md
CHANGED
|
@@ -1,64 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
Beads UI
|
|
3
|
+
</h1>
|
|
4
|
+
<p align="center">
|
|
5
|
+
<b>Localβfirst UI for the <code>bd</code> CLI β <a href="https://github.com/steveyegge/beads">Beads</a></b>
|
|
6
|
+
</p>
|
|
7
|
+
<div align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/beads-ui"><img src="https://img.shields.io/npm/v/beads-ui.svg" alt="npm Version"></a>
|
|
9
|
+
<a href="https://semver.org"><img src="https://img.shields.io/:semver-%E2%9C%93-blue.svg" alt="SemVer"></a>
|
|
10
|
+
<a href="https://github.com/mantoni/beads-ui/actions/worflows/ci.yml"><img src="https://github.com/mantoni/eslint_d.js/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/npm/l/eslint_d.svg" alt="MIT License"></a>
|
|
12
|
+
<br>
|
|
13
|
+
<br>
|
|
14
|
+
</div>
|
|
2
15
|
|
|
3
|
-
|
|
4
|
-
tracker.
|
|
16
|
+
## Features
|
|
5
17
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
- β¨ **Zero setup** β just run `bdui start`
|
|
19
|
+
- πΊ **Live updates** β Monitors the beads database for changes
|
|
20
|
+
- π **Issues view** β Filter and search issues, edit inline
|
|
21
|
+
- β°οΈ **Epics view** β Show progress per epic, expand rows, edit inline
|
|
22
|
+
- π **Board view** β Open / Blocked / Ready / In progress / Closed columns
|
|
23
|
+
- β¨οΈ **Keyboard navigation** β Navigate and edit without touching the mouse
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
## Setup
|
|
12
26
|
|
|
13
|
-
|
|
27
|
+
```sh
|
|
28
|
+
npm i beads-ui -g
|
|
29
|
+
# In the project directory with a beads database:
|
|
30
|
+
bdui start --open
|
|
31
|
+
```
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
- Epics view grouped by epic (from `bd epic status --json`) with expandable rows
|
|
17
|
-
- Board view with Ready / In progress / Closed columns
|
|
18
|
-
- Deep links for navigation; state persists across reloads
|
|
19
|
-
- Live updates via FS watch + WebSocket; optimistic UI with rollbacks on error
|
|
20
|
-
- Dark theme toggle, saved per user
|
|
21
|
-
- Local CLI helper `bdui` to daemonize the server and open your browser
|
|
33
|
+
See `bdui --help` for options.
|
|
22
34
|
|
|
23
35
|
## Screenshots
|
|
24
36
|
|
|
25
|
-
Issues
|
|
37
|
+
**Issues**
|
|
26
38
|
|
|
27
39
|

|
|
28
40
|
|
|
29
|
-
Epics
|
|
41
|
+
**Epics**
|
|
30
42
|
|
|
31
43
|

|
|
32
44
|
|
|
33
|
-
Board
|
|
45
|
+
**Board**
|
|
34
46
|
|
|
35
47
|

|
|
36
48
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
Prerequisites:
|
|
40
|
-
|
|
41
|
-
- Node.js >= 22
|
|
42
|
-
- `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
|
|
43
|
-
|
|
44
|
-
Install and start:
|
|
45
|
-
|
|
46
|
-
```sh
|
|
47
|
-
npm install -g beads-ui
|
|
48
|
-
bdui start
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
See `bdui --help` for options.
|
|
52
|
-
|
|
53
|
-
Environment variables:
|
|
49
|
+
## Environment variables
|
|
54
50
|
|
|
51
|
+
- `BD_BIN`: path to the `bd` binary.
|
|
55
52
|
- `BDUI_RUNTIME_DIR`: override runtime directory for PID/logs. Defaults to
|
|
56
53
|
`$XDG_RUNTIME_DIR/beads-ui` or the system temp dir.
|
|
57
|
-
- `BDUI_NO_OPEN=1`: disable opening the default browser on `start`.
|
|
54
|
+
- `BDUI_NO_OPEN=1`: disable opening the default browser on `start`. Note:
|
|
55
|
+
Opening the browser is disabled by default; use `--open` to explicitly launch
|
|
56
|
+
the browser, which overrides this env var.
|
|
58
57
|
- `PORT`: overrides the listen port (default `3000`). The server binds to
|
|
59
58
|
`127.0.0.1`.
|
|
60
59
|
|
|
61
|
-
Platform notes
|
|
60
|
+
## Platform notes
|
|
62
61
|
|
|
63
62
|
- macOS/Linux are fully supported. On Windows, the CLI uses `cmd /c start` to
|
|
64
63
|
open URLs and relies on Nodeβs `process.kill` semantics for stopping the
|
|
@@ -66,13 +65,8 @@ Platform notes:
|
|
|
66
65
|
|
|
67
66
|
## Developer Workflow
|
|
68
67
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
- Lint: `npm run lint`
|
|
72
|
-
- Format: `npm run format`
|
|
73
|
-
|
|
74
|
-
See `docs/quickstart.md` for details and `docs/architecture.md` for the protocol
|
|
75
|
-
and component overview.
|
|
68
|
+
- π¦ Make sure you have `beads-mcp` installed.
|
|
69
|
+
- π€ Ask your agent of choice. It will know.
|
|
76
70
|
|
|
77
71
|
## License
|
|
78
72
|
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
}
|
package/app/data/providers.js
CHANGED
|
@@ -1,129 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
4
|
-
* @param {(type: import('../protocol.js').MessageType, handler: (payload: unknown) => void) => void} [on_event] - Optional event subscription (used to invalidate caches on push updates).
|
|
5
|
-
* @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
2
|
+
* @import { MessageType } from '../protocol.js'
|
|
6
3
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
cache.list_ready = undefined;
|
|
16
|
-
cache.list_open = undefined;
|
|
17
|
-
cache.list_in_progress = undefined;
|
|
18
|
-
cache.list_closed_10 = undefined;
|
|
19
|
-
cache.epic_status = undefined;
|
|
20
|
-
});
|
|
21
|
-
} catch {
|
|
22
|
-
// noop
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Get epic status groups via `bd epic status --json`.
|
|
28
|
-
* @returns {Promise<unknown[]>}
|
|
29
|
-
*/
|
|
30
|
-
async function getEpicStatus() {
|
|
31
|
-
if (Array.isArray(cache.epic_status)) {
|
|
32
|
-
return /** @type {unknown[]} */ (cache.epic_status);
|
|
33
|
-
}
|
|
34
|
-
/** @type {unknown} */
|
|
35
|
-
const res = await transport('epic-status');
|
|
36
|
-
const arr = Array.isArray(res) ? res : [];
|
|
37
|
-
cache.epic_status = arr;
|
|
38
|
-
return arr;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Ready issues: `bd ready --json`.
|
|
43
|
-
* Sort by priority then updated_at on the UI; transport returns raw list.
|
|
44
|
-
* @returns {Promise<unknown[]>}
|
|
45
|
-
*/
|
|
46
|
-
async function getReady() {
|
|
47
|
-
if (Array.isArray(cache.list_ready)) {
|
|
48
|
-
return /** @type {unknown[]} */ (cache.list_ready);
|
|
49
|
-
}
|
|
50
|
-
/** @type {unknown} */
|
|
51
|
-
const res = await transport('list-issues', { filters: { ready: true } });
|
|
52
|
-
const arr = Array.isArray(res) ? res : [];
|
|
53
|
-
cache.list_ready = arr;
|
|
54
|
-
return arr;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Open issues: `bd list -s open --json`.
|
|
59
|
-
* @returns {Promise<unknown[]>}
|
|
60
|
-
*/
|
|
61
|
-
async function getOpen() {
|
|
62
|
-
if (Array.isArray(cache.list_open)) {
|
|
63
|
-
return /** @type {unknown[]} */ (cache.list_open);
|
|
64
|
-
}
|
|
65
|
-
/** @type {unknown} */
|
|
66
|
-
const res = await transport('list-issues', {
|
|
67
|
-
filters: { status: 'open' }
|
|
68
|
-
});
|
|
69
|
-
const arr = Array.isArray(res) ? res : [];
|
|
70
|
-
cache.list_open = arr;
|
|
71
|
-
return arr;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* In progress issues: `bd list -s in_progress --json`.
|
|
76
|
-
* @returns {Promise<unknown[]>}
|
|
77
|
-
*/
|
|
78
|
-
async function getInProgress() {
|
|
79
|
-
if (Array.isArray(cache.list_in_progress)) {
|
|
80
|
-
return /** @type {unknown[]} */ (cache.list_in_progress);
|
|
81
|
-
}
|
|
82
|
-
/** @type {unknown} */
|
|
83
|
-
const res = await transport('list-issues', {
|
|
84
|
-
filters: { status: 'in_progress' }
|
|
85
|
-
});
|
|
86
|
-
const arr = Array.isArray(res) ? res : [];
|
|
87
|
-
cache.list_in_progress = arr;
|
|
88
|
-
return arr;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Closed issues: `bd list -s closed -l 10 --json`.
|
|
93
|
-
* @param {number} [limit] - Optional limit (defaults to 10).
|
|
94
|
-
* @returns {Promise<unknown[]>}
|
|
95
|
-
*/
|
|
96
|
-
async function getClosed(limit = 10) {
|
|
97
|
-
if (limit === 10 && Array.isArray(cache.list_closed_10)) {
|
|
98
|
-
return /** @type {unknown[]} */ (cache.list_closed_10);
|
|
99
|
-
}
|
|
100
|
-
/** @type {unknown} */
|
|
101
|
-
const res = await transport('list-issues', {
|
|
102
|
-
filters: { status: 'closed', limit }
|
|
103
|
-
});
|
|
104
|
-
const arr = Array.isArray(res) ? res : [];
|
|
105
|
-
if (limit === 10) {
|
|
106
|
-
cache.list_closed_10 = arr;
|
|
107
|
-
}
|
|
108
|
-
return arr;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Show a single issue via `bd show <id> --json`.
|
|
113
|
-
* @param {string} id
|
|
114
|
-
* @returns {Promise<unknown>}
|
|
115
|
-
*/
|
|
116
|
-
async function getIssue(id) {
|
|
117
|
-
/** @type {unknown} */
|
|
118
|
-
const res = await transport('show-issue', { id });
|
|
119
|
-
return res;
|
|
120
|
-
}
|
|
121
|
-
|
|
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) {
|
|
122
12
|
/**
|
|
123
13
|
* Update issue fields by dispatching specific mutations.
|
|
124
|
-
* Supported fields: title, acceptance, status, priority, assignee.
|
|
14
|
+
* Supported fields: title, acceptance, notes, design, status, priority, assignee.
|
|
125
15
|
* Returns the updated issue on success.
|
|
126
|
-
* @param {{ id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
16
|
+
* @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
127
17
|
* @returns {Promise<unknown>}
|
|
128
18
|
*/
|
|
129
19
|
async function updateIssue(input) {
|
|
@@ -144,6 +34,20 @@ export function createDataLayer(transport, on_event) {
|
|
|
144
34
|
value: input.acceptance
|
|
145
35
|
});
|
|
146
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
|
+
}
|
|
147
51
|
if (typeof input.status === 'string') {
|
|
148
52
|
last = await transport('update-status', {
|
|
149
53
|
id,
|
|
@@ -167,12 +71,6 @@ export function createDataLayer(transport, on_event) {
|
|
|
167
71
|
}
|
|
168
72
|
|
|
169
73
|
return {
|
|
170
|
-
getEpicStatus,
|
|
171
|
-
getReady,
|
|
172
|
-
getOpen,
|
|
173
|
-
getInProgress,
|
|
174
|
-
getClosed,
|
|
175
|
-
getIssue,
|
|
176
74
|
updateIssue
|
|
177
75
|
};
|
|
178
76
|
}
|
package/app/data/sort.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { SubscriptionIssueStore, SubscriptionIssueStoreOptions } from '../../types/subscription-issue-store.js'
|
|
3
|
+
*/
|
|
4
|
+
import { cmpPriorityThenCreated } from './sort.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-subscription issue store. Holds full Issue objects and exposes a
|
|
8
|
+
* deterministic, read-only snapshot for rendering. Applies snapshot/upsert/
|
|
9
|
+
* delete messages in revision order and preserves object identity per id.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Sort comparator is centralized in app/data/sort.js
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a SubscriptionIssueStore for a given subscription id.
|
|
16
|
+
* @param {string} id
|
|
17
|
+
* @param {SubscriptionIssueStoreOptions} [options]
|
|
18
|
+
* @returns {SubscriptionIssueStore}
|
|
19
|
+
*/
|
|
20
|
+
export function createSubscriptionIssueStore(id, options = {}) {
|
|
21
|
+
/** @type {Map<string, any>} */
|
|
22
|
+
const items_by_id = new Map();
|
|
23
|
+
/** @type {any[]} */
|
|
24
|
+
let ordered = [];
|
|
25
|
+
/** @type {number} */
|
|
26
|
+
let last_revision = 0;
|
|
27
|
+
/** @type {Set<() => void>} */
|
|
28
|
+
const listeners = new Set();
|
|
29
|
+
/** @type {boolean} */
|
|
30
|
+
let is_disposed = false;
|
|
31
|
+
/** @type {(a:any,b:any)=>number} */
|
|
32
|
+
const sort = options.sort || cmpPriorityThenCreated;
|
|
33
|
+
|
|
34
|
+
function emit() {
|
|
35
|
+
for (const fn of Array.from(listeners)) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore listener errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rebuildOrdered() {
|
|
45
|
+
ordered = Array.from(items_by_id.values()).sort(sort);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply snapshot/upsert/delete in revision order. Snapshots reset state.
|
|
50
|
+
* - Ignore messages with revision <= last_revision (except snapshot which resets first).
|
|
51
|
+
* - Preserve object identity when updating an existing item by mutating
|
|
52
|
+
* fields in place rather than replacing the object reference.
|
|
53
|
+
* @param {{ type: 'snapshot'|'upsert'|'delete', id: string, revision: number, issues?: any[], issue?: any, issue_id?: string }} msg
|
|
54
|
+
*/
|
|
55
|
+
function applyPush(msg) {
|
|
56
|
+
if (is_disposed) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!msg || msg.id !== id) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const rev = Number(msg.revision) || 0;
|
|
63
|
+
// Ignore stale messages for all types, including snapshots
|
|
64
|
+
if (rev <= last_revision && msg.type !== 'snapshot') {
|
|
65
|
+
return; // stale or duplicate non-snapshot
|
|
66
|
+
}
|
|
67
|
+
if (msg.type === 'snapshot') {
|
|
68
|
+
if (rev <= last_revision) {
|
|
69
|
+
return; // ignore stale snapshot
|
|
70
|
+
}
|
|
71
|
+
items_by_id.clear();
|
|
72
|
+
const items = Array.isArray(msg.issues) ? msg.issues : [];
|
|
73
|
+
for (const it of items) {
|
|
74
|
+
if (it && typeof it.id === 'string' && it.id.length > 0) {
|
|
75
|
+
items_by_id.set(it.id, it);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
rebuildOrdered();
|
|
79
|
+
last_revision = rev;
|
|
80
|
+
emit();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (msg.type === 'upsert') {
|
|
84
|
+
const it = msg.issue;
|
|
85
|
+
if (it && typeof it.id === 'string' && it.id.length > 0) {
|
|
86
|
+
const existing = items_by_id.get(it.id);
|
|
87
|
+
if (!existing) {
|
|
88
|
+
items_by_id.set(it.id, it);
|
|
89
|
+
} else {
|
|
90
|
+
// Guard with updated_at; prefer newer
|
|
91
|
+
const prev_ts = Number.isFinite(existing.updated_at)
|
|
92
|
+
? /** @type {number} */ (existing.updated_at)
|
|
93
|
+
: 0;
|
|
94
|
+
const next_ts = Number.isFinite(it.updated_at)
|
|
95
|
+
? /** @type {number} */ (it.updated_at)
|
|
96
|
+
: 0;
|
|
97
|
+
if (prev_ts <= next_ts) {
|
|
98
|
+
// Mutate existing object to preserve reference
|
|
99
|
+
for (const k of Object.keys(existing)) {
|
|
100
|
+
if (!(k in it)) {
|
|
101
|
+
// remove keys that disappeared to avoid stale fields
|
|
102
|
+
delete existing[k];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const [k, v] of Object.entries(it)) {
|
|
106
|
+
// @ts-ignore - dynamic assignment
|
|
107
|
+
existing[k] = v;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// stale by timestamp; ignore
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
rebuildOrdered();
|
|
114
|
+
}
|
|
115
|
+
last_revision = rev;
|
|
116
|
+
emit();
|
|
117
|
+
} else if (msg.type === 'delete') {
|
|
118
|
+
const rid = String(msg.issue_id || '');
|
|
119
|
+
if (rid) {
|
|
120
|
+
items_by_id.delete(rid);
|
|
121
|
+
rebuildOrdered();
|
|
122
|
+
}
|
|
123
|
+
last_revision = rev;
|
|
124
|
+
emit();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id,
|
|
130
|
+
/**
|
|
131
|
+
* @param {() => void} fn
|
|
132
|
+
*/
|
|
133
|
+
subscribe(fn) {
|
|
134
|
+
listeners.add(fn);
|
|
135
|
+
return () => {
|
|
136
|
+
listeners.delete(fn);
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
applyPush,
|
|
140
|
+
snapshot() {
|
|
141
|
+
// Return as read-only view; callers must not mutate
|
|
142
|
+
return ordered;
|
|
143
|
+
},
|
|
144
|
+
size() {
|
|
145
|
+
return items_by_id.size;
|
|
146
|
+
},
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} xid
|
|
149
|
+
*/
|
|
150
|
+
getById(xid) {
|
|
151
|
+
return items_by_id.get(xid);
|
|
152
|
+
},
|
|
153
|
+
dispose() {
|
|
154
|
+
is_disposed = true;
|
|
155
|
+
items_by_id.clear();
|
|
156
|
+
ordered = [];
|
|
157
|
+
listeners.clear();
|
|
158
|
+
last_revision = 0;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|