From ad5e50eb485d7abd27f1c85726954ecbbc49202a Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 17:57:48 +0000 Subject: [PATCH] feat: queue available work in Today (#415) --- frontend/assign-and-start.js | 29 ++++-- frontend/dashboard.css | 1 + frontend/dashboard.js | 37 +++++++- frontend/index.html | 1 + frontend/queue-today.js | 17 ++++ frontend/service-worker.js | 3 +- tests/test_assign_and_start.py | 108 +++++++++++++++++++++- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 22 +++-- tests/test_today_sync.py | 2 +- 13 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 frontend/queue-today.js 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 @@ '' + detail + '
'; + '">Assign'; }).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 -- 2.43.0