beads-ui 0.2.0 → 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 +5 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +5 -138
- 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/main.js +342 -66
- package/app/protocol.js +10 -14
- package/app/protocol.md +18 -15
- package/app/styles.css +222 -197
- 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 +0 -1
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +166 -144
- package/app/views/detail.js +76 -66
- package/app/views/epics.js +126 -74
- package/app/views/issue-dialog.js +8 -15
- package/app/views/issue-row.js +1 -3
- package/app/views/list.js +101 -104
- package/app/views/new-issue-dialog.js +27 -34
- package/app/ws.js +6 -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 +34 -84
- 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 +4 -2
- package/server/bd.js +0 -2
- package/server/cli/commands.js +1 -2
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +0 -2
- package/server/cli/usage.js +1 -1
- 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 +5 -8
- package/server/ws.js +449 -230
package/app/utils/markdown.js
CHANGED
|
@@ -1,201 +1,22 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
import { html } from 'lit-html';
|
|
3
|
+
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
4
|
+
import { marked } from 'marked';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
7
|
+
* Render Markdown safely as HTML using marked and DOMPurify.
|
|
8
|
+
* Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
|
|
9
|
+
* embedded directly in templates.
|
|
5
10
|
* @function renderMarkdown
|
|
6
11
|
* @param {string} src Markdown source text
|
|
7
|
-
* @returns {
|
|
12
|
+
* @returns {import('lit-html').TemplateResult}
|
|
8
13
|
*/
|
|
9
14
|
export function renderMarkdown(src) {
|
|
10
|
-
/** @type {DocumentFragment} */
|
|
11
|
-
const frag = document.createDocumentFragment();
|
|
12
|
-
|
|
13
|
-
/** @type {string[]} */
|
|
14
|
-
const lines = (src || '').replace(/\r\n?/g, '\n').split('\n');
|
|
15
|
-
|
|
16
|
-
/** @type {boolean} */
|
|
17
|
-
let in_code = false;
|
|
18
|
-
/** @type {string[]} */
|
|
19
|
-
let code_acc = [];
|
|
20
|
-
|
|
21
|
-
/** @type {HTMLElement|null} */
|
|
22
|
-
let list_el = null;
|
|
23
15
|
/** @type {string} */
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
function flushParagraph(buf) {
|
|
31
|
-
if (buf.length === 0) {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const p = document.createElement('p');
|
|
35
|
-
appendInline(p, buf.join(' '));
|
|
36
|
-
frag.appendChild(p);
|
|
37
|
-
buf.length = 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** @type {string[]} */
|
|
41
|
-
const para = [];
|
|
42
|
-
|
|
43
|
-
for (let i = 0; i < lines.length; i++) {
|
|
44
|
-
const raw = lines[i];
|
|
45
|
-
const line = raw;
|
|
46
|
-
|
|
47
|
-
// fenced code blocks
|
|
48
|
-
if (/^```/.test(line)) {
|
|
49
|
-
if (!in_code) {
|
|
50
|
-
in_code = true;
|
|
51
|
-
code_acc = [];
|
|
52
|
-
} else {
|
|
53
|
-
// flush code block
|
|
54
|
-
const pre = document.createElement('pre');
|
|
55
|
-
const code = document.createElement('code');
|
|
56
|
-
code.textContent = code_acc.join('\n');
|
|
57
|
-
pre.appendChild(code);
|
|
58
|
-
frag.appendChild(pre);
|
|
59
|
-
in_code = false;
|
|
60
|
-
code_acc = [];
|
|
61
|
-
}
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (in_code) {
|
|
65
|
-
code_acc.push(raw);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// blank line -> paragraph / list break
|
|
70
|
-
if (/^\s*$/.test(line)) {
|
|
71
|
-
flushParagraph(para);
|
|
72
|
-
if (list_el !== null) {
|
|
73
|
-
frag.appendChild(list_el);
|
|
74
|
-
list_el = null;
|
|
75
|
-
list_type = '';
|
|
76
|
-
}
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// heading
|
|
81
|
-
const hx = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
82
|
-
if (hx) {
|
|
83
|
-
flushParagraph(para);
|
|
84
|
-
if (list_el !== null) {
|
|
85
|
-
frag.appendChild(list_el);
|
|
86
|
-
list_el = null;
|
|
87
|
-
list_type = '';
|
|
88
|
-
}
|
|
89
|
-
const level = Math.min(6, hx[1].length);
|
|
90
|
-
const h = /** @type {HTMLHeadingElement} */ (
|
|
91
|
-
document.createElement('h' + String(level))
|
|
92
|
-
);
|
|
93
|
-
appendInline(h, hx[2]);
|
|
94
|
-
frag.appendChild(h);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// list item
|
|
99
|
-
let m = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
100
|
-
if (m) {
|
|
101
|
-
if (list_el === null || list_type !== 'ul') {
|
|
102
|
-
flushParagraph(para);
|
|
103
|
-
if (list_el !== null) {
|
|
104
|
-
frag.appendChild(list_el);
|
|
105
|
-
}
|
|
106
|
-
list_el = document.createElement('ul');
|
|
107
|
-
list_type = 'ul';
|
|
108
|
-
}
|
|
109
|
-
const li = document.createElement('li');
|
|
110
|
-
appendInline(li, m[1]);
|
|
111
|
-
list_el.appendChild(li);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
m = /^\s*(\d+)\.\s+(.*)$/.exec(line);
|
|
115
|
-
if (m) {
|
|
116
|
-
if (list_el === null || list_type !== 'ol') {
|
|
117
|
-
flushParagraph(para);
|
|
118
|
-
if (list_el !== null) {
|
|
119
|
-
frag.appendChild(list_el);
|
|
120
|
-
}
|
|
121
|
-
list_el = document.createElement('ol');
|
|
122
|
-
list_type = 'ol';
|
|
123
|
-
}
|
|
124
|
-
const li = document.createElement('li');
|
|
125
|
-
appendInline(li, m[2]);
|
|
126
|
-
list_el.appendChild(li);
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// otherwise: paragraph text; accumulate to join with spaces
|
|
131
|
-
para.push(line.trim());
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// flush leftovers
|
|
135
|
-
if (in_code) {
|
|
136
|
-
const pre = document.createElement('pre');
|
|
137
|
-
const code = document.createElement('code');
|
|
138
|
-
code.textContent = code_acc.join('\n');
|
|
139
|
-
pre.appendChild(code);
|
|
140
|
-
frag.appendChild(pre);
|
|
141
|
-
}
|
|
142
|
-
flushParagraph(para);
|
|
143
|
-
if (list_el !== null) {
|
|
144
|
-
frag.appendChild(list_el);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return frag;
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Append inline content parsing backticks and links safely.
|
|
151
|
-
* - Inline code: `text`
|
|
152
|
-
* - Links: [text](url) where url starts with http, https, or mailto
|
|
153
|
-
* @param {HTMLElement} el
|
|
154
|
-
* @param {string} text
|
|
155
|
-
*/
|
|
156
|
-
function appendInline(el, text) {
|
|
157
|
-
/** @type {RegExp} */
|
|
158
|
-
const re = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))/g;
|
|
159
|
-
/** @type {number} */
|
|
160
|
-
let last = 0;
|
|
161
|
-
/** @type {any} */
|
|
162
|
-
let match = re.exec(text);
|
|
163
|
-
while (match) {
|
|
164
|
-
const idx = match.index;
|
|
165
|
-
if (idx > last) {
|
|
166
|
-
el.appendChild(document.createTextNode(text.slice(last, idx)));
|
|
167
|
-
}
|
|
168
|
-
const token = match[0];
|
|
169
|
-
if (token[0] === '\u0060') {
|
|
170
|
-
const code = document.createElement('code');
|
|
171
|
-
code.textContent = token.slice(1, -1);
|
|
172
|
-
el.appendChild(code);
|
|
173
|
-
} else if (token[0] === '[') {
|
|
174
|
-
// parse [text](url)
|
|
175
|
-
const m2 = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
|
|
176
|
-
if (m2) {
|
|
177
|
-
const a = document.createElement('a');
|
|
178
|
-
const href = m2[2].trim();
|
|
179
|
-
const allowed = /^(https?:|mailto:)/i.test(href);
|
|
180
|
-
if (allowed) {
|
|
181
|
-
a.setAttribute('href', href);
|
|
182
|
-
} else {
|
|
183
|
-
// if not allowed, render as text to avoid XSS vectors
|
|
184
|
-
a.remove();
|
|
185
|
-
el.appendChild(document.createTextNode(m2[1] + ' (' + href + ')'));
|
|
186
|
-
last = re.lastIndex;
|
|
187
|
-
match = re.exec(text);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
a.appendChild(document.createTextNode(m2[1]));
|
|
191
|
-
el.appendChild(a);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
last = re.lastIndex;
|
|
195
|
-
match = re.exec(text);
|
|
196
|
-
}
|
|
197
|
-
if (last < text.length) {
|
|
198
|
-
el.appendChild(document.createTextNode(text.slice(last)));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
16
|
+
const markdown = String(src || '');
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
const parsed = /** @type {string} */ (marked.parse(markdown));
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
const html_string = DOMPurify.sanitize(parsed);
|
|
21
|
+
return html`${unsafeHTML(html_string)}`;
|
|
201
22
|
}
|
|
@@ -6,9 +6,7 @@ import { priority_levels } from './priority.js';
|
|
|
6
6
|
* @returns {HTMLSpanElement}
|
|
7
7
|
*/
|
|
8
8
|
export function createPriorityBadge(priority) {
|
|
9
|
-
/** @type {number} */
|
|
10
9
|
const p = typeof priority === 'number' ? priority : 2;
|
|
11
|
-
/** @type {HTMLSpanElement} */
|
|
12
10
|
const el = document.createElement('span');
|
|
13
11
|
el.className = 'priority-badge';
|
|
14
12
|
el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
|
package/app/utils/toast.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
|
|
6
6
|
*/
|
|
7
7
|
export function showToast(text, variant = 'info', duration_ms = 2800) {
|
|
8
|
-
/** @type {HTMLDivElement} */
|
|
9
8
|
const el = document.createElement('div');
|
|
10
9
|
el.className = 'toast';
|
|
11
10
|
el.textContent = text;
|
package/app/utils/type-badge.js
CHANGED
|
@@ -4,13 +4,10 @@
|
|
|
4
4
|
* @returns {HTMLSpanElement}
|
|
5
5
|
*/
|
|
6
6
|
export function createTypeBadge(issue_type) {
|
|
7
|
-
/** @type {HTMLSpanElement} */
|
|
8
7
|
const el = document.createElement('span');
|
|
9
8
|
el.className = 'type-badge';
|
|
10
9
|
|
|
11
|
-
/** @type {string} */
|
|
12
10
|
const t = (issue_type || '').toString().toLowerCase();
|
|
13
|
-
/** @type {Set<string>} */
|
|
14
11
|
const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
|
|
15
12
|
const kind = KNOWN.has(t) ? t : 'neutral';
|
|
16
13
|
el.classList.add(`type-badge--${kind}`);
|
package/app/views/board.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { html, render } from 'lit-html';
|
|
2
|
+
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
+
import { cmpClosedDesc, cmpPriorityThenCreated } from '../data/sort.js';
|
|
2
4
|
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
5
|
import { createPriorityBadge } from '../utils/priority-badge.js';
|
|
4
6
|
import { createTypeBadge } from '../utils/type-badge.js';
|
|
@@ -10,29 +12,35 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
10
12
|
* status?: 'open'|'in_progress'|'closed',
|
|
11
13
|
* priority?: number,
|
|
12
14
|
* issue_type?: string,
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* created_at?: number,
|
|
16
|
+
* updated_at?: number,
|
|
17
|
+
* closed_at?: number
|
|
15
18
|
* }} IssueLite
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
* Create the Board view with
|
|
20
|
-
*
|
|
22
|
+
* Create the Board view with Blocked, Ready, In progress, Closed.
|
|
23
|
+
* Push-only: derives items from per-subscription stores.
|
|
21
24
|
*
|
|
22
25
|
* Sorting rules:
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
25
|
-
* - In progress: updated_at desc
|
|
26
|
-
* - Closed: closed_at desc (fallback to updated_at)
|
|
26
|
+
* - Ready/Blocked/In progress: priority asc, then created_at asc
|
|
27
|
+
* - Closed: closed_at desc
|
|
27
28
|
* @param {HTMLElement} mount_element
|
|
28
|
-
* @param {
|
|
29
|
+
* @param {unknown} _data - Unused (legacy param retained for call-compat)
|
|
29
30
|
* @param {(id: string) => void} gotoIssue - Navigate to issue detail.
|
|
30
31
|
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe?: (fn: (s:any)=>void)=>()=>void }} [store]
|
|
32
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
|
|
33
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
|
|
31
34
|
* @returns {{ load: () => Promise<void>, clear: () => void }}
|
|
32
35
|
*/
|
|
33
|
-
export function createBoardView(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
export function createBoardView(
|
|
37
|
+
mount_element,
|
|
38
|
+
_data,
|
|
39
|
+
gotoIssue,
|
|
40
|
+
store,
|
|
41
|
+
subscriptions = undefined,
|
|
42
|
+
issueStores = undefined
|
|
43
|
+
) {
|
|
36
44
|
/** @type {IssueLite[]} */
|
|
37
45
|
let list_ready = [];
|
|
38
46
|
/** @type {IssueLite[]} */
|
|
@@ -43,6 +51,8 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
43
51
|
let list_closed = [];
|
|
44
52
|
/** @type {IssueLite[]} */
|
|
45
53
|
let list_closed_raw = [];
|
|
54
|
+
// Centralized selection helpers
|
|
55
|
+
const selectors = issueStores ? createListSelectors(issueStores) : null;
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Closed column filter mode.
|
|
@@ -67,10 +77,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
67
77
|
function template() {
|
|
68
78
|
return html`
|
|
69
79
|
<div class="panel__body board-root">
|
|
70
|
-
|
|
71
|
-
${columnTemplate('Open', 'open-col', list_open)}
|
|
72
|
-
${columnTemplate('Blocked', 'blocked-col', list_blocked)}
|
|
73
|
-
</div>
|
|
80
|
+
${columnTemplate('Blocked', 'blocked-col', list_blocked)}
|
|
74
81
|
${columnTemplate('Ready', 'ready-col', list_ready)}
|
|
75
82
|
${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
|
|
76
83
|
${columnTemplate('Closed', 'closed-col', list_closed)}
|
|
@@ -144,8 +151,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
144
151
|
${it.title || '(no title)'}
|
|
145
152
|
</div>
|
|
146
153
|
<div class="board-card__meta">
|
|
147
|
-
${createTypeBadge(
|
|
148
|
-
${createPriorityBadge(/** @type {any} */ (it).priority)}
|
|
154
|
+
${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
|
|
149
155
|
${createIssueIdRenderer(it.id, { class_name: 'mono' })}
|
|
150
156
|
</div>
|
|
151
157
|
</article>
|
|
@@ -171,8 +177,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
171
177
|
mount_element.querySelectorAll('.board-column')
|
|
172
178
|
);
|
|
173
179
|
for (const col of columns) {
|
|
174
|
-
/** @type {HTMLElement|null} */
|
|
175
|
-
const body = /** @type {any} */ (
|
|
180
|
+
const body = /** @type {HTMLElement|null} */ (
|
|
176
181
|
col.querySelector('.board-column__body')
|
|
177
182
|
);
|
|
178
183
|
if (!body) {
|
|
@@ -208,8 +213,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
208
213
|
|
|
209
214
|
// Delegate keyboard handling from mount_element
|
|
210
215
|
mount_element.addEventListener('keydown', (ev) => {
|
|
211
|
-
|
|
212
|
-
const target = /** @type {any} */ (ev.target);
|
|
216
|
+
const target = ev.target;
|
|
213
217
|
if (!target || !(target instanceof HTMLElement)) {
|
|
214
218
|
return;
|
|
215
219
|
}
|
|
@@ -219,7 +223,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
219
223
|
tag === 'input' ||
|
|
220
224
|
tag === 'textarea' ||
|
|
221
225
|
tag === 'select' ||
|
|
222
|
-
|
|
226
|
+
target.isContentEditable === true
|
|
223
227
|
) {
|
|
224
228
|
return;
|
|
225
229
|
}
|
|
@@ -230,9 +234,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
230
234
|
const key = String(ev.key || '');
|
|
231
235
|
if (key === 'Enter' || key === ' ') {
|
|
232
236
|
ev.preventDefault();
|
|
233
|
-
const id =
|
|
234
|
-
'data-issue-id'
|
|
235
|
-
);
|
|
237
|
+
const id = card.getAttribute('data-issue-id');
|
|
236
238
|
if (id) {
|
|
237
239
|
gotoIssue(id);
|
|
238
240
|
}
|
|
@@ -252,9 +254,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
252
254
|
if (!col) {
|
|
253
255
|
return;
|
|
254
256
|
}
|
|
255
|
-
const body =
|
|
256
|
-
col.querySelector('.board-column__body')
|
|
257
|
-
);
|
|
257
|
+
const body = col.querySelector('.board-column__body');
|
|
258
258
|
if (!body) {
|
|
259
259
|
return;
|
|
260
260
|
}
|
|
@@ -324,47 +324,7 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
|
|
328
|
-
* Sort helpers.
|
|
329
|
-
*/
|
|
330
|
-
/**
|
|
331
|
-
* @param {IssueLite[]} arr
|
|
332
|
-
*/
|
|
333
|
-
function sortReady(arr) {
|
|
334
|
-
arr.sort((a, b) => {
|
|
335
|
-
const pa = a.priority ?? 2;
|
|
336
|
-
const pb = b.priority ?? 2;
|
|
337
|
-
if (pa !== pb) {
|
|
338
|
-
return pa - pb;
|
|
339
|
-
}
|
|
340
|
-
const ua = a.updated_at || '';
|
|
341
|
-
const ub = b.updated_at || '';
|
|
342
|
-
return ua < ub ? 1 : ua > ub ? -1 : 0;
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* @param {IssueLite[]} arr
|
|
348
|
-
*/
|
|
349
|
-
function sortByUpdatedDesc(arr) {
|
|
350
|
-
arr.sort((a, b) => {
|
|
351
|
-
const ua = a.updated_at || '';
|
|
352
|
-
const ub = b.updated_at || '';
|
|
353
|
-
return ua < ub ? 1 : ua > ub ? -1 : 0;
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Sort by closed_at desc with updated_at fallback.
|
|
359
|
-
* @param {IssueLite[]} arr
|
|
360
|
-
*/
|
|
361
|
-
function sortByClosedDesc(arr) {
|
|
362
|
-
arr.sort((a, b) => {
|
|
363
|
-
const ca = a.closed_at || a.updated_at || '';
|
|
364
|
-
const cb = b.closed_at || b.updated_at || '';
|
|
365
|
-
return ca < cb ? 1 : ca > cb ? -1 : 0;
|
|
366
|
-
});
|
|
367
|
-
}
|
|
327
|
+
// Sort helpers centralized in app/data/sort.js
|
|
368
328
|
|
|
369
329
|
/**
|
|
370
330
|
* Recompute closed list from raw using the current filter and sort.
|
|
@@ -373,7 +333,6 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
373
333
|
/** @type {IssueLite[]} */
|
|
374
334
|
let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
|
|
375
335
|
const now = new Date();
|
|
376
|
-
/** @type {number} */
|
|
377
336
|
let since_ts = 0;
|
|
378
337
|
if (closed_filter_mode === 'today') {
|
|
379
338
|
const start = new Date(
|
|
@@ -392,14 +351,15 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
392
351
|
since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
|
|
393
352
|
}
|
|
394
353
|
items = items.filter((it) => {
|
|
395
|
-
const s = it.closed_at
|
|
396
|
-
|
|
354
|
+
const s = Number.isFinite(it.closed_at)
|
|
355
|
+
? /** @type {number} */ (it.closed_at)
|
|
356
|
+
: NaN;
|
|
357
|
+
if (!Number.isFinite(s)) {
|
|
397
358
|
return false;
|
|
398
359
|
}
|
|
399
|
-
|
|
400
|
-
return t >= since_ts;
|
|
360
|
+
return s >= since_ts;
|
|
401
361
|
});
|
|
402
|
-
|
|
362
|
+
items.sort(cmpClosedDesc);
|
|
403
363
|
list_closed = items;
|
|
404
364
|
}
|
|
405
365
|
|
|
@@ -425,85 +385,147 @@ export function createBoardView(mount_element, data, gotoIssue, store) {
|
|
|
425
385
|
}
|
|
426
386
|
}
|
|
427
387
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
try {
|
|
451
|
-
// getBlocked is optional for backward compatibility in tests
|
|
452
|
-
const fn = /** @type {any} */ (data).getBlocked;
|
|
453
|
-
b = typeof fn === 'function' ? /** @type {any} */ (await fn()) : [];
|
|
454
|
-
} catch {
|
|
455
|
-
b = [];
|
|
456
|
-
}
|
|
457
|
-
try {
|
|
458
|
-
p = /** @type {any} */ (await data.getInProgress());
|
|
459
|
-
} catch {
|
|
460
|
-
p = [];
|
|
461
|
-
}
|
|
462
|
-
try {
|
|
463
|
-
c = /** @type {any} */ (await data.getClosed());
|
|
464
|
-
} catch {
|
|
465
|
-
c = [];
|
|
466
|
-
}
|
|
388
|
+
/**
|
|
389
|
+
* Compose lists from subscriptions + issues store and render.
|
|
390
|
+
*/
|
|
391
|
+
function refreshFromStores() {
|
|
392
|
+
try {
|
|
393
|
+
if (selectors) {
|
|
394
|
+
const in_progress = selectors.selectBoardColumn(
|
|
395
|
+
'tab:board:in-progress',
|
|
396
|
+
'in_progress'
|
|
397
|
+
);
|
|
398
|
+
const blocked = selectors.selectBoardColumn(
|
|
399
|
+
'tab:board:blocked',
|
|
400
|
+
'blocked'
|
|
401
|
+
);
|
|
402
|
+
const ready_raw = selectors.selectBoardColumn(
|
|
403
|
+
'tab:board:ready',
|
|
404
|
+
'ready'
|
|
405
|
+
);
|
|
406
|
+
const closed = selectors.selectBoardColumn(
|
|
407
|
+
'tab:board:closed',
|
|
408
|
+
'closed'
|
|
409
|
+
);
|
|
467
410
|
|
|
468
|
-
|
|
469
|
-
if (o.length > 0 && r.length > 0) {
|
|
411
|
+
// Ready excludes items that are in progress
|
|
470
412
|
/** @type {Set<string>} */
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
}
|
|
413
|
+
const in_prog_ids = new Set(in_progress.map((i) => i.id));
|
|
414
|
+
const ready = ready_raw.filter((i) => !in_prog_ids.has(i.id));
|
|
474
415
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
o = o.filter((it) => !blocked_ids.has(it.id));
|
|
416
|
+
list_ready = ready;
|
|
417
|
+
list_blocked = blocked;
|
|
418
|
+
list_in_progress = in_progress;
|
|
419
|
+
list_closed_raw = closed;
|
|
480
420
|
}
|
|
421
|
+
applyClosedFilter();
|
|
422
|
+
doRender();
|
|
423
|
+
} catch {
|
|
424
|
+
list_ready = [];
|
|
425
|
+
list_blocked = [];
|
|
426
|
+
list_in_progress = [];
|
|
427
|
+
list_closed = [];
|
|
428
|
+
doRender();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
481
431
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
432
|
+
// Live updates: recompose on issue store envelopes
|
|
433
|
+
if (selectors) {
|
|
434
|
+
selectors.subscribe(() => {
|
|
435
|
+
try {
|
|
436
|
+
refreshFromStores();
|
|
437
|
+
} catch {
|
|
438
|
+
// ignore
|
|
487
439
|
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
488
442
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
443
|
+
return {
|
|
444
|
+
async load() {
|
|
445
|
+
// Compose lists from subscriptions + issues store
|
|
446
|
+
refreshFromStores();
|
|
447
|
+
// If nothing is present yet (e.g., immediately after switching back
|
|
448
|
+
// to the Board and before list-delta arrives), fetch via data layer as
|
|
449
|
+
// a fallback so the board is not empty on initial display.
|
|
450
|
+
try {
|
|
451
|
+
const has_subs = Boolean(subscriptions && subscriptions.selectors);
|
|
452
|
+
/**
|
|
453
|
+
* @param {string} id
|
|
454
|
+
*/
|
|
455
|
+
const cnt = (id) => {
|
|
456
|
+
if (!has_subs || !subscriptions) {
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
const sel = subscriptions.selectors;
|
|
460
|
+
if (typeof sel.count === 'function') {
|
|
461
|
+
return Number(sel.count(id) || 0);
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const arr = sel.getIds(id);
|
|
465
|
+
return Array.isArray(arr) ? arr.length : 0;
|
|
466
|
+
} catch {
|
|
467
|
+
return 0;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
const total_items =
|
|
471
|
+
cnt('tab:board:ready') +
|
|
472
|
+
cnt('tab:board:blocked') +
|
|
473
|
+
cnt('tab:board:in-progress') +
|
|
474
|
+
cnt('tab:board:closed');
|
|
475
|
+
const data = /** @type {any} */ (_data);
|
|
476
|
+
const can_fetch =
|
|
477
|
+
data &&
|
|
478
|
+
typeof data.getReady === 'function' &&
|
|
479
|
+
typeof data.getBlocked === 'function' &&
|
|
480
|
+
typeof data.getInProgress === 'function' &&
|
|
481
|
+
typeof data.getClosed === 'function';
|
|
482
|
+
if (total_items === 0 && can_fetch) {
|
|
483
|
+
/** @type {[IssueLite[], IssueLite[], IssueLite[], IssueLite[]]} */
|
|
484
|
+
const [ready_raw, blocked_raw, in_prog_raw, closed_raw] =
|
|
485
|
+
await Promise.all([
|
|
486
|
+
data.getReady().catch(() => []),
|
|
487
|
+
data.getBlocked().catch(() => []),
|
|
488
|
+
data.getInProgress().catch(() => []),
|
|
489
|
+
data.getClosed().catch(() => [])
|
|
490
|
+
]);
|
|
491
|
+
// Normalize and map unknowns to IssueLite shape
|
|
492
|
+
/** @type {IssueLite[]} */
|
|
493
|
+
let ready = Array.isArray(ready_raw) ? ready_raw.map((it) => it) : [];
|
|
494
|
+
/** @type {IssueLite[]} */
|
|
495
|
+
const blocked = Array.isArray(blocked_raw)
|
|
496
|
+
? blocked_raw.map((it) => it)
|
|
497
|
+
: [];
|
|
498
|
+
/** @type {IssueLite[]} */
|
|
499
|
+
const in_prog = Array.isArray(in_prog_raw)
|
|
500
|
+
? in_prog_raw.map((it) => it)
|
|
501
|
+
: [];
|
|
502
|
+
/** @type {IssueLite[]} */
|
|
503
|
+
const closed = Array.isArray(closed_raw)
|
|
504
|
+
? closed_raw.map((it) => it)
|
|
505
|
+
: [];
|
|
495
506
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
// Remove items from Ready that are already In Progress
|
|
508
|
+
/** @type {Set<string>} */
|
|
509
|
+
const in_progress_ids = new Set(in_prog.map((i) => i.id));
|
|
510
|
+
ready = ready.filter((i) => !in_progress_ids.has(i.id));
|
|
511
|
+
|
|
512
|
+
// Sort as per column rules
|
|
513
|
+
ready.sort(cmpPriorityThenCreated);
|
|
514
|
+
blocked.sort(cmpPriorityThenCreated);
|
|
515
|
+
in_prog.sort(cmpPriorityThenCreated);
|
|
516
|
+
list_ready = ready;
|
|
517
|
+
list_blocked = blocked;
|
|
518
|
+
list_in_progress = in_prog;
|
|
519
|
+
list_closed_raw = closed;
|
|
520
|
+
applyClosedFilter();
|
|
521
|
+
doRender();
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
// ignore fallback errors
|
|
525
|
+
}
|
|
503
526
|
},
|
|
504
527
|
clear() {
|
|
505
528
|
mount_element.replaceChildren();
|
|
506
|
-
list_open = [];
|
|
507
529
|
list_ready = [];
|
|
508
530
|
list_blocked = [];
|
|
509
531
|
list_in_progress = [];
|