From fba1f4238d5ee3f5e351bb84cdc3910448e054e1 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 18:58:26 +0000 Subject: [PATCH] feat: batch-claim available work into Today (Closes #671) --- frontend/batch-find-work.js | 53 ++++++++++++ frontend/dashboard.css | 8 ++ frontend/dashboard.js | 60 ++++++++++++- frontend/index.html | 8 +- frontend/pick-work.js | 38 +++++++- frontend/service-worker.js | 1 + src/frontend_bundle.py | 2 +- tests/test_batch_find_work.py | 157 ++++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 9 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 frontend/batch-find-work.js create mode 100644 tests/test_batch_find_work.py 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 @@
Open · unassigned

Find work

- +

Claim an available issue and continue it in My Work.

Open Find Work to load available issues.
+
@@ -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",