From 4afe4dd3b96c672d1974b8348d2c377163405356 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 16:21:20 +0000 Subject: [PATCH] feat: assign and start available work (#411) --- frontend/assign-and-start.js | 29 +++++++ frontend/dashboard.css | 1 + frontend/dashboard.js | 59 ++++++++++++-- frontend/index.html | 1 + frontend/service-worker.js | 1 + tests/test_assign_and_start.py | 144 +++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 7 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 frontend/assign-and-start.js create mode 100644 tests/test_assign_and_start.py diff --git a/frontend/assign-and-start.js b/frontend/assign-and-start.js new file mode 100644 index 0000000..6c02cfc --- /dev/null +++ b/frontend/assign-and-start.js @@ -0,0 +1,29 @@ +function createAssignAndStart({ available, claim, start, recover, announce }) { + let request = null; + + function run(item) { + if (request) return request; + if (!available()) { + announce('Today is full—remove an item before assigning this issue.'); + return Promise.resolve('full'); + } + request = Promise.resolve() + .then(() => claim(item)) + .then(confirmed => { + const outcome = start(confirmed); + if (outcome === 'started') { + announce('Assigned, added to Today, and ready to work.'); + return outcome; + } + announce('Assigned to you, but Today could not start. The issue is open so you can recover.'); + recover(confirmed); + return 'recovery'; + }) + .finally(() => { request = null; }); + return request; + } + + return { run }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 302bbd5..89878ca 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -263,6 +263,7 @@ textarea { resize: vertical; min-height: 120px; } .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 button { width:100%; font-weight:700; } +.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; } .find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } .find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1a60eca..83dfe83 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -757,6 +757,38 @@ announce: message => { qs('#my-work-action-status').textContent = message; }, }); + function acceptClaimedIssue(confirmed) { + lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; + lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate => + candidate.repository !== confirmed.repository || candidate.number !== confirmed.number + )); + lastMyWork = buildMyWork(lastContextSnapshot); + return lastMyWork.find(work => + work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number + ); + } + + const assignAndStart = createAssignAndStart({ + available: createAndStart.available, + claim: item => findWorkController.claim(item), + start: confirmed => { + const claimed = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + return createAndStart.complete(claimed); + }, + recover: confirmed => { + const claimed = acceptClaimedIssue(confirmed); + taskOverlayHistory.leave(); + refreshMyWorkView(); + openRoutedWork(claimed, qs('#find-work')); + }, + announce: message => { + qs('#find-work-status').textContent = message; + qs('#my-work-action-status').textContent = message; + }, + }); + let planTodayTrigger = null; function planTodayItemMarkup(item, selected, index = -1) { const id = todayWork.identity(item); @@ -1869,7 +1901,9 @@ '
' + (item.labels || []).map(label => '' + escapeHtml(label) + '').join(' ') + '
' + - detail + ''; + detail + '
'; }).join('') : '
No unassigned issues are available on this page.
'; list.querySelectorAll('[data-preview-index]').forEach(button => { button.addEventListener('click', () => { @@ -1889,12 +1923,7 @@ button.disabled = true; try { const confirmed = await findWorkController.claim(item); - lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] }; - lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); - lastMyWork = buildMyWork(lastContextSnapshot); - const claimed = lastMyWork.find(work => - work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number - ); + const claimed = acceptClaimedIssue(confirmed); taskOverlayHistory.leave(); refreshMyWorkView(); qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.'; @@ -1906,6 +1935,22 @@ } }); }); + list.querySelectorAll('[data-claim-start-index]').forEach(button => { + button.addEventListener('click', async () => { + const item = findWorkController.items()[Number(button.dataset.claimStartIndex)]; + if (!item) return; + const claimButtons = button.closest('.find-work-card') + .querySelectorAll('[data-claim-index], [data-claim-start-index]'); + claimButtons.forEach(action => { action.disabled = true; }); + try { + await assignAndStart.run(item); + } catch (error) { + qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.'; + claimButtons.forEach(action => { action.disabled = false; }); + button.focus(); + } + }); + }); } async function openFindWorkSheet(navigate = true) { diff --git a/frontend/index.html b/frontend/index.html index b68af68..ada1710 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -606,6 +606,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index aa9a011..e3a878f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -40,6 +40,7 @@ const SHELL = [ BASE + 'static/issue-sheet.js', BASE + 'static/create-issue-sheet.js', BASE + 'static/create-and-start.js', + BASE + 'static/assign-and-start.js', BASE + 'static/pull-sheet.js', BASE + 'static/review-sheet.js', BASE + 'static/work-route.js', diff --git a/tests/test_assign_and_start.py b/tests/test_assign_and_start.py new file mode 100644 index 0000000..e8f9a2b --- /dev/null +++ b/tests/test_assign_and_start.py @@ -0,0 +1,144 @@ +import json +import subprocess +from pathlib import Path + + +ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js" +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_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue(): + script = f""" +const fs=require('fs'); +if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{ + process.stdout.write(JSON.stringify({{available:false}})); +}} else {{ + const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))}); + const calls=[]; + let release; + const claimResult=new Promise(resolve=>{{release=resolve;}}); + const flow=createAssignAndStart({{ + available:()=>true, + claim:item=>{{calls.push('claim:'+item.number);return claimResult;}}, + start:item=>{{calls.push('start:'+item.number);return 'started';}}, + recover:item=>calls.push('recover:'+item.number), + announce:message=>calls.push('announce:'+message), + }}); + const issue={{repository:'stackchain/dashboard',number:411}}; + const first=flow.run(issue); + const second=flow.run(issue); + release({{...issue,assignees:['timmy']}}); + Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}}))); +}} +""" + + assert run_node(script) == { + "available": True, + "calls": [ + "claim:411", + "start:411", + "announce:Assigned, added to Today, and ready to work.", + ], + "results": ["started", "started"], + "same": True, + } + + +def test_full_today_blocks_before_assignment_and_explains_recovery(): + script = f""" +const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))}); +const calls=[]; +const flow=createAssignAndStart({{ + available:()=>false, + claim:()=>{{calls.push('claim');return Promise.resolve({{}});}}, + start:()=>calls.push('start'), + recover:()=>calls.push('recover'), + announce:message=>calls.push('announce:'+message), +}}); +flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=> + process.stdout.write(JSON.stringify({{result,calls}})) +); +""" + + assert run_node(script) == { + "result": "full", + "calls": ["announce:Today is full—remove an item before assigning this issue."], + } + + +def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails(): + script = f""" +const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))}); +const calls=[]; +const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}}; +const flow=createAssignAndStart({{ + available:()=>true, + claim:()=>Promise.resolve(issue), + start:()=> 'sync-unavailable', + recover:item=>calls.push('recover:'+item.number), + announce:message=>calls.push('announce:'+message), +}}); +flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}}))); +""" + + assert run_node(script) == { + "result": "recovery", + "calls": [ + "announce:Assigned to you, but Today could not start. The issue is open so you can recover.", + "recover:411", + ], + } + + +def test_failed_assignment_does_not_start_today_and_can_be_retried(): + script = f""" +const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))}); +const calls=[];let attempt=0; +const issue={{repository:'stackchain/dashboard',number:411}}; +const flow=createAssignAndStart({{ + available:()=>true, + claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}}, + start:()=>{{calls.push('start');return 'started';}}, + recover:()=>calls.push('recover'), + announce:message=>calls.push('announce:'+message), +}}); +flow.run(issue).catch(error=>{{ + calls.push('error:'+error.message); + return flow.run(issue); +}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}}))); +""" + + assert run_node(script) == { + "result": "started", + "calls": [ + "claim:1", + "error:conflict", + "claim:2", + "start", + "announce:Assigned, added to Today, and ready to work.", + ], + } + + +def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell(): + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + worker = WORKER.read_text() + + assert "data-claim-start-index" in dashboard + assert ">Assign & start" in dashboard + assert "const assignAndStart = createAssignAndStart({" in dashboard + assert "assignAndStart.run(item)" in dashboard + assert "createAndStart.available" in dashboard + assert "createAndStart.complete" in dashboard + assert ".find-work-claim-actions" in css + assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css + assert "BASE + 'static/assign-and-start.js'" in worker diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 20540e4..8087d94 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -377,6 +377,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/issue-sheet.js", "/dashboard/static/create-issue-sheet.js", "/dashboard/static/create-and-start.js", + "/dashboard/static/assign-and-start.js", "/dashboard/static/pull-sheet.js", "/dashboard/static/review-sheet.js", "/dashboard/static/work-route.js",