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",