diff --git a/frontend/assign-and-start.js b/frontend/assign-and-start.js
index b220e95..f36c684 100644
--- a/frontend/assign-and-start.js
+++ b/frontend/assign-and-start.js
@@ -1,7 +1,7 @@
-function createAssignAndStart({ available, claim, start, recover, announce }) {
+function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
let request = null;
- function run(item, { alreadyOwned = false } = {}) {
+ function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
if (request) return request;
if (!available()) {
announce('Today is full—remove an item before assigning this issue.');
@@ -10,15 +10,32 @@ function createAssignAndStart({ available, claim, start, recover, announce }) {
request = Promise.resolve()
.then(() => alreadyOwned ? item : claim(item))
.then(confirmed => {
- const outcome = start(confirmed);
+ const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
+ if (outcome === 'queued') {
+ announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
+ 'Assigned and queued in Today. Keep finding work when ready.');
+ return outcome;
+ }
if (outcome === 'started') {
announce(alreadyOwned ? 'Added to Today and ready to work.' :
'Assigned, added to Today, and ready to work.');
return outcome;
}
- announce(alreadyOwned ?
- 'Today could not start. The issue is open so you can recover.' :
- 'Assigned to you, but Today could not start. The issue is open so you can recover.');
+ if (destination === 'queue') {
+ if (outcome === 'sync-unavailable') {
+ announce(alreadyOwned ?
+ 'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
+ 'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
+ } else {
+ announce(alreadyOwned ?
+ 'Today could not be queued. The issue is open so you can recover.' :
+ 'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
+ }
+ } else {
+ announce(alreadyOwned ?
+ 'Today could not start. The issue is open so you can recover.' :
+ 'Assigned to you, but Today could not start. The issue is open so you can recover.');
+ }
recover(confirmed);
return 'recovery';
})
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 12013ea..9c51b1e 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -264,6 +264,7 @@ textarea { resize: vertical; min-height: 120px; }
.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-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
.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 9d27128..e774c19 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -757,6 +757,13 @@
announce: message => { qs('#my-work-action-status').textContent = message; },
});
+ const queueToday = createQueueToday({
+ todayWork,
+ todaySync,
+ refresh: refreshMyWorkView,
+ warm: warmTodayOffline,
+ });
+
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
@@ -777,6 +784,10 @@
refreshMyWorkView();
return createAndStart.complete(claimed);
},
+ queue: confirmed => {
+ const claimed = acceptClaimedIssue(confirmed);
+ return queueToday(claimed);
+ },
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
@@ -1902,8 +1913,9 @@
'' + (expanded ? 'Hide details' : 'View details') + ' ' +
detail + '
Assign to me Assign & start
';
+ '">AssignQueue Today Start now ';
}).join('') : 'No unassigned issues are available on this page.
';
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
@@ -1935,12 +1947,31 @@
}
});
});
+ list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
+ button.addEventListener('click', async () => {
+ const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
+ if (!item) return;
+ const claimButtons = button.closest('.find-work-card')
+ .querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
+ claimButtons.forEach(action => { action.disabled = true; });
+ try {
+ const outcome = await assignAndStart.run(item, { destination:'queue' });
+ if (outcome === 'queued') {
+ (qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
+ }
+ } catch (error) {
+ qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
+ claimButtons.forEach(action => { action.disabled = false; });
+ button.focus();
+ }
+ });
+ });
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]');
+ .querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
await assignAndStart.run(item);
diff --git a/frontend/index.html b/frontend/index.html
index 0f6412c..7067bc6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -610,6 +610,7 @@
+
diff --git a/frontend/queue-today.js b/frontend/queue-today.js
new file mode 100644
index 0000000..eb872c9
--- /dev/null
+++ b/frontend/queue-today.js
@@ -0,0 +1,17 @@
+function createQueueToday({ todayWork, todaySync, refresh, warm }) {
+ return function queueToday(issue) {
+ const added = todayWork.add(issue);
+ if (added !== 'added' && added !== 'exists') return added;
+ if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
+ refresh();
+ warm();
+ return 'sync-unavailable';
+ }
+ refresh();
+ if (added === 'added') todaySync.flush();
+ warm();
+ return 'queued';
+ };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index e3a878f..2e1483c 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v66';
+const CACHE = 'stackchain-dashboard-shell-v67';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -41,6 +41,7 @@ const SHELL = [
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
+ BASE + 'static/queue-today.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
index 2f39bc4..d3820dd 100644
--- a/tests/test_assign_and_start.py
+++ b/tests/test_assign_and_start.py
@@ -4,6 +4,8 @@ from pathlib import Path
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
+QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.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"
@@ -154,17 +156,121 @@ flow.run(issue, {{alreadyOwned:true}}).then(result=>
}
+def test_queue_today_claims_once_without_starting_the_active_session():
+ script = f"""
+const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
+const calls=[];
+let release;
+const claimResult=new Promise(resolve=>{{release=resolve;}});
+const issue={{repository:'stackchain/dashboard',number:415}};
+const flow=createAssignAndStart({{
+ available:()=>true,
+ claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
+ start:item=>{{calls.push('start:'+item.number);return 'started';}},
+ queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
+ recover:item=>calls.push('recover:'+item.number),
+ announce:message=>calls.push('announce:'+message),
+}});
+const first=flow.run(issue, {{destination:'queue'}});
+const second=flow.run(issue, {{destination:'queue'}});
+release({{...issue,assignees:['timmy']}});
+Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
+ calls,results,same:first===second
+}})));
+"""
+
+ assert run_node(script) == {
+ "calls": [
+ "claim:415",
+ "queue:415",
+ "announce:Assigned and queued in Today. Keep finding work when ready.",
+ ],
+ "results": ["queued", "queued"],
+ "same": True,
+ }
+
+
+def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
+ script = f"""
+const fs=require('fs');
+if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
+ process.stdout.write(JSON.stringify({{available:false}}));
+}} else {{
+ const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
+ const calls=[];
+ const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
+ const queue=createQueueToday({{
+ todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
+ todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
+ refresh:()=>calls.push('refresh'),
+ warm:()=>calls.push('warm'),
+ }});
+ process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
+}}
+"""
+
+ assert run_node(script) == {
+ "available": True,
+ "result": "queued",
+ "calls": [
+ "add",
+ "add:issue:stackchain/dashboard:415:",
+ "refresh",
+ "flush",
+ "warm",
+ ],
+ }
+
+
+def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
+ script = f"""
+const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
+const calls=[];
+const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
+const flow=createAssignAndStart({{
+ available:()=>true,
+ claim:()=>Promise.resolve(issue),
+ start:()=> 'started',
+ queue:()=> 'sync-unavailable',
+ recover:item=>calls.push('recover:'+item.number),
+ announce:message=>calls.push('announce:'+message),
+}});
+flow.run(issue, {{destination:'queue'}}).then(result=>
+ process.stdout.write(JSON.stringify({{result,calls}}))
+);
+"""
+
+ assert run_node(script) == {
+ "result": "recovery",
+ "calls": [
+ "announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
+ "recover:415",
+ ],
+ }
+
+
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
+ html = HTML.read_text()
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 "data-claim-queue-index" in dashboard
+ assert ">Start now" in dashboard
+ assert ">Queue Today" in dashboard
assert "const assignAndStart = createAssignAndStart({" in dashboard
assert "assignAndStart.run(item)" in dashboard
+ assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
+ assert "queue: confirmed =>" in dashboard
+ assert "const queueToday = createQueueToday({" in dashboard
+ assert "return queueToday(claimed)" 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 ".find-work-claim-actions [data-claim-start-index]" in css
+ assert "grid-column:1 / -1" in css
assert "BASE + 'static/assign-and-start.js'" in worker
+ assert "BASE + 'static/queue-today.js'" in worker
+ assert '' in html
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 766963f..0b8de34 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 2da6ab0..b523e25 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v66" in worker
+ assert "stackchain-dashboard-shell-v67" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 2bdcacc..1e98bda 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v66" in worker
+ assert "stackchain-dashboard-shell-v67" in worker
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 953d2d8..b01b1fd 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 8087d94..ec08a95 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -138,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -154,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -331,6 +331,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
assert "batch: withSessionCsrf" in source
+def test_queue_today_ships_atomically_in_a_new_offline_shell():
+ source = WORKER.read_text()
+
+ assert "stackchain-dashboard-shell-v67" in source
+ assert "BASE + 'static/queue-today.js'" in source
+
+
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -378,6 +385,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js",
+ "/dashboard/static/queue-today.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 1a1e209..ef555f2 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v66" in source
+ assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source