@bongos/core 1.20.46 → 1.20.47
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/.bongos-core.json +41 -31
- package/docs/copy-inventory.md +151 -148
- package/docs/copy-registry.json +266 -199
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +90 -90
- package/modules/hall-ui/public/bulk-toolbar.js +291 -0
- package/modules/hall-ui/public/goals-page.js +29 -44
- package/modules/hall-ui/public/goals-render.js +33 -21
- package/modules/hall-ui/public/goals.html +12 -16
- package/modules/hall-ui/public/style.css +74 -3
- package/modules/hall-ui/public/work.css +4 -66
- package/modules/hall-ui/public/work.html +2 -1
- package/modules/hall-ui/public/work.js +17 -236
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/bulk_toolbar.mjs +202 -0
- package/tests/claim_action.mjs +13 -4
- package/tests/goal_category_ui.mjs +1 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest_page.mjs +1 -0
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/goal_mine_lens.mjs +1 -0
- package/tests/hall_board_world.mjs +1 -1
- package/tests/hall_mine_lens.mjs +1 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// modules/hall-ui/public/bulk-toolbar.js — the hall's ONE bulk-action toolbar
|
|
2
|
+
// (task 1003141, carved from idea 1000568).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT IS. The bar that floats over a board once one or more task cards are
|
|
5
|
+
// ticked: Bulk claim, Bulk release, Copy parallel prompt, Copy sequential prompt,
|
|
6
|
+
// Clear. It lived inside work.js (task 1759), and the Goals page carried a
|
|
7
|
+
// claim-only subset copied from it. Now both pages build it from here, so the
|
|
8
|
+
// toolbar a builder meets on /work and on /goals is the same toolbar.
|
|
9
|
+
//
|
|
10
|
+
// FLAGS ARE ELEMENTS. A page asks for a button by putting it in its markup: no
|
|
11
|
+
// release button in the HTML means no bulk release on that surface, and the
|
|
12
|
+
// parallel / sequential prompts the same. The factory never assumes one exists.
|
|
13
|
+
//
|
|
14
|
+
// WHAT STAYS WITH THE PAGE. The selection itself (a Set of ready task ids and a
|
|
15
|
+
// Map of claim id → task id, both mutated by the page's own checkboxes), how
|
|
16
|
+
// "Clear" repaints the page's cards, and the claim call itself — which is
|
|
17
|
+
// OTBClaimAction's, never this file's (tests/claim_action.mjs fails if a hall
|
|
18
|
+
// script POSTs to /claims). This file only decides what the bar shows and
|
|
19
|
+
// what each button does with the selection.
|
|
20
|
+
//
|
|
21
|
+
// Pure helpers (topoOrder, the two prompt builders) are exported for a test to
|
|
22
|
+
// run directly; the factory is browser-only. Dependency-free, like task-lib.js.
|
|
23
|
+
(() => {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// Kahn's algorithm restricted to edges BETWEEN the selected ids (an
|
|
27
|
+
// out-of-selection dependency doesn't block sequencing the selection itself).
|
|
28
|
+
// Returns the topological order, or null if the restricted graph has a cycle
|
|
29
|
+
// (can't be sequenced).
|
|
30
|
+
function topoOrder(ids, edgesByTask) {
|
|
31
|
+
const remaining = new Set(ids);
|
|
32
|
+
const order = [];
|
|
33
|
+
let progressed = true;
|
|
34
|
+
while (remaining.size && progressed) {
|
|
35
|
+
progressed = false;
|
|
36
|
+
for (const id of Array.from(remaining)) {
|
|
37
|
+
const deps = (edgesByTask.get(id) || []).filter((d) => remaining.has(d));
|
|
38
|
+
if (deps.length === 0) {
|
|
39
|
+
order.push(id);
|
|
40
|
+
remaining.delete(id);
|
|
41
|
+
progressed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return remaining.size ? null : order;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hallBase(loc) {
|
|
49
|
+
return loc.hostname.startsWith('builders.') ? loc.origin : `${loc.origin}/builders`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The paste-into-your-agent prompt for several tasks at once, one session each.
|
|
53
|
+
function buildBulkParallelPrompt(tasks, loc = location) {
|
|
54
|
+
const base = hallBase(loc);
|
|
55
|
+
const lines = [
|
|
56
|
+
`I want to work on ${tasks.length} Bongos tasks IN PARALLEL — open ${tasks.length} separate Claude Code sessions, each in its own worktree, one per task below, so they can run at the same time without touching each other's files.`,
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
tasks.forEach((t, i) => {
|
|
60
|
+
lines.push(`Session ${i + 1} — task ${t.id} (${t.title}) — ${base}#/task/${t.id}`);
|
|
61
|
+
lines.push(`Claim it with /builder-claim ${t.id}, follow its acceptance criteria, stay within touches[], ship via /builder-ship when done.`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
});
|
|
64
|
+
return lines.join('\n').trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The same, for one session working the tasks in dependency order.
|
|
68
|
+
function buildBulkSequentialPrompt(tasks, loc = location) {
|
|
69
|
+
const base = hallBase(loc);
|
|
70
|
+
const lines = [
|
|
71
|
+
`I want to work on ${tasks.length} Bongos tasks IN ORDER, one after another in this same session — claim, build, and ship each before moving to the next.`,
|
|
72
|
+
'',
|
|
73
|
+
];
|
|
74
|
+
tasks.forEach((t, i) => {
|
|
75
|
+
lines.push(`${i + 1}. Task ${t.id} (${t.title}) — ${base}#/task/${t.id}`);
|
|
76
|
+
});
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push('For each: /builder-claim <id>, follow its acceptance criteria, stay within touches[], /builder-ship <id> before starting the next.');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build one page's toolbar.
|
|
83
|
+
// ids element ids: bar, count, claim, clear (required) and release,
|
|
84
|
+
// parallel, sequential (each optional — absent = feature off)
|
|
85
|
+
// selection { claim: Set<String task_id>, release: Map<String claim_id, task_id> }
|
|
86
|
+
// — the page's live object; release may be omitted
|
|
87
|
+
// findTask (numeric id) → the task row the prompts name, or undefined
|
|
88
|
+
// claimAction OTBClaimAction (claimMany, releaseMany, validateMany)
|
|
89
|
+
// getJSON (path, opts) → body, for the dependency lookups
|
|
90
|
+
// copyText (text) → Promise<boolean>
|
|
91
|
+
// onClear repaint the page's cards after the selection is emptied
|
|
92
|
+
function makeBulkToolbar({ ids, selection, findTask, claimAction, getJSON, API, copyText, toast, curLabel, describeReason, onClear }) {
|
|
93
|
+
const el = (key) => (ids[key] ? document.getElementById(ids[key]) : null);
|
|
94
|
+
const releaseSel = () => selection.release || new Map();
|
|
95
|
+
|
|
96
|
+
// Last server dry-run (POST /claims/batch/validate) result, keyed by the
|
|
97
|
+
// sorted selected-id set so an unrelated re-render doesn't refire it.
|
|
98
|
+
const validateCache = { key: '', parallelSafe: null, byId: new Map() };
|
|
99
|
+
let validateDebounce = null;
|
|
100
|
+
// Per-task dependency edges for the "can these be sequenced?" cycle check —
|
|
101
|
+
// fetched lazily via GET /tasks/:id since the claimable feed doesn't carry
|
|
102
|
+
// dependencies[] per row. Stable for a ready task within one page session, so
|
|
103
|
+
// caching by id is safe.
|
|
104
|
+
const depsCache = new Map(); // task_id -> [dependency task_id, ...]
|
|
105
|
+
const sequentialCache = { key: '', order: null };
|
|
106
|
+
let sequentialDebounce = null;
|
|
107
|
+
|
|
108
|
+
const selectionTotal = () => selection.claim.size + releaseSel().size;
|
|
109
|
+
const selectedClaimIds = () => [...selection.claim].map(Number);
|
|
110
|
+
const selectionKey = (list) => list.slice().sort((a, b) => a - b).join(',');
|
|
111
|
+
|
|
112
|
+
function scheduleValidate(key, list) {
|
|
113
|
+
if (validateDebounce) clearTimeout(validateDebounce);
|
|
114
|
+
validateDebounce = setTimeout(async () => {
|
|
115
|
+
// The dry run lives in claim-action.js with the rest of the claim path; it
|
|
116
|
+
// is best-effort there, so a failure lands as parallelSafe:false with an
|
|
117
|
+
// empty map rather than a throw — Parallel stays disabled with a generic
|
|
118
|
+
// title instead of copying a prompt for tasks nobody verified. The
|
|
119
|
+
// debounce, the cache key and the re-render stay here: page furniture.
|
|
120
|
+
const out = await claimAction.validateMany(list);
|
|
121
|
+
validateCache.key = key;
|
|
122
|
+
validateCache.parallelSafe = out.parallelSafe;
|
|
123
|
+
validateCache.byId = out.byId;
|
|
124
|
+
render();
|
|
125
|
+
}, 350);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function fetchDeps(taskId) {
|
|
129
|
+
if (depsCache.has(taskId)) return depsCache.get(taskId);
|
|
130
|
+
let deps = [];
|
|
131
|
+
try {
|
|
132
|
+
const d = await getJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { softAuth: true });
|
|
133
|
+
if (d && d.task && Array.isArray(d.task.dependencies)) {
|
|
134
|
+
deps = d.task.dependencies.map((x) => Number(x.id)).filter((x) => Number.isFinite(x));
|
|
135
|
+
}
|
|
136
|
+
} catch (_) { /* treat as no known dependencies */ }
|
|
137
|
+
depsCache.set(taskId, deps);
|
|
138
|
+
return deps;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scheduleSequentialCheck(key, list) {
|
|
142
|
+
if (sequentialDebounce) clearTimeout(sequentialDebounce);
|
|
143
|
+
sequentialDebounce = setTimeout(async () => {
|
|
144
|
+
const edgesByTask = new Map();
|
|
145
|
+
await Promise.all(list.map(async (id) => { edgesByTask.set(id, await fetchDeps(id)); }));
|
|
146
|
+
sequentialCache.key = key;
|
|
147
|
+
sequentialCache.order = topoOrder(list, edgesByTask);
|
|
148
|
+
render();
|
|
149
|
+
}, 350);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function render() {
|
|
153
|
+
const bar = el('bar');
|
|
154
|
+
if (!bar) return;
|
|
155
|
+
if (selectionTotal() === 0) { bar.hidden = true; return; }
|
|
156
|
+
bar.hidden = false;
|
|
157
|
+
|
|
158
|
+
const countEl = el('count');
|
|
159
|
+
if (countEl) {
|
|
160
|
+
const parts = [];
|
|
161
|
+
if (selection.claim.size) parts.push(`${selection.claim.size} to claim`);
|
|
162
|
+
if (releaseSel().size) parts.push(`${releaseSel().size} to release`);
|
|
163
|
+
countEl.textContent = parts.join(' · ');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const claimBtn = el('claim');
|
|
167
|
+
if (claimBtn) {
|
|
168
|
+
claimBtn.disabled = selection.claim.size === 0;
|
|
169
|
+
claimBtn.textContent = selection.claim.size > 1 ? `Bulk claim (${selection.claim.size})` : 'Bulk claim';
|
|
170
|
+
}
|
|
171
|
+
const releaseBtn = el('release');
|
|
172
|
+
if (releaseBtn) {
|
|
173
|
+
releaseBtn.disabled = releaseSel().size === 0;
|
|
174
|
+
releaseBtn.textContent = releaseSel().size > 1 ? `Bulk release (${releaseSel().size})` : 'Bulk release';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const parallelBtn = el('parallel');
|
|
178
|
+
if (parallelBtn) {
|
|
179
|
+
if (selection.claim.size < 2) {
|
|
180
|
+
parallelBtn.disabled = true;
|
|
181
|
+
parallelBtn.title = 'Select at least 2 waiting tasks to copy a parallel-session prompt.';
|
|
182
|
+
} else {
|
|
183
|
+
const list = selectedClaimIds();
|
|
184
|
+
const key = selectionKey(list);
|
|
185
|
+
if (validateCache.key === key && validateCache.parallelSafe != null) {
|
|
186
|
+
parallelBtn.disabled = !validateCache.parallelSafe;
|
|
187
|
+
if (!validateCache.parallelSafe) {
|
|
188
|
+
const bad = [...validateCache.byId.values()].filter((r) => !r.valid);
|
|
189
|
+
const detail = bad.map((r) => `#${r.task_id}: ${(r.reasons || []).map(describeReason).join(', ')}`).join(' · ');
|
|
190
|
+
parallelBtn.title = detail || 'Selected tasks conflict and cannot run in parallel.';
|
|
191
|
+
} else {
|
|
192
|
+
parallelBtn.title = '';
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
parallelBtn.disabled = true;
|
|
196
|
+
parallelBtn.title = 'Checking for conflicts…';
|
|
197
|
+
scheduleValidate(key, list);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const sequentialBtn = el('sequential');
|
|
203
|
+
if (sequentialBtn) {
|
|
204
|
+
if (selection.claim.size < 2) {
|
|
205
|
+
sequentialBtn.disabled = true;
|
|
206
|
+
sequentialBtn.title = 'Select at least 2 waiting tasks to copy a sequential prompt.';
|
|
207
|
+
} else {
|
|
208
|
+
const list = selectedClaimIds();
|
|
209
|
+
const key = selectionKey(list);
|
|
210
|
+
if (sequentialCache.key === key) {
|
|
211
|
+
sequentialBtn.disabled = sequentialCache.order == null;
|
|
212
|
+
sequentialBtn.title = sequentialCache.order == null
|
|
213
|
+
? 'These tasks depend on each other in a cycle and cannot be ordered.'
|
|
214
|
+
: '';
|
|
215
|
+
} else {
|
|
216
|
+
sequentialBtn.disabled = true;
|
|
217
|
+
sequentialBtn.title = 'Checking dependency order…';
|
|
218
|
+
scheduleSequentialCheck(key, list);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function clearSelection() {
|
|
225
|
+
selection.claim.clear();
|
|
226
|
+
releaseSel().clear();
|
|
227
|
+
if (onClear) onClear();
|
|
228
|
+
render();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function doBulkClaim() {
|
|
232
|
+
const list = selectedClaimIds();
|
|
233
|
+
if (!list.length) return;
|
|
234
|
+
const btn = el('claim');
|
|
235
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
|
|
236
|
+
try {
|
|
237
|
+
const out = await claimAction.claimMany(list);
|
|
238
|
+
if (out.ok) selection.claim.clear();
|
|
239
|
+
} finally {
|
|
240
|
+
render();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function doBulkRelease() {
|
|
245
|
+
const entries = [...releaseSel().entries()]; // [claimId, taskId]
|
|
246
|
+
if (!entries.length) return;
|
|
247
|
+
const label = entries.length === 1 ? `#${entries[0][1]}` : `${entries.length} tasks`;
|
|
248
|
+
const pronoun = entries.length === 1 ? 'It returns' : 'They return';
|
|
249
|
+
if (!window.confirm(`Release ${label}? ${pronoun} to the queue and no ${curLabel()} are earned.`)) return;
|
|
250
|
+
const btn = el('release');
|
|
251
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
|
|
252
|
+
try {
|
|
253
|
+
const out = await claimAction.releaseMany(entries);
|
|
254
|
+
if (out.ok) releaseSel().clear();
|
|
255
|
+
} finally {
|
|
256
|
+
render();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function copyParallel() {
|
|
261
|
+
const tasks = selectedClaimIds().map(findTask).filter(Boolean);
|
|
262
|
+
if (tasks.length < 2) return;
|
|
263
|
+
const ok = await copyText(buildBulkParallelPrompt(tasks));
|
|
264
|
+
toast(ok ? `Parallel-session prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function copySequential() {
|
|
268
|
+
const key = selectionKey(selectedClaimIds());
|
|
269
|
+
if (sequentialCache.key !== key || sequentialCache.order == null) return;
|
|
270
|
+
const tasks = sequentialCache.order.map(findTask).filter(Boolean);
|
|
271
|
+
if (tasks.length < 2) return;
|
|
272
|
+
const ok = await copyText(buildBulkSequentialPrompt(tasks));
|
|
273
|
+
toast(ok ? `Sequential prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Bind each button that exists in this page's markup.
|
|
277
|
+
function wire() {
|
|
278
|
+
el('claim')?.addEventListener('click', doBulkClaim);
|
|
279
|
+
el('release')?.addEventListener('click', doBulkRelease);
|
|
280
|
+
el('clear')?.addEventListener('click', clearSelection);
|
|
281
|
+
el('parallel')?.addEventListener('click', copyParallel);
|
|
282
|
+
el('sequential')?.addEventListener('click', copySequential);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { render, wire, clearSelection, selectionTotal };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const api = { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar };
|
|
289
|
+
if (typeof window !== 'undefined') window.OTBBulkToolbar = api;
|
|
290
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
291
|
+
})();
|
|
@@ -375,6 +375,20 @@
|
|
|
375
375
|
// goal-title match — a single goal makes that redundant).
|
|
376
376
|
// Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
|
|
377
377
|
// GitHub in a pasted session; the prompt points the builder at the hall task URL.
|
|
378
|
+
// Clipboard write that says whether it worked (the bulk prompts report it).
|
|
379
|
+
async function copyText(text) {
|
|
380
|
+
try { await navigator.clipboard.writeText(text); return true; }
|
|
381
|
+
catch (_) {
|
|
382
|
+
try {
|
|
383
|
+
const ta = document.createElement('textarea');
|
|
384
|
+
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
385
|
+
document.body.appendChild(ta); ta.select();
|
|
386
|
+
const ok = document.execCommand('copy');
|
|
387
|
+
ta.remove();
|
|
388
|
+
return ok;
|
|
389
|
+
} catch (_e) { return false; }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
378
392
|
async function copyPromptForTask(taskId, btn) {
|
|
379
393
|
const t = state.work.todo.find((x) => String(x.id) === String(taskId));
|
|
380
394
|
if (!t) return;
|
|
@@ -399,53 +413,25 @@
|
|
|
399
413
|
// Uses the SAME .task markup + classes as the board, so work.css (linked from
|
|
400
414
|
// goals.html) styles it identically. The lead cell + actions adapt to status.
|
|
401
415
|
|
|
402
|
-
// ---- multi-select
|
|
403
|
-
// Selection holds
|
|
404
|
-
// release
|
|
405
|
-
//
|
|
406
|
-
//
|
|
416
|
+
// ---- multi-select ------------------------------------------------------------
|
|
417
|
+
// Selection holds the READY cards the viewer ticked (to claim) and their own
|
|
418
|
+
// ACTIVE cards (to release). The toolbar over it — bulk claim / release, the
|
|
419
|
+
// parallel and sequential prompts — is the hall's shared one, bulk-toolbar.js,
|
|
420
|
+
// built in goals-render.js beside the selection.
|
|
407
421
|
function toggleGoalCardSelect(card, forceChecked) {
|
|
408
|
-
const cb = card.querySelector('.task__select');
|
|
422
|
+
const cb = card.querySelector('.task__select, .wip__select');
|
|
409
423
|
if (!cb) return;
|
|
410
424
|
const next = forceChecked != null ? forceChecked : !cb.checked;
|
|
411
425
|
cb.checked = next;
|
|
412
426
|
const id = String(cb.dataset.taskId);
|
|
413
|
-
|
|
427
|
+
const claimId = cb.dataset.claimId;
|
|
428
|
+
if (claimId) {
|
|
429
|
+
if (next) goalSel.release.set(claimId, id); else goalSel.release.delete(claimId);
|
|
430
|
+
} else if (next) goalSel.claim.add(id); else goalSel.claim.delete(id);
|
|
414
431
|
card.classList.toggle('task--selected', next);
|
|
415
432
|
renderGoalBulkToolbar();
|
|
416
433
|
}
|
|
417
|
-
function clearGoalSelection() {
|
|
418
|
-
goalSel.claim.clear();
|
|
419
|
-
document.querySelectorAll('#goal-todo-list .task--selected').forEach((c) => c.classList.remove('task--selected'));
|
|
420
|
-
document.querySelectorAll('#goal-todo-list .task__select:checked').forEach((cb) => { cb.checked = false; });
|
|
421
|
-
renderGoalBulkToolbar();
|
|
422
|
-
}
|
|
423
|
-
// Drop any selected id that's fallen out from under the selection (claimed by
|
|
424
|
-
// someone else, shipped, or we navigated to another goal) so a stale tick never
|
|
425
|
-
// outlives its card. Called after every task reload (work.js:606-619).
|
|
426
|
-
// A single batch-item failure reason → a readable fragment (work.js:545-562).
|
|
427
|
-
// Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
|
|
428
|
-
// all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
|
|
429
|
-
// goal's tasks either way so the cards reflect fresh truth.
|
|
430
|
-
async function doGoalBulkClaim() {
|
|
431
|
-
const ids = [...goalSel.claim].map(Number);
|
|
432
|
-
if (!ids.length) return;
|
|
433
|
-
const btn = $('#goal-bulk-claim-btn');
|
|
434
|
-
if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
|
|
435
|
-
try {
|
|
436
|
-
const out = await claimAction.claimMany(ids);
|
|
437
|
-
if (out.ok) goalSel.claim.clear();
|
|
438
|
-
} finally {
|
|
439
|
-
// The shared action already reloaded the open goal's task list, on the
|
|
440
|
-
// success path AND the 409 one — re-fetching here would make every bulk
|
|
441
|
-
// claim on this page fetch and re-render twice. work.js's doBulkClaim is
|
|
442
|
-
// the right shape: redraw the toolbar, nothing else. It has to happen on
|
|
443
|
-
// every path, because the button was disabled above and the paths where
|
|
444
|
-
// no reload runs (the auth redirect, a refresh that failed quietly) have
|
|
445
|
-
// nothing else to re-enable it.
|
|
446
|
-
renderGoalBulkToolbar();
|
|
447
|
-
}
|
|
448
|
-
}
|
|
434
|
+
function clearGoalSelection() { bulk.clearSelection(); }
|
|
449
435
|
|
|
450
436
|
// Client-side conflict estimate: exact/prefix overlap between the declared
|
|
451
437
|
// touches[] of not-yet-done tasks. Deliberately conservative — the glob-aware
|
|
@@ -464,8 +450,8 @@
|
|
|
464
450
|
// is passed straight through; workTabs is a GETTER because it is reassigned
|
|
465
451
|
// after boot and a renderer must see the current controller, not the null it
|
|
466
452
|
// held here at startup. This call sits AFTER every declaration it passes.
|
|
467
|
-
const { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
|
|
468
|
-
API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
|
|
453
|
+
const { GOAL_SORTS, WORK_SORT_HEADERS, bulk, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
|
|
454
|
+
API, claimAction, copyText, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
|
|
469
455
|
get workTabs() { return workTabs; },
|
|
470
456
|
});
|
|
471
457
|
|
|
@@ -1073,7 +1059,7 @@
|
|
|
1073
1059
|
// task 1002425 — the To-do card list: multi-select + inline Claim/Release +
|
|
1074
1060
|
// copy-prompt + open, mirroring the board's task-list wiring (work.js:1245-1272).
|
|
1075
1061
|
$('#goal-todo-list')?.addEventListener('click', (e) => {
|
|
1076
|
-
const selectCb = e.target.closest('.task__select');
|
|
1062
|
+
const selectCb = e.target.closest('.task__select, .wip__select');
|
|
1077
1063
|
if (selectCb) { const card = selectCb.closest('.task'); if (card) toggleGoalCardSelect(card, selectCb.checked); return; }
|
|
1078
1064
|
const claimBtn = e.target.closest('[data-claim-task]');
|
|
1079
1065
|
if (claimBtn) { e.stopPropagation(); doGoalClaim(claimBtn.dataset.claimTask, claimBtn); return; }
|
|
@@ -1095,8 +1081,7 @@
|
|
|
1095
1081
|
}
|
|
1096
1082
|
});
|
|
1097
1083
|
// task 1002425 — the goal's bulk-action toolbar.
|
|
1098
|
-
|
|
1099
|
-
$('#goal-bulk-clear-btn')?.addEventListener('click', clearGoalSelection);
|
|
1084
|
+
bulk.wire();
|
|
1100
1085
|
// task 1002164 — Confirm on a pending-review criterion, in both the capped
|
|
1101
1086
|
// band column and the dedicated #/goal/N/completion view.
|
|
1102
1087
|
['#goal-completion-body', '#goal-completion-full'].forEach((sel) => {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
'use strict';
|
|
19
19
|
|
|
20
20
|
window.OTBGoalsRender = function createGoalsRender(ctx) {
|
|
21
|
-
const { API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
|
|
21
|
+
const { API, claimAction, copyText, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
|
|
22
22
|
const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel, categoryChip } = window.OTB;
|
|
23
23
|
const {
|
|
24
24
|
priorityRank, touchesConflict, rankTagHtml, deHashRefs,
|
|
@@ -346,12 +346,16 @@
|
|
|
346
346
|
const claim = state.claimsByTask.get(id);
|
|
347
347
|
const isReady = t.status === 'ready' && !!state.me;
|
|
348
348
|
const mineActive = t.status === 'active' && claim && sameId(claim.builder_id, selfId());
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
//
|
|
349
|
+
const releaseKey = mineActive ? String(claim.claim_id) : null;
|
|
350
|
+
const selected = isReady ? goalSel.claim.has(id) : (mineActive && goalSel.release.has(releaseKey));
|
|
351
|
+
// Lead: a select checkbox on ready cards (drives bulk claim) and on the
|
|
352
|
+
// viewer's own active cards (drives bulk release, the board's `.wip__select`);
|
|
353
|
+
// otherwise a status tag so other cards read at a glance.
|
|
352
354
|
const lead = isReady
|
|
353
355
|
? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
|
|
354
|
-
:
|
|
356
|
+
: (mineActive
|
|
357
|
+
? `<input type="checkbox" class="wip__select" data-claim-id="${escapeHtml(releaseKey)}" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk release"${selected ? ' checked' : ''}>`
|
|
358
|
+
: `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`);
|
|
355
359
|
// Actions: ready → Claim + copy; mine-active → Release; other's active →
|
|
356
360
|
// "claimed · @who"; anything else → nothing (open via the title / card).
|
|
357
361
|
let actions = '';
|
|
@@ -392,25 +396,33 @@
|
|
|
392
396
|
<div class="task__actions">${actions}</div>
|
|
393
397
|
</div>`;
|
|
394
398
|
}
|
|
395
|
-
|
|
396
|
-
|
|
399
|
+
// The goal's selection: ready cards to claim, the viewer's own active cards to
|
|
400
|
+
// release (claim id -> task id). The toolbar over it is the hall's shared one.
|
|
401
|
+
const goalSel = { claim: new Set(), release: new Map() };
|
|
402
|
+
function goalSelTotal() { return goalSel.claim.size + goalSel.release.size; }
|
|
397
403
|
function pruneGoalSelection() {
|
|
398
404
|
const readyIds = new Set(state.work.todo.filter((t) => t.status === 'ready').map((t) => String(t.id)));
|
|
399
405
|
for (const id of Array.from(goalSel.claim)) if (!readyIds.has(id)) goalSel.claim.delete(id);
|
|
406
|
+
const mineClaimIds = new Set(
|
|
407
|
+
state.work.todo
|
|
408
|
+
.filter((t) => t.status === 'active')
|
|
409
|
+
.map((t) => state.claimsByTask.get(String(t.id)))
|
|
410
|
+
.filter((c) => c && sameId(c.builder_id, selfId()))
|
|
411
|
+
.map((c) => String(c.claim_id)),
|
|
412
|
+
);
|
|
413
|
+
for (const claimId of Array.from(goalSel.release.keys())) if (!mineClaimIds.has(claimId)) goalSel.release.delete(claimId);
|
|
400
414
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
}
|
|
415
|
+
const bulk = window.OTBBulkToolbar.makeBulkToolbar({
|
|
416
|
+
ids: { bar: 'goal-bulk-toolbar', count: 'goal-bulk-count', claim: 'goal-bulk-claim-btn', release: 'goal-bulk-release-btn', parallel: 'goal-bulk-parallel-btn', sequential: 'goal-bulk-sequential-btn', clear: 'goal-bulk-clear-btn' },
|
|
417
|
+
selection: goalSel,
|
|
418
|
+
findTask: (id) => state.work.todo.find((t) => Number(t.id) === id),
|
|
419
|
+
claimAction, getJSON, API, copyText, toast, curLabel, describeReason: friendlyBatchReason,
|
|
420
|
+
onClear: () => {
|
|
421
|
+
document.querySelectorAll('#goal-todo-list .task--selected').forEach((c) => c.classList.remove('task--selected'));
|
|
422
|
+
document.querySelectorAll('#goal-todo-list .task__select:checked, #goal-todo-list .wip__select:checked').forEach((cb) => { cb.checked = false; });
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
function renderGoalBulkToolbar() { bulk.render(); }
|
|
414
426
|
function taskRowsDone(tasks) {
|
|
415
427
|
return tasks.map((t) => `
|
|
416
428
|
<tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
|
|
@@ -1464,6 +1476,6 @@
|
|
|
1464
1476
|
return d;
|
|
1465
1477
|
}
|
|
1466
1478
|
|
|
1467
|
-
return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
|
|
1479
|
+
return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, bulk, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
|
|
1468
1480
|
};
|
|
1469
1481
|
})();
|
|
@@ -16,14 +16,6 @@
|
|
|
16
16
|
block per the hall placement rules; shared pager/filter/tab/count-pill
|
|
17
17
|
styles now live in style.css's hall-kit section. -->
|
|
18
18
|
<link rel="stylesheet" href="/builders/goals.css">
|
|
19
|
-
<!-- task 1002425 — the goal-detail "Tasks" pane mirrors the Tasks board's
|
|
20
|
-
claimable cards + bulk toolbar, so it needs the board's page-local card /
|
|
21
|
-
.task__claim / .bulk-toolbar styling. That styling lives in work.css (the
|
|
22
|
-
shared base .task grid is already in style.css above); linking it here is
|
|
23
|
-
the no-duplication way to give both surfaces identical cards. (Follow-up:
|
|
24
|
-
promote the shared card/toolbar rules into style.css so work.css can go
|
|
25
|
-
back to being board-only — idea captured at ship.) -->
|
|
26
|
-
<link rel="stylesheet" href="/builders/work.css">
|
|
27
19
|
</head>
|
|
28
20
|
<body data-page="goals" data-page-title="Goals">
|
|
29
21
|
|
|
@@ -178,16 +170,19 @@
|
|
|
178
170
|
</div>
|
|
179
171
|
|
|
180
172
|
<!-- task 1002425 — bulk-action toolbar for the goal's task cards,
|
|
181
|
-
mirroring the Tasks board's toolbar
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
173
|
+
mirroring the Tasks board's toolbar (task 1003141: the same shared
|
|
174
|
+
toolbar, bulk-toolbar.js, so every button the board offers is here).
|
|
175
|
+
Hidden until 1+ cards are selected; position:fixed (via
|
|
176
|
+
.bulk-toolbar in style.css) so its DOM location here is immaterial.
|
|
177
|
+
It lives inside the detail view so it's scoped to this route and
|
|
178
|
+
cleared on route change. -->
|
|
187
179
|
<div id="goal-bulk-toolbar" class="bulk-toolbar" hidden role="region" aria-label="Bulk actions">
|
|
188
180
|
<span class="bulk-toolbar__count" id="goal-bulk-count"></span>
|
|
189
181
|
<div class="bulk-toolbar__actions">
|
|
190
182
|
<button type="button" class="bulk-toolbar__btn" id="goal-bulk-claim-btn">Bulk claim</button>
|
|
183
|
+
<button type="button" class="bulk-toolbar__btn" id="goal-bulk-release-btn">Bulk release</button>
|
|
184
|
+
<button type="button" class="bulk-toolbar__btn" id="goal-bulk-parallel-btn">Copy parallel prompt</button>
|
|
185
|
+
<button type="button" class="bulk-toolbar__btn" id="goal-bulk-sequential-btn">Copy sequential prompt</button>
|
|
191
186
|
</div>
|
|
192
187
|
<button type="button" class="bulk-toolbar__clear" id="goal-bulk-clear-btn">Clear</button>
|
|
193
188
|
</div>
|
|
@@ -261,7 +256,8 @@
|
|
|
261
256
|
<!-- task 1003206: the page RENDERERS, the second half of the carve goals-lib.js
|
|
262
257
|
began — goals-lib took the pure helpers, this takes the view painters.
|
|
263
258
|
Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
|
|
264
|
-
<script src="/builders/
|
|
265
|
-
<script src="/builders/goals-
|
|
259
|
+
<script src="/builders/bulk-toolbar.js"></script>
|
|
260
|
+
<script src="/builders/goals-render.js?v=2026-09-30-bulktoolbar"></script>
|
|
261
|
+
<script src="/builders/goals-page.js?v=2026-09-30-bulktoolbar"></script>
|
|
266
262
|
</body>
|
|
267
263
|
</html>
|
|
@@ -2876,16 +2876,87 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2876
2876
|
}
|
|
2877
2877
|
.bulk-toolbar__clear:hover { color: var(--ink); }
|
|
2878
2878
|
|
|
2879
|
+
/* ---- shared by the Tasks board AND the goal board's cards (task 1003141) ----
|
|
2880
|
+
These three used to live in work.css, which goals.html borrowed whole. They
|
|
2881
|
+
are single-class rules no other sheet restates, so moving them earlier in the
|
|
2882
|
+
cascade changes nothing — checked by computed style on both pages. */
|
|
2883
|
+
|
|
2884
|
+
/* Filtered-count line under the filter bar — the label voice, with the number
|
|
2885
|
+
itself stepping up to ink so the count reads before the words. */
|
|
2886
|
+
.claim-count-line {
|
|
2887
|
+
margin: 0 0 12px;
|
|
2888
|
+
font-family: var(--label-face);
|
|
2889
|
+
font-size: var(--label-size);
|
|
2890
|
+
letter-spacing: var(--label-space);
|
|
2891
|
+
text-transform: var(--label-case);
|
|
2892
|
+
color: var(--ink-faint);
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
.wip__release {
|
|
2896
|
+
display: inline-flex;
|
|
2897
|
+
align-items: center;
|
|
2898
|
+
justify-content: center;
|
|
2899
|
+
min-height: 30px;
|
|
2900
|
+
padding: 0 14px;
|
|
2901
|
+
background: none;
|
|
2902
|
+
border: var(--line) solid var(--rule);
|
|
2903
|
+
color: var(--ink-soft);
|
|
2904
|
+
border-radius: var(--radius-pill);
|
|
2905
|
+
font-size: 13px;
|
|
2906
|
+
text-transform: lowercase;
|
|
2907
|
+
cursor: pointer;
|
|
2908
|
+
transition: background 150ms, color 150ms, border-color 150ms;
|
|
2909
|
+
}
|
|
2910
|
+
.wip__release:hover:not(:disabled) {
|
|
2911
|
+
background: var(--danger-weak);
|
|
2912
|
+
color: var(--danger);
|
|
2913
|
+
border-color: color-mix(in srgb, var(--danger) 40%, transparent);
|
|
2914
|
+
}
|
|
2915
|
+
.wip__release:disabled { opacity: 0.55; cursor: default; }
|
|
2916
|
+
|
|
2917
|
+
.wip__select {
|
|
2918
|
+
appearance: none;
|
|
2919
|
+
-webkit-appearance: none;
|
|
2920
|
+
position: relative;
|
|
2921
|
+
width: 24px;
|
|
2922
|
+
height: 24px;
|
|
2923
|
+
margin: 0;
|
|
2924
|
+
cursor: pointer;
|
|
2925
|
+
}
|
|
2926
|
+
.wip__select::before {
|
|
2927
|
+
content: "";
|
|
2928
|
+
position: absolute;
|
|
2929
|
+
left: 5px;
|
|
2930
|
+
top: 5px;
|
|
2931
|
+
width: 14px;
|
|
2932
|
+
height: 14px;
|
|
2933
|
+
border: var(--line) solid var(--rule);
|
|
2934
|
+
border-radius: 3px;
|
|
2935
|
+
}
|
|
2936
|
+
.wip__select:checked::before { background: var(--accent); border-color: var(--accent); }
|
|
2937
|
+
.wip__select:checked::after {
|
|
2938
|
+
content: "";
|
|
2939
|
+
position: absolute;
|
|
2940
|
+
left: 9px;
|
|
2941
|
+
top: 8px;
|
|
2942
|
+
width: 4px;
|
|
2943
|
+
height: 8px;
|
|
2944
|
+
border: solid var(--accent-on);
|
|
2945
|
+
border-width: 0 1.5px 1.5px 0;
|
|
2946
|
+
transform: rotate(42deg);
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2879
2949
|
/* The shell's .app-content already ends in 108px of bottom air at desktop
|
|
2880
2950
|
width, which is what clears the bar — but at 768 it drops to 48px for the
|
|
2881
2951
|
phone, and there the bar is TALLER (its actions wrap to their own row). So
|
|
2882
|
-
the phone width buys the room back, scoped to
|
|
2883
|
-
|
|
2952
|
+
the phone width buys the room back, scoped to the two pages that carry the
|
|
2953
|
+
toolbar (the goal board gained the full one in task 1003141). */
|
|
2884
2954
|
@media (max-width: 768px) {
|
|
2885
2955
|
/* At the drawer breakpoint the sidebar leaves the flow, so the bar spans. */
|
|
2886
2956
|
.bulk-toolbar { left: 0; padding: 10px 14px; gap: 10px; }
|
|
2887
2957
|
.bulk-toolbar__actions { margin-left: 0; width: 100%; }
|
|
2888
|
-
body[data-page="work"] .app-content
|
|
2958
|
+
body[data-page="work"] .app-content,
|
|
2959
|
+
body[data-page="goals"] .app-content { padding-bottom: 200px; }
|
|
2889
2960
|
}
|
|
2890
2961
|
|
|
2891
2962
|
.task__credits { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; padding-top: 2px; }
|