From 0280077e4550d7e41d86d90cdc7cf336c09a1c1d Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 03:42:01 +0000 Subject: [PATCH] feat: continue Today after ownership exits (#449) --- frontend/dashboard.js | 44 ++++++++++++++++++++--- frontend/service-worker.js | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 38 ++++++++++++++++++++ tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 25 ++++++++----- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 10 files changed, 100 insertions(+), 21 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f0fa3d5..a04e0db 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -917,6 +917,14 @@ announce: message => { qs('#my-work-action-status').textContent = message; }, advance: () => runTodayTransition('complete'), }); + async function completeOwnershipExitToday(item) { + if (!item || !todayWork.remove(item)) return null; + todaySync.enqueue('remove', todayWork.identity(item)); + todaySync.flush(); + refreshMyWorkView({ reconcileSession:false }); + warmTodayOffline(); + return await runTodayTransition('complete'); + } function setCommentNextVisibility(kind) { const item = kind === 'issue' ? selectedIssue : selectedPull; qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item); @@ -1947,6 +1955,7 @@ qs('#issue-handoff-recipient').innerHTML = ''; qs('#issue-handoff-recipient').disabled = true; qs('#confirm-issue-handoff').disabled = true; + qs('#confirm-issue-handoff').textContent = workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'; qs('#load-issue-handoff').disabled = false; qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.'; qs('#issue-planning').open = false; @@ -1974,6 +1983,7 @@ qs('#close-issue').textContent = offlineDetail ? (workSession.active() ? 'Queue close & next' : 'Queue issue closure') : (workSession.active() ? 'Close & next' : 'Close issue'); + qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'; qs('#close-issue-sheet').focus(); try { const detail = offlineDetail || await issueController.load(item); @@ -3679,6 +3689,7 @@ qs('#release-issue').addEventListener('click', async () => { if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return; const releasing = selectedIssue; + const continuingSession = workSession.checkpointed(releasing); const button = qs('#release-issue'); button.disabled = true; qs('#issue-sheet-status').textContent = 'Releasing assignment…'; @@ -3688,9 +3699,20 @@ lastContextSnapshot, releasing.repository, releasing.number ); closeIssueSheet(); - paintMyWork(lastContextSnapshot); - qs('#my-work-action-status').textContent = releasing.key + ' released.' + - (confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.'); + if (continuingSession) { + const transitionResult = await completeOwnershipExitToday(releasing); + if (transitionResult === 'opened') { + qs('#my-work-action-status').textContent = releasing.key + ' released. Next work item opened.'; + } else if (transitionResult === 'gated') { + qs('#my-work-action-status').textContent = releasing.key + ' released. Choose the next ready Today item.'; + } else if (transitionResult === null) { + qs('#my-work-action-status').textContent = releasing.key + ' released, but Today still needs completion.'; + } + } else { + paintMyWork(lastContextSnapshot); + qs('#my-work-action-status').textContent = releasing.key + ' released.' + + (confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.'); + } } catch (error) { qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.'; button.disabled = false; @@ -3734,6 +3756,7 @@ const recipient = qs('#issue-handoff-recipient').value; if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return; const handingOff = selectedIssue; + const continuingSession = workSession.checkpointed(handingOff); const button = qs('#confirm-issue-handoff'); button.disabled = true; qs('#issue-handoff-status').textContent = 'Confirming handoff…'; @@ -3743,8 +3766,19 @@ lastContextSnapshot, handingOff.repository, handingOff.number ); closeIssueSheet(); - paintMyWork(lastContextSnapshot); - qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.'; + if (continuingSession) { + const transitionResult = await completeOwnershipExitToday(handingOff); + if (transitionResult === 'opened') { + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Next work item opened.'; + } else if (transitionResult === 'gated') { + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '. Choose the next ready Today item.'; + } else if (transitionResult === null) { + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + ', but Today still needs completion.'; + } + } else { + paintMyWork(lastContextSnapshot); + qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.'; + } } catch (error) { qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.'; button.disabled = false; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 539207f..2bd4914 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-v76'; +const CACHE = 'stackchain-dashboard-shell-v77'; 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; diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 3296359..05cf0e0 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-v76" in source + assert "stackchain-dashboard-shell-v77" 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 cc3b653..d520d35 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-v76" in worker + assert "stackchain-dashboard-shell-v77" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index b83a29a..d098456 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,7 +35,7 @@ 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-v76" in worker + assert "stackchain-dashboard-shell-v77" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index f5e8fd4..f911a6e 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1688,6 +1688,44 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_ ) +@pytest.mark.anyio +async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition(): + html = await dashboard() + + assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html + release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split( + "qs('#load-issue-handoff').addEventListener", 1 + )[0] + assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler + assert "await completeOwnershipExitToday(releasing)" in release_handler + assert "paintMyWork(lastContextSnapshot);" in release_handler + assert release_handler.index("await issueController.release") < release_handler.index( + "await completeOwnershipExitToday(releasing)" + ) + assert release_handler.index("buildMyWork.removeIssue") < release_handler.index( + "await completeOwnershipExitToday(releasing)" + ) + + +@pytest.mark.anyio +async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition(): + html = await dashboard() + + assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html + handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split( + "qs('#close-issue').addEventListener", 1 + )[0] + assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler + assert "await completeOwnershipExitToday(handingOff)" in handoff_handler + assert "paintMyWork(lastContextSnapshot);" in handoff_handler + assert handoff_handler.index("await issueController.handoff") < handoff_handler.index( + "await completeOwnershipExitToday(handingOff)" + ) + assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index( + "await completeOwnershipExitToday(handingOff)" + ) + + @pytest.mark.anyio async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next(): html = await dashboard() diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 53ddbd7..d0a7835 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -232,6 +232,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-v76" in source + assert "stackchain-dashboard-shell-v77" 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 22e4c6d..ecaad38 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,16 +122,23 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source +def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v77" in source + assert "BASE + 'static/dashboard.js'" in source + + def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -139,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -147,14 +154,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-v76" in source + assert "stackchain-dashboard-shell-v77" 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-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -163,21 +170,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-v76" in source + assert "stackchain-dashboard-shell-v77" 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-v76" in source + assert "stackchain-dashboard-shell-v77" 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-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/update-ownership.js'" in source @@ -358,7 +365,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index eaab404..00a366f 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v76';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v77';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index bbd3a80..bd5cde1 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-v76" in source + assert "stackchain-dashboard-shell-v77" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0