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.
- package/CHANGES.md +26 -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 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- 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
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a colored badge for a status value.
|
|
3
|
-
* @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
|
|
4
|
-
* @returns {HTMLSpanElement}
|
|
5
|
-
*/
|
|
6
|
-
export function createStatusBadge(status) {
|
|
7
|
-
const el = document.createElement('span');
|
|
8
|
-
el.className = 'status-badge';
|
|
9
|
-
const s = String(status || 'open');
|
|
10
|
-
el.classList.add(`is-${s}`);
|
|
11
|
-
el.setAttribute('role', 'img');
|
|
12
|
-
el.setAttribute('title', labelForStatus(s));
|
|
13
|
-
el.setAttribute('aria-label', `Status: ${labelForStatus(s)}`);
|
|
14
|
-
el.textContent = labelForStatus(s);
|
|
15
|
-
return el;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* @param {string} s
|
|
20
|
-
*/
|
|
21
|
-
function labelForStatus(s) {
|
|
22
|
-
switch (s) {
|
|
23
|
-
case 'open':
|
|
24
|
-
return 'Open';
|
|
25
|
-
case 'in_progress':
|
|
26
|
-
return 'In progress';
|
|
27
|
-
case 'closed':
|
|
28
|
-
return 'Closed';
|
|
29
|
-
default:
|
|
30
|
-
return 'Unknown';
|
|
31
|
-
}
|
|
32
|
-
}
|
package/app/utils/status.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Known status values in canonical order.
|
|
3
|
-
* @type {Array<'open'|'in_progress'|'closed'>}
|
|
4
|
-
*/
|
|
5
|
-
export const STATUSES = ['open', 'in_progress', 'closed'];
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Map canonical status to display label.
|
|
9
|
-
* @param {string | null | undefined} status
|
|
10
|
-
* @returns {string}
|
|
11
|
-
*/
|
|
12
|
-
export function statusLabel(status) {
|
|
13
|
-
switch ((status || '').toString()) {
|
|
14
|
-
case 'open':
|
|
15
|
-
return 'Open';
|
|
16
|
-
case 'in_progress':
|
|
17
|
-
return 'In progress';
|
|
18
|
-
case 'closed':
|
|
19
|
-
return 'Closed';
|
|
20
|
-
default:
|
|
21
|
-
return (status || '').toString() || 'Open';
|
|
22
|
-
}
|
|
23
|
-
}
|
package/app/utils/toast.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Show a transient global toast message anchored to the viewport.
|
|
3
|
-
* @param {string} text - Message text.
|
|
4
|
-
* @param {'info'|'success'|'error'} [variant] - Visual variant.
|
|
5
|
-
* @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
|
|
6
|
-
*/
|
|
7
|
-
export function showToast(text, variant = 'info', duration_ms = 2800) {
|
|
8
|
-
const el = document.createElement('div');
|
|
9
|
-
el.className = 'toast';
|
|
10
|
-
el.textContent = text;
|
|
11
|
-
el.style.position = 'fixed';
|
|
12
|
-
el.style.right = '12px';
|
|
13
|
-
el.style.bottom = '12px';
|
|
14
|
-
el.style.zIndex = '1000';
|
|
15
|
-
el.style.color = '#fff';
|
|
16
|
-
el.style.padding = '8px 10px';
|
|
17
|
-
el.style.borderRadius = '4px';
|
|
18
|
-
el.style.fontSize = '12px';
|
|
19
|
-
if (variant === 'success') {
|
|
20
|
-
el.style.background = '#156d36';
|
|
21
|
-
} else if (variant === 'error') {
|
|
22
|
-
el.style.background = '#9f2011';
|
|
23
|
-
} else {
|
|
24
|
-
el.style.background = 'rgba(0,0,0,0.85)';
|
|
25
|
-
}
|
|
26
|
-
(document.body || document.documentElement).appendChild(el);
|
|
27
|
-
setTimeout(() => {
|
|
28
|
-
try {
|
|
29
|
-
el.remove();
|
|
30
|
-
} catch {
|
|
31
|
-
/* ignore */
|
|
32
|
-
}
|
|
33
|
-
}, duration_ms);
|
|
34
|
-
}
|
package/app/utils/type-badge.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Create a compact, colored badge for an issue type.
|
|
3
|
-
* @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
|
|
4
|
-
* @returns {HTMLSpanElement}
|
|
5
|
-
*/
|
|
6
|
-
export function createTypeBadge(issue_type) {
|
|
7
|
-
const el = document.createElement('span');
|
|
8
|
-
el.className = 'type-badge';
|
|
9
|
-
|
|
10
|
-
const t = (issue_type || '').toString().toLowerCase();
|
|
11
|
-
const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
|
|
12
|
-
const kind = KNOWN.has(t) ? t : 'neutral';
|
|
13
|
-
el.classList.add(`type-badge--${kind}`);
|
|
14
|
-
el.setAttribute('role', 'img');
|
|
15
|
-
const label = KNOWN.has(t)
|
|
16
|
-
? t === 'bug'
|
|
17
|
-
? 'Bug'
|
|
18
|
-
: t === 'feature'
|
|
19
|
-
? 'Feature'
|
|
20
|
-
: t === 'task'
|
|
21
|
-
? 'Task'
|
|
22
|
-
: t === 'epic'
|
|
23
|
-
? 'Epic'
|
|
24
|
-
: 'Chore'
|
|
25
|
-
: '—';
|
|
26
|
-
el.setAttribute(
|
|
27
|
-
'aria-label',
|
|
28
|
-
KNOWN.has(t) ? `Issue type: ${label}` : 'Issue type: unknown'
|
|
29
|
-
);
|
|
30
|
-
el.setAttribute('title', KNOWN.has(t) ? `Type: ${label}` : 'Type: unknown');
|
|
31
|
-
el.textContent = label;
|
|
32
|
-
return el;
|
|
33
|
-
}
|
package/app/views/board.js
DELETED
|
@@ -1,535 +0,0 @@
|
|
|
1
|
-
import { html, render } from 'lit-html';
|
|
2
|
-
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
-
import { cmpClosedDesc, cmpPriorityThenCreated } from '../data/sort.js';
|
|
4
|
-
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
5
|
-
import { createPriorityBadge } from '../utils/priority-badge.js';
|
|
6
|
-
import { createTypeBadge } from '../utils/type-badge.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @typedef {{
|
|
10
|
-
* id: string,
|
|
11
|
-
* title?: string,
|
|
12
|
-
* status?: 'open'|'in_progress'|'closed',
|
|
13
|
-
* priority?: number,
|
|
14
|
-
* issue_type?: string,
|
|
15
|
-
* created_at?: number,
|
|
16
|
-
* updated_at?: number,
|
|
17
|
-
* closed_at?: number
|
|
18
|
-
* }} IssueLite
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Create the Board view with Blocked, Ready, In progress, Closed.
|
|
23
|
-
* Push-only: derives items from per-subscription stores.
|
|
24
|
-
*
|
|
25
|
-
* Sorting rules:
|
|
26
|
-
* - Ready/Blocked/In progress: priority asc, then created_at asc
|
|
27
|
-
* - Closed: closed_at desc
|
|
28
|
-
* @param {HTMLElement} mount_element
|
|
29
|
-
* @param {unknown} _data - Unused (legacy param retained for call-compat)
|
|
30
|
-
* @param {(id: string) => void} gotoIssue - Navigate to issue detail.
|
|
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]
|
|
34
|
-
* @returns {{ load: () => Promise<void>, clear: () => void }}
|
|
35
|
-
*/
|
|
36
|
-
export function createBoardView(
|
|
37
|
-
mount_element,
|
|
38
|
-
_data,
|
|
39
|
-
gotoIssue,
|
|
40
|
-
store,
|
|
41
|
-
subscriptions = undefined,
|
|
42
|
-
issueStores = undefined
|
|
43
|
-
) {
|
|
44
|
-
/** @type {IssueLite[]} */
|
|
45
|
-
let list_ready = [];
|
|
46
|
-
/** @type {IssueLite[]} */
|
|
47
|
-
let list_blocked = [];
|
|
48
|
-
/** @type {IssueLite[]} */
|
|
49
|
-
let list_in_progress = [];
|
|
50
|
-
/** @type {IssueLite[]} */
|
|
51
|
-
let list_closed = [];
|
|
52
|
-
/** @type {IssueLite[]} */
|
|
53
|
-
let list_closed_raw = [];
|
|
54
|
-
// Centralized selection helpers
|
|
55
|
-
const selectors = issueStores ? createListSelectors(issueStores) : null;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Closed column filter mode.
|
|
59
|
-
* 'today' → items with closed_at since local day start
|
|
60
|
-
* '3' → last 3 days; '7' → last 7 days
|
|
61
|
-
* @type {'today'|'3'|'7'}
|
|
62
|
-
*/
|
|
63
|
-
let closed_filter_mode = 'today';
|
|
64
|
-
if (store) {
|
|
65
|
-
try {
|
|
66
|
-
const s = store.getState();
|
|
67
|
-
const cf =
|
|
68
|
-
s && s.board ? String(s.board.closed_filter || 'today') : 'today';
|
|
69
|
-
if (cf === 'today' || cf === '3' || cf === '7') {
|
|
70
|
-
closed_filter_mode = /** @type {any} */ (cf);
|
|
71
|
-
}
|
|
72
|
-
} catch {
|
|
73
|
-
// ignore store init errors
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function template() {
|
|
78
|
-
return html`
|
|
79
|
-
<div class="panel__body board-root">
|
|
80
|
-
${columnTemplate('Blocked', 'blocked-col', list_blocked)}
|
|
81
|
-
${columnTemplate('Ready', 'ready-col', list_ready)}
|
|
82
|
-
${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
|
|
83
|
-
${columnTemplate('Closed', 'closed-col', list_closed)}
|
|
84
|
-
</div>
|
|
85
|
-
`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* @param {string} title
|
|
90
|
-
* @param {string} id
|
|
91
|
-
* @param {IssueLite[]} items
|
|
92
|
-
*/
|
|
93
|
-
function columnTemplate(title, id, items) {
|
|
94
|
-
return html`
|
|
95
|
-
<section class="board-column" id=${id}>
|
|
96
|
-
<header
|
|
97
|
-
class="board-column__header"
|
|
98
|
-
id=${id + '-header'}
|
|
99
|
-
role="heading"
|
|
100
|
-
aria-level="2"
|
|
101
|
-
>
|
|
102
|
-
<span>${title}</span>
|
|
103
|
-
${id === 'closed-col'
|
|
104
|
-
? html`<label class="board-closed-filter">
|
|
105
|
-
<span class="visually-hidden">Filter closed issues</span>
|
|
106
|
-
<select
|
|
107
|
-
id="closed-filter"
|
|
108
|
-
aria-label="Filter closed issues"
|
|
109
|
-
@change=${onClosedFilterChange}
|
|
110
|
-
>
|
|
111
|
-
<option
|
|
112
|
-
value="today"
|
|
113
|
-
?selected=${closed_filter_mode === 'today'}
|
|
114
|
-
>
|
|
115
|
-
Today
|
|
116
|
-
</option>
|
|
117
|
-
<option value="3" ?selected=${closed_filter_mode === '3'}>
|
|
118
|
-
Last 3 days
|
|
119
|
-
</option>
|
|
120
|
-
<option value="7" ?selected=${closed_filter_mode === '7'}>
|
|
121
|
-
Last 7 days
|
|
122
|
-
</option>
|
|
123
|
-
</select>
|
|
124
|
-
</label>`
|
|
125
|
-
: ''}
|
|
126
|
-
</header>
|
|
127
|
-
<div
|
|
128
|
-
class="board-column__body"
|
|
129
|
-
role="list"
|
|
130
|
-
aria-labelledby=${id + '-header'}
|
|
131
|
-
>
|
|
132
|
-
${items.map((it) => cardTemplate(it))}
|
|
133
|
-
</div>
|
|
134
|
-
</section>
|
|
135
|
-
`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* @param {IssueLite} it
|
|
140
|
-
*/
|
|
141
|
-
function cardTemplate(it) {
|
|
142
|
-
return html`
|
|
143
|
-
<article
|
|
144
|
-
class="board-card"
|
|
145
|
-
data-issue-id=${it.id}
|
|
146
|
-
role="listitem"
|
|
147
|
-
tabindex="-1"
|
|
148
|
-
@click=${() => gotoIssue(it.id)}
|
|
149
|
-
>
|
|
150
|
-
<div class="board-card__title text-truncate">
|
|
151
|
-
${it.title || '(no title)'}
|
|
152
|
-
</div>
|
|
153
|
-
<div class="board-card__meta">
|
|
154
|
-
${createTypeBadge(it.issue_type)} ${createPriorityBadge(it.priority)}
|
|
155
|
-
${createIssueIdRenderer(it.id, { class_name: 'mono' })}
|
|
156
|
-
</div>
|
|
157
|
-
</article>
|
|
158
|
-
`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function doRender() {
|
|
162
|
-
render(template(), mount_element);
|
|
163
|
-
postRenderEnhance();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Enhance rendered board with a11y and keyboard navigation.
|
|
168
|
-
* - Roving tabindex per column (first card tabbable)
|
|
169
|
-
* - ArrowUp/ArrowDown within column
|
|
170
|
-
* - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card)
|
|
171
|
-
* - Enter/Space to open details for focused card
|
|
172
|
-
*/
|
|
173
|
-
function postRenderEnhance() {
|
|
174
|
-
try {
|
|
175
|
-
/** @type {HTMLElement[]} */
|
|
176
|
-
const columns = Array.from(
|
|
177
|
-
mount_element.querySelectorAll('.board-column')
|
|
178
|
-
);
|
|
179
|
-
for (const col of columns) {
|
|
180
|
-
const body = /** @type {HTMLElement|null} */ (
|
|
181
|
-
col.querySelector('.board-column__body')
|
|
182
|
-
);
|
|
183
|
-
if (!body) {
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
/** @type {HTMLElement[]} */
|
|
187
|
-
const cards = Array.from(body.querySelectorAll('.board-card'));
|
|
188
|
-
// Assign aria-label using column header for screen readers
|
|
189
|
-
const header = /** @type {HTMLElement|null} */ (
|
|
190
|
-
col.querySelector('.board-column__header')
|
|
191
|
-
);
|
|
192
|
-
const col_name = header ? header.textContent?.trim() || '' : '';
|
|
193
|
-
for (const card of cards) {
|
|
194
|
-
const title_el = /** @type {HTMLElement|null} */ (
|
|
195
|
-
card.querySelector('.board-card__title')
|
|
196
|
-
);
|
|
197
|
-
const t = title_el ? title_el.textContent?.trim() || '' : '';
|
|
198
|
-
card.setAttribute(
|
|
199
|
-
'aria-label',
|
|
200
|
-
`Issue ${t || '(no title)'} — Column ${col_name}`
|
|
201
|
-
);
|
|
202
|
-
// Default roving setup
|
|
203
|
-
card.tabIndex = -1;
|
|
204
|
-
}
|
|
205
|
-
if (cards.length > 0) {
|
|
206
|
-
cards[0].tabIndex = 0;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// non-fatal
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Delegate keyboard handling from mount_element
|
|
215
|
-
mount_element.addEventListener('keydown', (ev) => {
|
|
216
|
-
const target = ev.target;
|
|
217
|
-
if (!target || !(target instanceof HTMLElement)) {
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
// Do not intercept keys inside editable controls
|
|
221
|
-
const tag = String(target.tagName || '').toLowerCase();
|
|
222
|
-
if (
|
|
223
|
-
tag === 'input' ||
|
|
224
|
-
tag === 'textarea' ||
|
|
225
|
-
tag === 'select' ||
|
|
226
|
-
target.isContentEditable === true
|
|
227
|
-
) {
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const card = target.closest('.board-card');
|
|
231
|
-
if (!card) {
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const key = String(ev.key || '');
|
|
235
|
-
if (key === 'Enter' || key === ' ') {
|
|
236
|
-
ev.preventDefault();
|
|
237
|
-
const id = card.getAttribute('data-issue-id');
|
|
238
|
-
if (id) {
|
|
239
|
-
gotoIssue(id);
|
|
240
|
-
}
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (
|
|
244
|
-
key !== 'ArrowUp' &&
|
|
245
|
-
key !== 'ArrowDown' &&
|
|
246
|
-
key !== 'ArrowLeft' &&
|
|
247
|
-
key !== 'ArrowRight'
|
|
248
|
-
) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
ev.preventDefault();
|
|
252
|
-
// Column context
|
|
253
|
-
const col = /** @type {HTMLElement|null} */ (card.closest('.board-column'));
|
|
254
|
-
if (!col) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const body = col.querySelector('.board-column__body');
|
|
258
|
-
if (!body) {
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
/** @type {HTMLElement[]} */
|
|
262
|
-
const cards = Array.from(body.querySelectorAll('.board-card'));
|
|
263
|
-
const idx = cards.indexOf(/** @type {HTMLElement} */ (card));
|
|
264
|
-
if (idx === -1) {
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
if (key === 'ArrowDown' && idx < cards.length - 1) {
|
|
268
|
-
moveFocus(cards[idx], cards[idx + 1]);
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
if (key === 'ArrowUp' && idx > 0) {
|
|
272
|
-
moveFocus(cards[idx], cards[idx - 1]);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (key === 'ArrowRight' || key === 'ArrowLeft') {
|
|
276
|
-
// Find adjacent column with at least one card
|
|
277
|
-
/** @type {HTMLElement[]} */
|
|
278
|
-
const cols = Array.from(mount_element.querySelectorAll('.board-column'));
|
|
279
|
-
const col_idx = cols.indexOf(col);
|
|
280
|
-
if (col_idx === -1) {
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const dir = key === 'ArrowRight' ? 1 : -1;
|
|
284
|
-
let next_idx = col_idx + dir;
|
|
285
|
-
/** @type {HTMLElement|null} */
|
|
286
|
-
let target_col = null;
|
|
287
|
-
while (next_idx >= 0 && next_idx < cols.length) {
|
|
288
|
-
const candidate = cols[next_idx];
|
|
289
|
-
const c_body = /** @type {HTMLElement|null} */ (
|
|
290
|
-
candidate.querySelector('.board-column__body')
|
|
291
|
-
);
|
|
292
|
-
const c_cards = c_body
|
|
293
|
-
? Array.from(c_body.querySelectorAll('.board-card'))
|
|
294
|
-
: [];
|
|
295
|
-
if (c_cards.length > 0) {
|
|
296
|
-
target_col = candidate;
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
next_idx += dir;
|
|
300
|
-
}
|
|
301
|
-
if (target_col) {
|
|
302
|
-
const first = /** @type {HTMLElement|null} */ (
|
|
303
|
-
target_col.querySelector('.board-column__body .board-card')
|
|
304
|
-
);
|
|
305
|
-
if (first) {
|
|
306
|
-
moveFocus(/** @type {HTMLElement} */ (card), first);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* @param {HTMLElement} from
|
|
315
|
-
* @param {HTMLElement} to
|
|
316
|
-
*/
|
|
317
|
-
function moveFocus(from, to) {
|
|
318
|
-
try {
|
|
319
|
-
from.tabIndex = -1;
|
|
320
|
-
to.tabIndex = 0;
|
|
321
|
-
to.focus();
|
|
322
|
-
} catch {
|
|
323
|
-
// ignore focus errors
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Sort helpers centralized in app/data/sort.js
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Recompute closed list from raw using the current filter and sort.
|
|
331
|
-
*/
|
|
332
|
-
function applyClosedFilter() {
|
|
333
|
-
/** @type {IssueLite[]} */
|
|
334
|
-
let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
|
|
335
|
-
const now = new Date();
|
|
336
|
-
let since_ts = 0;
|
|
337
|
-
if (closed_filter_mode === 'today') {
|
|
338
|
-
const start = new Date(
|
|
339
|
-
now.getFullYear(),
|
|
340
|
-
now.getMonth(),
|
|
341
|
-
now.getDate(),
|
|
342
|
-
0,
|
|
343
|
-
0,
|
|
344
|
-
0,
|
|
345
|
-
0
|
|
346
|
-
);
|
|
347
|
-
since_ts = start.getTime();
|
|
348
|
-
} else if (closed_filter_mode === '3') {
|
|
349
|
-
since_ts = now.getTime() - 3 * 24 * 60 * 60 * 1000;
|
|
350
|
-
} else if (closed_filter_mode === '7') {
|
|
351
|
-
since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
|
|
352
|
-
}
|
|
353
|
-
items = items.filter((it) => {
|
|
354
|
-
const s = Number.isFinite(it.closed_at)
|
|
355
|
-
? /** @type {number} */ (it.closed_at)
|
|
356
|
-
: NaN;
|
|
357
|
-
if (!Number.isFinite(s)) {
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
return s >= since_ts;
|
|
361
|
-
});
|
|
362
|
-
items.sort(cmpClosedDesc);
|
|
363
|
-
list_closed = items;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* @param {Event} ev
|
|
368
|
-
*/
|
|
369
|
-
function onClosedFilterChange(ev) {
|
|
370
|
-
try {
|
|
371
|
-
const el = /** @type {HTMLSelectElement} */ (ev.target);
|
|
372
|
-
const v = String(el.value || 'today');
|
|
373
|
-
closed_filter_mode = v === '3' || v === '7' ? v : 'today';
|
|
374
|
-
if (store) {
|
|
375
|
-
try {
|
|
376
|
-
store.setState({ board: { closed_filter: closed_filter_mode } });
|
|
377
|
-
} catch {
|
|
378
|
-
// ignore store errors
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
applyClosedFilter();
|
|
382
|
-
doRender();
|
|
383
|
-
} catch {
|
|
384
|
-
// ignore
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
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
|
-
);
|
|
410
|
-
|
|
411
|
-
// Ready excludes items that are in progress
|
|
412
|
-
/** @type {Set<string>} */
|
|
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));
|
|
415
|
-
|
|
416
|
-
list_ready = ready;
|
|
417
|
-
list_blocked = blocked;
|
|
418
|
-
list_in_progress = in_progress;
|
|
419
|
-
list_closed_raw = closed;
|
|
420
|
-
}
|
|
421
|
-
applyClosedFilter();
|
|
422
|
-
doRender();
|
|
423
|
-
} catch {
|
|
424
|
-
list_ready = [];
|
|
425
|
-
list_blocked = [];
|
|
426
|
-
list_in_progress = [];
|
|
427
|
-
list_closed = [];
|
|
428
|
-
doRender();
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Live updates: recompose on issue store envelopes
|
|
433
|
-
if (selectors) {
|
|
434
|
-
selectors.subscribe(() => {
|
|
435
|
-
try {
|
|
436
|
-
refreshFromStores();
|
|
437
|
-
} catch {
|
|
438
|
-
// ignore
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
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
|
-
: [];
|
|
506
|
-
|
|
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
|
-
}
|
|
526
|
-
},
|
|
527
|
-
clear() {
|
|
528
|
-
mount_element.replaceChildren();
|
|
529
|
-
list_ready = [];
|
|
530
|
-
list_blocked = [];
|
|
531
|
-
list_in_progress = [];
|
|
532
|
-
list_closed = [];
|
|
533
|
-
}
|
|
534
|
-
};
|
|
535
|
-
}
|