diff --git a/frontend/batch-find-work.js b/frontend/batch-find-work.js
new file mode 100644
index 0000000..a7e2422
--- /dev/null
+++ b/frontend/batch-find-work.js
@@ -0,0 +1,53 @@
+function createBatchFindWork({ capacity, claim, queue, onProgress = () => {} }) {
+ let request = null;
+
+ function result(status, selected, available, queued = [], failed = []) {
+ return { status, selected, available, queued, failed };
+ }
+
+ function key(item) {
+ return String(item.repository || '') + '#' + String(item.number || '');
+ }
+
+ function run(items) {
+ if (request) return request;
+ const selected = Array.isArray(items) ? items.slice() : [];
+ const available = Math.max(0, Number(capacity()) || 0);
+ if (selected.length > available) {
+ const outcome = result('full', selected.length, available);
+ onProgress(outcome);
+ return Promise.resolve(outcome);
+ }
+ request = (async () => {
+ const queued = [];
+ const failed = [];
+ for (let index = 0; index < selected.length; index += 1) {
+ const item = selected[index];
+ try {
+ const confirmed = await claim(item);
+ const queueResult = await queue(confirmed);
+ if (queueResult === 'queued' || queueResult === 'exists') {
+ queued.push(key(item));
+ } else {
+ failed.push({
+ key: key(item),
+ reason: 'assigned but Today sync is unavailable',
+ assigned: true,
+ });
+ }
+ } catch (error) {
+ failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
+ }
+ onProgress({ status: 'running', processed: index + 1, selected: selected.length });
+ }
+ const outcome = result('complete', selected.length, available, queued, failed);
+ onProgress({ ...outcome, processed: selected.length });
+ return outcome;
+ })().finally(() => { request = null; });
+ return request;
+ }
+
+ return { run };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 4c70f78..f664416 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -453,9 +453,17 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
+.find-work-header-actions { display:flex; gap:8px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
+.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
+.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
+.find-work-select input { width:22px; height:22px; }
+.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
+.find-work-batch-actions[hidden] { display:none; }
+.find-work-batch-actions span { grid-column:1 / -1; }
+.find-work-batch-actions button { min-height:44px; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 930f143..741b0bc 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -584,6 +584,14 @@
qs('#load-more-available').hidden = !pagination.has_more;
},
onStatus: message => { qs('#find-work-status').textContent = message; },
+ onSelection: state => {
+ qs('#batch-find-work-actions').hidden = !state.active;
+ qs('#select-find-work').hidden = state.active;
+ qs('#find-work-selection-status').textContent = state.count ?
+ state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No work selected.';
+ qs('#claim-selected-work').disabled = state.count === 0;
+ renderAvailableIssues(findWorkController.items());
+ },
});
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
@@ -1375,6 +1383,21 @@
},
});
+ const batchFindWork = createBatchFindWork({
+ capacity: () => Math.max(0, todayWork.limit - todayWork.read().length),
+ claim: item => findWorkController.claim(item),
+ queue: confirmed => queueToday(acceptClaimedIssue(confirmed)),
+ onProgress: progress => {
+ if (progress.status === 'full') {
+ qs('#find-work-status').textContent = 'Today has ' + progress.available +
+ ' open slot' + (progress.available === 1 ? '' : 's') + '. Reduce the selection before assigning.';
+ } else if (progress.status === 'running') {
+ qs('#find-work-status').textContent = 'Assigning and queueing ' + progress.processed +
+ ' of ' + progress.selected + '…';
+ }
+ },
+ });
+
let planTodayTrigger = null;
function formatPlanMinutes(minutes) {
if (!Number.isInteger(minutes)) return 'Not set';
@@ -3020,23 +3043,34 @@
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
+ const selection = findWorkController.selection();
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
+ const selected = findWorkController.isSelected(item);
const detailId = 'find-work-detail-' + index;
const detail = '
' + renderMarkdown(item.body || 'No description provided.') + '
' +
(item.url ? '
Open in Gitea' : '') +
'
';
- return '' + escapeHtml(item.repository) + '#' +
+ return '
' +
+ (selection.active ? '' : '') +
+ '' + escapeHtml(item.repository) + '#' +
Number(item.number) + '
' + escapeHtml(item.title || 'Untitled issue') + '' +
'' + (item.labels || []).map(label => '' + escapeHtml(label) + '').join(' ') +
'
' +
- detail + '';
}).join('') : '
No unassigned issues are available on this page.
';
+ list.querySelectorAll('[data-find-work-select]').forEach(input => {
+ input.addEventListener('change', () => {
+ const item = findWorkController.items()[Number(input.dataset.findWorkSelect)];
+ if (item) findWorkController.toggleSelection(item);
+ });
+ });
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
@@ -4187,6 +4221,28 @@
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
+ qs('#select-find-work').addEventListener('click', () => findWorkController.startSelection());
+ qs('#cancel-find-work-selection').addEventListener('click', () => findWorkController.cancelSelection());
+ qs('#claim-selected-work').addEventListener('click', async event => {
+ event.currentTarget.disabled = true;
+ const outcome = await batchFindWork.run(findWorkController.selectedItems());
+ if (outcome.status === 'complete') {
+ const assignedOnly = outcome.failed.filter(item => item.assigned).length;
+ const unavailable = outcome.failed.length - assignedOnly;
+ qs('#find-work-status').textContent = outcome.queued.length + ' queued' +
+ (unavailable ? ' · ' + unavailable + ' unavailable' : '') +
+ (assignedOnly ? ' · ' + assignedOnly + ' assigned but not queued' : '') + '.';
+ if (outcome.failed.length) {
+ renderAvailableIssues(findWorkController.items());
+ event.currentTarget.disabled = false;
+ } else {
+ findWorkController.cancelSelection();
+ }
+ refreshMyWorkView();
+ } else {
+ event.currentTarget.disabled = false;
+ }
+ });
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
diff --git a/frontend/index.html b/frontend/index.html
index c9fa368..782cc77 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -532,12 +532,17 @@
Claim an available issue and continue it in My Work.
Open Find Work to load available issues.
Load more available issues
+
+ No work selected.
+ Cancel
+ Assign & queue Today
+
@@ -952,6 +957,7 @@
+
diff --git a/frontend/pick-work.js b/frontend/pick-work.js
index 5b3b05c..59d883a 100644
--- a/frontend/pick-work.js
+++ b/frontend/pick-work.js
@@ -1,14 +1,22 @@
-function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
+function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
const previewed = new Set();
+ let selecting = false;
+ const selected = new Map();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
+ function emitSelection() {
+ const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
+ onSelection(state);
+ return state;
+ }
+
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
@@ -63,6 +71,32 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
items() {
return available.slice();
},
+ startSelection() {
+ selecting = true;
+ selected.clear();
+ return emitSelection();
+ },
+ cancelSelection() {
+ selecting = false;
+ selected.clear();
+ return emitSelection();
+ },
+ toggleSelection(item) {
+ if (!selecting || !item) return emitSelection();
+ const key = itemKey(item);
+ if (selected.has(key)) selected.delete(key);
+ else selected.set(key, item);
+ return emitSelection();
+ },
+ isSelected(item) {
+ return selected.has(itemKey(item));
+ },
+ selectedItems() {
+ return Array.from(selected.values());
+ },
+ selection() {
+ return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
+ },
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
@@ -91,6 +125,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
+ selected.delete(itemKey(item));
+ emitSelection();
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 2cb644a..15db6cc 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -55,6 +55,7 @@ const SHELL = [
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
+ BASE + 'static/batch-find-work.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-attachment.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 02f7f2d..ce80df8 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -31,7 +31,7 @@ FEATURE_SOURCES = {
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
- "static/today-work.js",
+ "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
),
}
CACHE_DECLARATION = re.compile(
diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py
new file mode 100644
index 0000000..2780a35
--- /dev/null
+++ b/tests/test_batch_find_work.py
@@ -0,0 +1,157 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
+PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
+HTML = Path(__file__).parents[1] / "frontend" / "index.html"
+DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
+CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
+WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
+
+
+def run_node(script):
+ return json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+
+def test_batch_preflights_today_capacity_before_claiming_any_issue():
+ script = f"""
+const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+const calls=[];
+const flow=createBatchFindWork({{
+ capacity:()=>1,
+ claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
+ queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
+ onProgress:progress=>calls.push('progress:'+progress.status),
+}});
+flow.run([
+ {{repository:'stackchain/dashboard',number:671}},
+ {{repository:'stackchain/dashboard',number:672}},
+]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
+"""
+
+ assert run_node(script) == {
+ "result": {
+ "status": "full",
+ "selected": 2,
+ "available": 1,
+ "queued": [],
+ "failed": [],
+ },
+ "calls": ["progress:full"],
+ }
+
+
+def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
+ script = f"""
+const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+const calls=[];
+const flow=createBatchFindWork({{
+ capacity:()=>3,
+ claim:item=>{{
+ calls.push('claim:'+item.number);
+ return item.number===672 ? Promise.reject(new Error('already claimed')) :
+ Promise.resolve({{...item,assignees:['timmy']}});
+ }},
+ queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
+ onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
+}});
+flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
+ .then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
+"""
+
+ assert run_node(script) == {
+ "result": {
+ "status": "complete",
+ "selected": 3,
+ "available": 3,
+ "queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
+ "failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
+ },
+ "calls": [
+ "claim:671", "queue:671", "progress:running:1",
+ "claim:672", "progress:running:2",
+ "claim:673", "queue:673", "progress:running:3",
+ "progress:complete:3",
+ ],
+ }
+
+
+def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
+ script = f"""
+const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
+const calls=[];
+const flow=createBatchFindWork({{
+ capacity:()=>1,
+ claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
+ queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
+}});
+flow.run([{{repository:'stackchain/dashboard',number:671}}])
+ .then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
+"""
+
+ assert run_node(script) == {
+ "result": {
+ "status": "complete",
+ "selected": 1,
+ "available": 1,
+ "queued": [],
+ "failed": [{
+ "key": "stackchain/dashboard#671",
+ "reason": "assigned but Today sync is unavailable",
+ "assigned": True,
+ }],
+ },
+ "calls": ["claim", "queue"],
+ }
+
+
+def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
+ script = f"""
+const createFindWork=require({json.dumps(str(PICK_WORK))});
+const pages={{
+ 1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
+ 2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
+}};
+const controller=createFindWork({{
+ fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
+ Promise.resolve(pages[path.endsWith('=2') ? 2 : 1]),
+ onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
+}});
+controller.load().then(()=>{{
+ controller.startSelection();
+ controller.toggleSelection(controller.items()[0]);
+ return controller.loadMore();
+}}).then(()=>{{
+ controller.toggleSelection(controller.items()[1]);
+ return controller.claim(controller.items()[0]);
+}}).then(()=>process.stdout.write(JSON.stringify({{
+ selected:controller.selectedItems().map(item=>item.number),
+ selecting:controller.selection().active,
+}})));
+"""
+
+ assert run_node(script) == {"selected": [672], "selecting": True}
+
+
+def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
+ html = HTML.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+ worker = WORKER.read_text()
+
+ assert 'id="select-find-work"' in html
+ assert 'id="batch-find-work-actions"' in html
+ assert 'id="claim-selected-work"' in html
+ assert 'aria-live="polite"' in html
+ assert "createBatchFindWork({" in dashboard
+ assert "findWorkController.startSelection()" in dashboard
+ assert "findWorkController.toggleSelection(item)" in dashboard
+ assert ".find-work-batch-actions" in css
+ assert "position:sticky" in css
+ assert "env(safe-area-inset-bottom)" in css
+ assert "BASE + 'static/batch-find-work.js'" in worker
+ assert '' in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index d8b6908..83039ed 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -718,6 +718,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
+ "/dashboard/static/batch-find-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-attachment.js",