beads-ui 0.3.1 → 0.4.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.
- package/CHANGES.md +24 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -14
- package/server/app.js +38 -36
- package/server/bd.js +2 -2
- package/server/cli/commands.js +8 -8
- package/server/cli/daemon.js +13 -5
- package/server/cli/index.js +17 -31
- package/server/cli/usage.js +3 -2
- package/server/db.js +6 -6
- package/server/index.js +10 -4
- package/server/logging.js +23 -0
- package/server/watcher.js +7 -5
- package/server/ws.js +23 -11
- package/app/data/list-selectors.js +0 -103
- package/app/data/providers.js +0 -78
- package/app/data/sort.js +0 -47
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -128
- package/app/data/subscriptions-store.js +0 -227
- package/app/main.js +0 -706
- package/app/protocol.md +0 -66
- package/app/router.js +0 -117
- package/app/state.js +0 -105
- package/app/utils/issue-id-renderer.js +0 -72
- package/app/utils/issue-id.js +0 -11
- package/app/utils/issue-type.js +0 -29
- package/app/utils/issue-url.js +0 -10
- package/app/utils/markdown.js +0 -16
- package/app/utils/priority-badge.js +0 -48
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -33
- package/app/utils/status.js +0 -25
- package/app/utils/toast.js +0 -35
- package/app/utils/type-badge.js +0 -34
- package/app/views/board.js +0 -537
- package/app/views/detail.js +0 -1252
- package/app/views/epics.js +0 -281
- package/app/views/issue-dialog.js +0 -164
- package/app/views/issue-row.js +0 -191
- package/app/views/list.js +0 -468
- package/app/views/nav.js +0 -68
- package/app/views/new-issue-dialog.js +0 -348
- package/app/ws.js +0 -282
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
- package/server/protocol.js +0 -3
package/app/protocol.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# beads-ui WebSocket Protocol (v2.0.0)
|
|
2
|
-
|
|
3
|
-
Note (2025-10-26)
|
|
4
|
-
|
|
5
|
-
- The server no longer implements legacy read RPCs `list-issues` and
|
|
6
|
-
`epic-status`. Clients must use the push-only protocol described in
|
|
7
|
-
`docs/protocol/issues-push-v2.md` (subscribe-list with per-subscription events
|
|
8
|
-
snapshot/upsert/delete). The shapes below are retained for historical
|
|
9
|
-
reference of v1.
|
|
10
|
-
|
|
11
|
-
This document defines the JSON messages exchanged between the browser client and
|
|
12
|
-
the local server.
|
|
13
|
-
|
|
14
|
-
- Transport: single WebSocket connection
|
|
15
|
-
- Encoding: JSON text frames
|
|
16
|
-
- Correlation: all request/response pairs share the same `id`
|
|
17
|
-
|
|
18
|
-
## Envelope Shapes
|
|
19
|
-
|
|
20
|
-
- RequestEnvelope: `{ id: string, type: string, payload?: any }`
|
|
21
|
-
- ReplyEnvelope:
|
|
22
|
-
`{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
|
|
23
|
-
|
|
24
|
-
Server may send unsolicited events (e.g., subscription
|
|
25
|
-
`snapshot`/`upsert`/`delete`) using the ReplyEnvelope shape with `ok: true` and
|
|
26
|
-
a generated `id`.
|
|
27
|
-
|
|
28
|
-
## Message Types
|
|
29
|
-
|
|
30
|
-
- Removed in v2: `list-issues` (use subscriptions + push stores)
|
|
31
|
-
- `update-status` payload:
|
|
32
|
-
`{ id: string, status: 'open'|'in_progress'|'closed' }`
|
|
33
|
-
- `edit-text` payload:
|
|
34
|
-
`{ id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }`
|
|
35
|
-
- `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
|
|
36
|
-
- `create-issue` payload:
|
|
37
|
-
`{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
|
|
38
|
-
- `list-ready` payload: `{}`
|
|
39
|
-
- Removed in v2: `subscribe-updates` and the `issues-changed` event. All list
|
|
40
|
-
and detail updates flow via per-subscription push envelopes
|
|
41
|
-
(`snapshot`/`upsert`/`delete`).
|
|
42
|
-
- `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
|
|
43
|
-
depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
|
|
44
|
-
issue for `view_id` (or `a` when omitted).
|
|
45
|
-
- `dep-remove` payload: `{ a: string, b: string, view_id?: string }` removing
|
|
46
|
-
the `a` depends on `b` link. Reply payload is the updated issue for `view_id`
|
|
47
|
-
(or `a`).
|
|
48
|
-
|
|
49
|
-
## Mapping to `bd` CLI
|
|
50
|
-
|
|
51
|
-
- Removed in v2: `list-issues` → use subscriptions and push
|
|
52
|
-
(`docs/protocol/issues-push-v2.md`)
|
|
53
|
-
- `update-status` → `bd update <id> --status <status>`
|
|
54
|
-
- `edit-text` → `bd update <id> --title <t>` or `--description <d>` or
|
|
55
|
-
`--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
|
|
56
|
-
- `update-priority` → `bd update <id> --priority <n>`
|
|
57
|
-
- `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
|
|
58
|
-
- `list-ready` → `bd ready --json`
|
|
59
|
-
|
|
60
|
-
## Errors
|
|
61
|
-
|
|
62
|
-
Errors follow the shape `{ code, message, details? }`. Common codes:
|
|
63
|
-
|
|
64
|
-
- `bad_request` – malformed payload or unknown type
|
|
65
|
-
- `not_found` – entity not found (e.g., issue id)
|
|
66
|
-
- `bd_error` – underlying `bd` command failed
|
package/app/router.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { issueHashFor } from './utils/issue-url.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Parse an application hash and extract the selected issue id.
|
|
9
|
-
* Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
|
|
10
|
-
* "#/issue/<id>" which we will rewrite to the canonical form.
|
|
11
|
-
*
|
|
12
|
-
* @param {string} hash
|
|
13
|
-
* @returns {string | null}
|
|
14
|
-
*/
|
|
15
|
-
export function parseHash(hash) {
|
|
16
|
-
const h = String(hash || '');
|
|
17
|
-
// Extract the fragment sans leading '#'
|
|
18
|
-
const frag = h.startsWith('#') ? h.slice(1) : h;
|
|
19
|
-
const qIndex = frag.indexOf('?');
|
|
20
|
-
const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
|
|
21
|
-
if (query) {
|
|
22
|
-
const params = new URLSearchParams(query);
|
|
23
|
-
const id = params.get('issue');
|
|
24
|
-
if (id) {
|
|
25
|
-
return decodeURIComponent(id);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
// Legacy pattern: #/issue/<id>
|
|
29
|
-
const m = /^\/issue\/([^\s?#]+)/.exec(frag);
|
|
30
|
-
return m && m[1] ? decodeURIComponent(m[1]) : null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Parse the current view from hash.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} hash
|
|
37
|
-
* @returns {'issues'|'epics'|'board'}
|
|
38
|
-
*/
|
|
39
|
-
export function parseView(hash) {
|
|
40
|
-
const h = String(hash || '');
|
|
41
|
-
if (/^#\/epics(\b|\/|$)/.test(h)) {
|
|
42
|
-
return 'epics';
|
|
43
|
-
}
|
|
44
|
-
if (/^#\/board(\b|\/|$)/.test(h)) {
|
|
45
|
-
return 'board';
|
|
46
|
-
}
|
|
47
|
-
// Default to issues (also covers #/issues and unknown/empty)
|
|
48
|
-
return 'issues';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {{ getState: () => any, setState: (patch: any) => void }} store
|
|
53
|
-
*/
|
|
54
|
-
export function createHashRouter(store) {
|
|
55
|
-
/** @type {(ev?: HashChangeEvent) => any} */
|
|
56
|
-
const onHashChange = () => {
|
|
57
|
-
const hash = window.location.hash || '';
|
|
58
|
-
// Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
|
|
59
|
-
const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
|
|
60
|
-
if (legacyMatch && legacyMatch[1]) {
|
|
61
|
-
const id = decodeURIComponent(legacyMatch[1]);
|
|
62
|
-
// Update state immediately for consumers expecting sync selection
|
|
63
|
-
store.setState({ selected_id: id, view: 'issues' });
|
|
64
|
-
const next = `#/issues?issue=${encodeURIComponent(id)}`;
|
|
65
|
-
if (window.location.hash !== next) {
|
|
66
|
-
window.location.hash = next;
|
|
67
|
-
return; // will trigger handler again
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const id = parseHash(hash);
|
|
71
|
-
const view = parseView(hash);
|
|
72
|
-
store.setState({ selected_id: id, view });
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
start() {
|
|
77
|
-
window.addEventListener('hashchange', onHashChange);
|
|
78
|
-
onHashChange();
|
|
79
|
-
},
|
|
80
|
-
stop() {
|
|
81
|
-
window.removeEventListener('hashchange', onHashChange);
|
|
82
|
-
},
|
|
83
|
-
/**
|
|
84
|
-
* @param {string} id
|
|
85
|
-
*/
|
|
86
|
-
gotoIssue(id) {
|
|
87
|
-
// Keep current view in hash and append issue param via helper
|
|
88
|
-
const s = store.getState ? store.getState() : { view: 'issues' };
|
|
89
|
-
const view = s.view || 'issues';
|
|
90
|
-
const next = issueHashFor(view, id);
|
|
91
|
-
if (window.location.hash !== next) {
|
|
92
|
-
window.location.hash = next;
|
|
93
|
-
} else {
|
|
94
|
-
// Force state update even if hash is the same
|
|
95
|
-
store.setState({ selected_id: id, view });
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
/**
|
|
99
|
-
* Navigate to a top-level view.
|
|
100
|
-
*
|
|
101
|
-
* @param {'issues'|'epics'|'board'} view
|
|
102
|
-
*/
|
|
103
|
-
/**
|
|
104
|
-
* @param {'issues'|'epics'|'board'} view
|
|
105
|
-
*/
|
|
106
|
-
gotoView(view) {
|
|
107
|
-
const s = store.getState ? store.getState() : { selected_id: null };
|
|
108
|
-
const id = s.selected_id;
|
|
109
|
-
const next = id ? issueHashFor(view, id) : `#/${view}`;
|
|
110
|
-
if (window.location.hash !== next) {
|
|
111
|
-
window.location.hash = next;
|
|
112
|
-
} else {
|
|
113
|
-
store.setState({ view, selected_id: null });
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
}
|
package/app/state.js
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal app state store with subscription.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @typedef {'all'|'open'|'in_progress'|'closed'|'ready'} StatusFilter
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {{ status: StatusFilter, search: string, type: string }} Filters
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @typedef {'issues'|'epics'|'board'} ViewName
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {'today'|'3'|'7'} ClosedFilter
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @typedef {{ closed_filter: ClosedFilter }} BoardState
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Create a simple store for application state.
|
|
31
|
-
*
|
|
32
|
-
* @param {Partial<AppState>} [initial]
|
|
33
|
-
* @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
|
|
34
|
-
*/
|
|
35
|
-
export function createStore(initial = {}) {
|
|
36
|
-
/** @type {AppState} */
|
|
37
|
-
let state = {
|
|
38
|
-
selected_id: initial.selected_id ?? null,
|
|
39
|
-
view: initial.view ?? 'issues',
|
|
40
|
-
filters: {
|
|
41
|
-
status: initial.filters?.status ?? 'all',
|
|
42
|
-
search: initial.filters?.search ?? '',
|
|
43
|
-
type:
|
|
44
|
-
typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
|
|
45
|
-
},
|
|
46
|
-
board: {
|
|
47
|
-
closed_filter:
|
|
48
|
-
initial.board?.closed_filter === '3' ||
|
|
49
|
-
initial.board?.closed_filter === '7' ||
|
|
50
|
-
initial.board?.closed_filter === 'today'
|
|
51
|
-
? initial.board?.closed_filter
|
|
52
|
-
: 'today'
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/** @type {Set<(s: AppState) => void>} */
|
|
57
|
-
const subs = new Set();
|
|
58
|
-
|
|
59
|
-
function emit() {
|
|
60
|
-
for (const fn of Array.from(subs)) {
|
|
61
|
-
try {
|
|
62
|
-
fn(state);
|
|
63
|
-
} catch {
|
|
64
|
-
// ignore
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
getState() {
|
|
71
|
-
return state;
|
|
72
|
-
},
|
|
73
|
-
/**
|
|
74
|
-
* Update state. Nested filters can be partial.
|
|
75
|
-
*
|
|
76
|
-
* @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
|
|
77
|
-
*/
|
|
78
|
-
setState(patch) {
|
|
79
|
-
/** @type {AppState} */
|
|
80
|
-
const next = {
|
|
81
|
-
...state,
|
|
82
|
-
...patch,
|
|
83
|
-
filters: { ...state.filters, ...(patch.filters || {}) },
|
|
84
|
-
board: { ...state.board, ...(patch.board || {}) }
|
|
85
|
-
};
|
|
86
|
-
// Avoid emitting if nothing changed (shallow compare)
|
|
87
|
-
if (
|
|
88
|
-
next.selected_id === state.selected_id &&
|
|
89
|
-
next.view === state.view &&
|
|
90
|
-
next.filters.status === state.filters.status &&
|
|
91
|
-
next.filters.search === state.filters.search &&
|
|
92
|
-
next.filters.type === state.filters.type &&
|
|
93
|
-
next.board.closed_filter === state.board.closed_filter
|
|
94
|
-
) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
state = next;
|
|
98
|
-
emit();
|
|
99
|
-
},
|
|
100
|
-
subscribe(fn) {
|
|
101
|
-
subs.add(fn);
|
|
102
|
-
return () => subs.delete(fn);
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { issueDisplayId } from './issue-id.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create a reusable, copy-to-clipboard issue ID renderer.
|
|
5
|
-
* Looks like the current inline ID (monospace `#123`) but acts as a button
|
|
6
|
-
* that copies the full, prefixed ID (e.g., `UI-123`) when activated.
|
|
7
|
-
* Shows transient "Copied" feedback and then restores the ID.
|
|
8
|
-
*
|
|
9
|
-
* @param {string} id - Full issue id including the prefix (e.g., "UI-123").
|
|
10
|
-
* @param {{ class_name?: string, duration_ms?: number }} [opts]
|
|
11
|
-
* @returns {HTMLButtonElement}
|
|
12
|
-
*/
|
|
13
|
-
export function createIssueIdRenderer(id, opts) {
|
|
14
|
-
/** @type {number} */
|
|
15
|
-
const duration =
|
|
16
|
-
typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
|
|
17
|
-
/** @type {HTMLButtonElement} */
|
|
18
|
-
const btn = document.createElement('button');
|
|
19
|
-
// Visual: match inline ID look; keep it neutral and text-like
|
|
20
|
-
btn.className =
|
|
21
|
-
(opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
|
|
22
|
-
btn.type = 'button';
|
|
23
|
-
btn.setAttribute('aria-live', 'polite');
|
|
24
|
-
btn.setAttribute('title', 'Copy issue ID');
|
|
25
|
-
btn.setAttribute('aria-label', `Copy issue ID ${id}`);
|
|
26
|
-
const label = issueDisplayId(id);
|
|
27
|
-
btn.textContent = label;
|
|
28
|
-
|
|
29
|
-
/** Copy handler with feedback. */
|
|
30
|
-
async function doCopy() {
|
|
31
|
-
// Prevent accidental row navigation and parent handlers
|
|
32
|
-
// (click/key handlers call this inside an event context)
|
|
33
|
-
try {
|
|
34
|
-
if (
|
|
35
|
-
navigator.clipboard &&
|
|
36
|
-
typeof navigator.clipboard.writeText === 'function'
|
|
37
|
-
) {
|
|
38
|
-
await navigator.clipboard.writeText(String(id));
|
|
39
|
-
}
|
|
40
|
-
const prev = btn.textContent || label;
|
|
41
|
-
btn.textContent = 'Copied';
|
|
42
|
-
// Keep accessible label consistent with feedback
|
|
43
|
-
const oldAria = btn.getAttribute('aria-label') || '';
|
|
44
|
-
btn.setAttribute('aria-label', 'Copied');
|
|
45
|
-
setTimeout(
|
|
46
|
-
() => {
|
|
47
|
-
btn.textContent = prev;
|
|
48
|
-
btn.setAttribute('aria-label', oldAria);
|
|
49
|
-
},
|
|
50
|
-
Math.max(80, duration)
|
|
51
|
-
);
|
|
52
|
-
} catch {
|
|
53
|
-
// On failure, leave text as-is; no throw to avoid disruptive UX
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
btn.addEventListener('click', (ev) => {
|
|
58
|
-
ev.preventDefault();
|
|
59
|
-
ev.stopPropagation();
|
|
60
|
-
void doCopy();
|
|
61
|
-
});
|
|
62
|
-
btn.addEventListener('keydown', (ev) => {
|
|
63
|
-
// Ensure keyboard activation works even in non-interactive test envs
|
|
64
|
-
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
65
|
-
ev.preventDefault();
|
|
66
|
-
ev.stopPropagation();
|
|
67
|
-
void doCopy();
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
return btn;
|
|
72
|
-
}
|
package/app/utils/issue-id.js
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Format a beads issue id as a user-facing display string `#${n}`.
|
|
3
|
-
* Extracts the trailing numeric portion of the id and prefixes with '#'.
|
|
4
|
-
*
|
|
5
|
-
* @param {string | null | undefined} id
|
|
6
|
-
* @returns {string}
|
|
7
|
-
*/
|
|
8
|
-
export function issueDisplayId(id) {
|
|
9
|
-
const m = String(id || '').match(/(\d+)$/);
|
|
10
|
-
return m ? `#${m[1]}` : '#';
|
|
11
|
-
}
|
package/app/utils/issue-type.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Known issue types in canonical order for dropdowns.
|
|
3
|
-
*
|
|
4
|
-
* @type {Array<'bug'|'feature'|'task'|'epic'|'chore'>}
|
|
5
|
-
*/
|
|
6
|
-
export const ISSUE_TYPES = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Return a human-friendly label for an issue type.
|
|
10
|
-
*
|
|
11
|
-
* @param {string | null | undefined} type
|
|
12
|
-
* @returns {string}
|
|
13
|
-
*/
|
|
14
|
-
export function typeLabel(type) {
|
|
15
|
-
switch ((type || '').toString().toLowerCase()) {
|
|
16
|
-
case 'bug':
|
|
17
|
-
return 'Bug';
|
|
18
|
-
case 'feature':
|
|
19
|
-
return 'Feature';
|
|
20
|
-
case 'task':
|
|
21
|
-
return 'Task';
|
|
22
|
-
case 'epic':
|
|
23
|
-
return 'Epic';
|
|
24
|
-
case 'chore':
|
|
25
|
-
return 'Chore';
|
|
26
|
-
default:
|
|
27
|
-
return '';
|
|
28
|
-
}
|
|
29
|
-
}
|
package/app/utils/issue-url.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build a canonical issue hash that retains the view.
|
|
3
|
-
*
|
|
4
|
-
* @param {'issues'|'epics'|'board'} view
|
|
5
|
-
* @param {string} id
|
|
6
|
-
*/
|
|
7
|
-
export function issueHashFor(view, id) {
|
|
8
|
-
const v = view === 'epics' || view === 'board' ? view : 'issues';
|
|
9
|
-
return `#/${v}?issue=${encodeURIComponent(id)}`;
|
|
10
|
-
}
|
package/app/utils/markdown.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import DOMPurify from 'dompurify';
|
|
2
|
-
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
3
|
-
import { marked } from 'marked';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Render Markdown safely as HTML using marked and DOMPurify.
|
|
7
|
-
* Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
|
|
8
|
-
* embedded directly in templates.
|
|
9
|
-
*
|
|
10
|
-
* @param {string} markdown - Markdown source text
|
|
11
|
-
*/
|
|
12
|
-
export function renderMarkdown(markdown) {
|
|
13
|
-
const parsed = /** @type {string} */ (marked.parse(markdown));
|
|
14
|
-
const html_string = DOMPurify.sanitize(parsed);
|
|
15
|
-
return unsafeHTML(html_string);
|
|
16
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { priority_levels } from './priority.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create a colored badge for a priority value (0..4).
|
|
5
|
-
*
|
|
6
|
-
* @param {number | null | undefined} priority
|
|
7
|
-
* @returns {HTMLSpanElement}
|
|
8
|
-
*/
|
|
9
|
-
export function createPriorityBadge(priority) {
|
|
10
|
-
const p = typeof priority === 'number' ? priority : 2;
|
|
11
|
-
const el = document.createElement('span');
|
|
12
|
-
el.className = 'priority-badge';
|
|
13
|
-
el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
|
|
14
|
-
el.setAttribute('role', 'img');
|
|
15
|
-
const label = labelForPriority(p);
|
|
16
|
-
el.setAttribute('title', label);
|
|
17
|
-
el.setAttribute('aria-label', `Priority: ${label}`);
|
|
18
|
-
el.textContent = emojiForPriority(p) + ' ' + label;
|
|
19
|
-
return el;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @param {number} p
|
|
24
|
-
*/
|
|
25
|
-
function labelForPriority(p) {
|
|
26
|
-
const i = Math.max(0, Math.min(4, p));
|
|
27
|
-
return priority_levels[i] || 'Medium';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @param {number} p
|
|
32
|
-
*/
|
|
33
|
-
export function emojiForPriority(p) {
|
|
34
|
-
switch (p) {
|
|
35
|
-
case 0:
|
|
36
|
-
return '🔥';
|
|
37
|
-
case 1:
|
|
38
|
-
return '⚡️';
|
|
39
|
-
case 2:
|
|
40
|
-
return '🔧';
|
|
41
|
-
case 3:
|
|
42
|
-
return '🪶';
|
|
43
|
-
case 4:
|
|
44
|
-
return '💤';
|
|
45
|
-
default:
|
|
46
|
-
return '🔧';
|
|
47
|
-
}
|
|
48
|
-
}
|
package/app/utils/priority.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const priority_levels = ['Critical', 'High', 'Medium', 'Low', 'Backlog'];
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a colored badge for a status value.
|
|
3
|
-
*
|
|
4
|
-
* @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
|
|
5
|
-
* @returns {HTMLSpanElement}
|
|
6
|
-
*/
|
|
7
|
-
export function createStatusBadge(status) {
|
|
8
|
-
const el = document.createElement('span');
|
|
9
|
-
el.className = 'status-badge';
|
|
10
|
-
const s = String(status || 'open');
|
|
11
|
-
el.classList.add(`is-${s}`);
|
|
12
|
-
el.setAttribute('role', 'img');
|
|
13
|
-
el.setAttribute('title', labelForStatus(s));
|
|
14
|
-
el.setAttribute('aria-label', `Status: ${labelForStatus(s)}`);
|
|
15
|
-
el.textContent = labelForStatus(s);
|
|
16
|
-
return el;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {string} s
|
|
21
|
-
*/
|
|
22
|
-
function labelForStatus(s) {
|
|
23
|
-
switch (s) {
|
|
24
|
-
case 'open':
|
|
25
|
-
return 'Open';
|
|
26
|
-
case 'in_progress':
|
|
27
|
-
return 'In progress';
|
|
28
|
-
case 'closed':
|
|
29
|
-
return 'Closed';
|
|
30
|
-
default:
|
|
31
|
-
return 'Unknown';
|
|
32
|
-
}
|
|
33
|
-
}
|
package/app/utils/status.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Known status values in canonical order.
|
|
3
|
-
*
|
|
4
|
-
* @type {Array<'open'|'in_progress'|'closed'>}
|
|
5
|
-
*/
|
|
6
|
-
export const STATUSES = ['open', 'in_progress', 'closed'];
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Map canonical status to display label.
|
|
10
|
-
*
|
|
11
|
-
* @param {string | null | undefined} status
|
|
12
|
-
* @returns {string}
|
|
13
|
-
*/
|
|
14
|
-
export function statusLabel(status) {
|
|
15
|
-
switch ((status || '').toString()) {
|
|
16
|
-
case 'open':
|
|
17
|
-
return 'Open';
|
|
18
|
-
case 'in_progress':
|
|
19
|
-
return 'In progress';
|
|
20
|
-
case 'closed':
|
|
21
|
-
return 'Closed';
|
|
22
|
-
default:
|
|
23
|
-
return (status || '').toString() || 'Open';
|
|
24
|
-
}
|
|
25
|
-
}
|
package/app/utils/toast.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Show a transient global toast message anchored to the viewport.
|
|
3
|
-
*
|
|
4
|
-
* @param {string} text - Message text.
|
|
5
|
-
* @param {'info'|'success'|'error'} [variant] - Visual variant.
|
|
6
|
-
* @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
|
|
7
|
-
*/
|
|
8
|
-
export function showToast(text, variant = 'info', duration_ms = 2800) {
|
|
9
|
-
const el = document.createElement('div');
|
|
10
|
-
el.className = 'toast';
|
|
11
|
-
el.textContent = text;
|
|
12
|
-
el.style.position = 'fixed';
|
|
13
|
-
el.style.right = '12px';
|
|
14
|
-
el.style.bottom = '12px';
|
|
15
|
-
el.style.zIndex = '1000';
|
|
16
|
-
el.style.color = '#fff';
|
|
17
|
-
el.style.padding = '8px 10px';
|
|
18
|
-
el.style.borderRadius = '4px';
|
|
19
|
-
el.style.fontSize = '12px';
|
|
20
|
-
if (variant === 'success') {
|
|
21
|
-
el.style.background = '#156d36';
|
|
22
|
-
} else if (variant === 'error') {
|
|
23
|
-
el.style.background = '#9f2011';
|
|
24
|
-
} else {
|
|
25
|
-
el.style.background = 'rgba(0,0,0,0.85)';
|
|
26
|
-
}
|
|
27
|
-
(document.body || document.documentElement).appendChild(el);
|
|
28
|
-
setTimeout(() => {
|
|
29
|
-
try {
|
|
30
|
-
el.remove();
|
|
31
|
-
} catch {
|
|
32
|
-
/* ignore */
|
|
33
|
-
}
|
|
34
|
-
}, duration_ms);
|
|
35
|
-
}
|
package/app/utils/type-badge.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a compact, colored badge for an issue type.
|
|
3
|
-
*
|
|
4
|
-
* @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
|
|
5
|
-
* @returns {HTMLSpanElement}
|
|
6
|
-
*/
|
|
7
|
-
export function createTypeBadge(issue_type) {
|
|
8
|
-
const el = document.createElement('span');
|
|
9
|
-
el.className = 'type-badge';
|
|
10
|
-
|
|
11
|
-
const t = (issue_type || '').toString().toLowerCase();
|
|
12
|
-
const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
|
|
13
|
-
const kind = KNOWN.has(t) ? t : 'neutral';
|
|
14
|
-
el.classList.add(`type-badge--${kind}`);
|
|
15
|
-
el.setAttribute('role', 'img');
|
|
16
|
-
const label = KNOWN.has(t)
|
|
17
|
-
? t === 'bug'
|
|
18
|
-
? 'Bug'
|
|
19
|
-
: t === 'feature'
|
|
20
|
-
? 'Feature'
|
|
21
|
-
: t === 'task'
|
|
22
|
-
? 'Task'
|
|
23
|
-
: t === 'epic'
|
|
24
|
-
? 'Epic'
|
|
25
|
-
: 'Chore'
|
|
26
|
-
: '—';
|
|
27
|
-
el.setAttribute(
|
|
28
|
-
'aria-label',
|
|
29
|
-
KNOWN.has(t) ? `Issue type: ${label}` : 'Issue type: unknown'
|
|
30
|
-
);
|
|
31
|
-
el.setAttribute('title', KNOWN.has(t) ? `Type: ${label}` : 'Type: unknown');
|
|
32
|
-
el.textContent = label;
|
|
33
|
-
return el;
|
|
34
|
-
}
|