From b9ae9c6c83485487c2b7a00c228173de275735aa Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 12:39:19 +0000 Subject: [PATCH] feat: make Plan Today browser-back safe (#401) --- frontend/dashboard.js | 24 ++++++++---- frontend/service-worker.js | 2 +- frontend/task-overlay-history.js | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 14 ++++++- tests/test_service_worker.py | 14 +++---- tests/test_task_overlay_history.py | 45 +++++++++++++++++++++++ tests/test_today_sync.py | 2 +- 10 files changed, 87 insertions(+), 22 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a628504..c1b8407 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -759,7 +759,11 @@ })); } - function closePlanToday() { + function closePlanToday(navigate = true) { + if (navigate) { + taskOverlayHistory.close(); + return; + } planToday.cancel(); qs('#plan-today-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); @@ -788,12 +792,16 @@ }, }); - function openPlanToday(trigger) { + function openPlanToday(trigger, navigate = true) { if (!planningOwnerLogin) { qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; return; } - planTodayTrigger = trigger; + if (trigger) planTodayTrigger = trigger; + if (navigate) { + taskOverlayHistory.open('plan-today'); + return; + } planToday.open(todayMyWork, activeMyWork); qs('#plan-today-error').textContent = ''; qs('#plan-today-sheet').hidden = false; @@ -2618,9 +2626,11 @@ mobileSearchViewport.close(); qs('#open-palette').focus(); } + if (previous === 'plan-today' && kind !== 'plan-today') closePlanToday(false); if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false); + if (kind === 'plan-today' && previous !== 'plan-today') openPlanToday(planTodayTrigger, false); }, }); taskOverlayHistory.start(); @@ -3578,15 +3588,13 @@ }); qs('#save-today-plan').addEventListener('click', () => { const result = planToday.commit(); - if (result === 'saved') closePlanToday(); + if (result === 'saved') taskOverlayHistory.leave(); else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.'; }); qs('#save-and-start-today').addEventListener('click', () => { const result = planToday.commit({ start:true }); - if (result === 'saved') { - qs('#plan-today-sheet').hidden = true; - document.body.classList.remove('task-overlay-open'); - } else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.'; + if (result === 'saved') taskOverlayHistory.leave(); + else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.'; }); qs('#start-work-session').addEventListener('click', () => { const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f986b0f..f1ebd5a 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-v62'; +const CACHE = 'stackchain-dashboard-shell-v63'; 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/frontend/task-overlay-history.js b/frontend/task-overlay-history.js index 70fb571..d1f5c4a 100644 --- a/frontend/task-overlay-history.js +++ b/frontend/task-overlay-history.js @@ -5,7 +5,7 @@ })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; - const allowed = new Set(['new', 'find', 'search', 'search-preview']); + const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today']); return function createTaskOverlayHistory({ history, eventTarget, onChange }) { let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null; diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 9a702af..e29e9af 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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 ced53d6..8fe8c25 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-v62" in worker + assert "stackchain-dashboard-shell-v63" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 54f6843..cb493a8 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-v62" in worker + assert "stackchain-dashboard-shell-v63" in worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index d7f7d2e..f32b4f1 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -98,8 +98,20 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html +@pytest.mark.anyio +async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(): + html = await dashboard() + + assert "taskOverlayHistory.open('plan-today')" in html + assert "previous === 'plan-today' && kind !== 'plan-today'" in html + assert "kind === 'plan-today' && previous !== 'plan-today'" in html + assert "openPlanToday(planTodayTrigger, false)" in html + assert "closePlanToday(false)" in html + assert "if (result === 'saved') taskOverlayHistory.leave();" in html + + def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v62" in source + assert "stackchain-dashboard-shell-v63" in source assert "BASE + 'static/plan-today.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 4305f59..6060586 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" 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-v62" in source + assert "stackchain-dashboard-shell-v63" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_task_overlay_history.py b/tests/test_task_overlay_history.py index 3a6c1fa..bdd4552 100644 --- a/tests/test_task_overlay_history.py +++ b/tests/test_task_overlay_history.py @@ -117,6 +117,51 @@ process.stdout.write(JSON.stringify({{left, state:history.state, current:control } +def test_plan_today_uses_one_history_layer_and_can_leave_after_save(): + script = f""" +const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); +const listeners = {{}}; +const changes = []; +const stack = [{{ page:'dashboard' }}]; +let cursor = 0; +const history = {{ + get state() {{ return stack[cursor]; }}, + pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }}, + replaceState(state) {{ stack[cursor] = state; }}, + back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }}, +}}; +const controller = createTaskOverlayHistory({{ + history, + eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }}, + onChange(kind, previous) {{ changes.push([kind, previous]); }}, +}}); +controller.start(); +const opened = controller.open('plan-today'); +const repeated = controller.open('plan-today'); +const left = controller.leave(); +process.stdout.write(JSON.stringify({{ + opened, repeated, left, stack, cursor, current:controller.current(), changes, +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "opened": True, + "repeated": True, + "left": True, + "stack": [ + {"page": "dashboard"}, + {"page": "dashboard"}, + ], + "cursor": 1, + "current": None, + "changes": [["plan-today", None], [None, "plan-today"]], + } + + def test_dashboard_routes_mobile_task_overlays_through_browser_history(): html = dashboard_bundle_text() diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 89137d4..09635ef 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-v62" in source + assert "stackchain-dashboard-shell-v63" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0