From c26f4bdeeb1c1088da1690656fe8e455367921de Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 15:25:44 +0000 Subject: [PATCH] fix: continue Prepare Today after Attention clears (Closes #901) --- frontend/dashboard.js | 5 +- frontend/mobile-start-day.js | 22 ++++++--- frontend/service-worker.js | 2 +- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 56 ++++++++++++++++++++++- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 ++++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_today_work.py | 2 +- 14 files changed, 93 insertions(+), 30 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ba400e4..5e0d11c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2634,11 +2634,11 @@ }); activeMyWork = partitioned.active; laterMyWork = partitioned.later; - const authoritativeTodayReconciliation = liveMode && hasContextSnapshot && + const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot && !lastContextSnapshot?.error && !Object.values(workPagination).some(page => page?.has_more); todayMyWork = todayWork.reconcile(actionableMyWork, { - pruneMissing: authoritativeTodayReconciliation, + pruneMissing: authoritativeMyWorkRefresh, onPrune: retiredIds => { const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean); if (queued) todaySync.flush(); @@ -2666,6 +2666,7 @@ if (element) element.textContent = count; }); mobileQueueCounts = counts; + mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh}); mobileStartDay.render(); mobileTaskDock.updateQueues(counts); mobileTaskDock.updateWork(mobileWorkEntry.mode()); diff --git a/frontend/mobile-start-day.js b/frontend/mobile-start-day.js index 4c3155e..b666cb2 100644 --- a/frontend/mobile-start-day.js +++ b/frontend/mobile-start-day.js @@ -44,12 +44,12 @@ } } - function saveCheckpoint() { + function saveCheckpoint(phase = null) { if (!storage) return false; const current = identity(); if (!current.login || !current.day) return false; try { - storage.setItem(checkpointKey, JSON.stringify(current)); + storage.setItem(checkpointKey, JSON.stringify({...current, phase})); return true; } catch (_) { return false; @@ -87,7 +87,7 @@ function startNext() { const next = briefing().next; - if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(); + if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next); else clearCheckpoint(); options.openQueue(next); return next; @@ -98,13 +98,23 @@ return {active:Boolean(checkpoint()), next:current.next, label:current.label}; } - function completePhase() { - if (!checkpoint()) return false; + function completePhase(phase) { + const saved = checkpoint(); + if (!saved || (saved.phase && saved.phase !== phase)) return false; const current = render(); + saveCheckpoint(); if (options.onHandoff) options.onHandoff(current); return true; } + function reconcile({authoritative = false} = {}) { + const saved = checkpoint(); + if (!authoritative || !saved?.phase) return false; + const counts = options.getCounts ? options.getCounts() : {}; + if (count(counts[saved.phase]) > 0) return false; + return completePhase(saved.phase); + } + function finish() { const cleared = clearCheckpoint(); render(); @@ -128,5 +138,5 @@ if (options.elements) options.elements.action.addEventListener('click', startNext); } - return {briefing, completePhase, finish, render, start, startNext, state}; + return {briefing, completePhase, finish, reconcile, render, start, startNext, state}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 1411dec..993ff86 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v104'; +const CACHE = 'stackchain-dashboard-shell-v105'; 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_comment_next.py b/tests/test_comment_next.py index 8aa4e16..f3f8401 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v104" in worker + assert "stackchain-dashboard-shell-v105" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 712cff7..72002d0 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-v104" in source + assert "stackchain-dashboard-shell-v105" 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 1d42e18..b6f86df 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-v104" in worker + assert "stackchain-dashboard-shell-v105" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index b1ba82f..562247c 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,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-v104" in worker + assert "stackchain-dashboard-shell-v105" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index d11765e..7df47d2 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v104" in worker + assert "stackchain-dashboard-shell-v105" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 8d800f7..15de727 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -179,6 +179,57 @@ process.stdout.write(JSON.stringify({{started, handed, resumed, isolated, expire } +def test_prepare_today_attention_hands_off_once_only_after_authoritative_refresh(): + script = f""" +const createStartDay = require({json.dumps(str(START_DAY))}); +const saved = new Map(); +const storage = {{ + getItem: key => saved.has(key) ? saved.get(key) : null, + setItem: (key, value) => saved.set(key, value), + removeItem: key => saved.delete(key), +}}; +let counts = {{agenda:0, attention:1, update:2, filed:0, today:3}}; +const opened = []; +const handoffs = []; +const controller = createStartDay({{ + storage, + getLogin: () => 'timmy', + getDay: () => '2026-08-15', + getCounts: () => counts, + openQueue: name => opened.push(name), + onHandoff: current => handoffs.push({{next:current.next, label:current.label}}), +}}); +const launchedAttention = controller.startNext(); +counts = {{agenda:0, attention:0, update:2, filed:0, today:3}}; +const ignoredPartial = controller.reconcile({{authoritative:false}}); +const handedToUpdates = controller.reconcile({{authoritative:true}}); +const duplicateRefresh = controller.reconcile({{authoritative:true}}); +const launchedUpdates = controller.startNext(); +counts = {{agenda:0, attention:0, update:0, filed:0, today:3}}; +const handedToToday = controller.reconcile({{authoritative:true}}); +const duplicateReadyRefresh = controller.reconcile({{authoritative:true}}); +process.stdout.write(JSON.stringify({{ + launchedAttention, ignoredPartial, handedToUpdates, duplicateRefresh, + launchedUpdates, handedToToday, duplicateReadyRefresh, opened, handoffs, +}})); +""" + + assert run_node(script) == { + "launchedAttention": "attention", + "ignoredPartial": False, + "handedToUpdates": True, + "duplicateRefresh": False, + "launchedUpdates": "update", + "handedToToday": True, + "duplicateReadyRefresh": False, + "opened": ["attention", "update"], + "handoffs": [ + {"next": "update", "label": "Review Updates"}, + {"next": "today", "label": "Continue Today"}, + ], + } + + def test_prepare_today_checkpoint_uses_the_device_local_day(): script = f""" process.env.TZ = 'Pacific/Honolulu'; @@ -202,7 +253,7 @@ createStartDay({{ process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-start-day.v1')))); """ - assert run_node(script) == {"login": "timmy", "day": "2026-08-15"} + assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"} @pytest.mark.anyio @@ -222,6 +273,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert "mobileStartDay.completePhase('agenda')" in html assert "mobileStartDay.completePhase('update')" in html assert "mobileStartDay.completePhase('filed')" in html + assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html assert "mobileStartDay.finish()" in html assert "openQueue: name =>" in html assert "if (sheet.open) sheet.close();" in html @@ -231,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v104" in service_worker + assert "stackchain-dashboard-shell-v105" in service_worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 238e06d..fb3ade6 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ 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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.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 fd23511..ae92c30 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -152,7 +152,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -161,14 +161,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v104" in source + assert "stackchain-dashboard-shell-v105" 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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -176,7 +176,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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -184,14 +184,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-v104" in source + assert "stackchain-dashboard-shell-v105" 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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -200,21 +200,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-v104" in source + assert "stackchain-dashboard-shell-v105" 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-v104" in source + assert "stackchain-dashboard-shell-v105" 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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/update-ownership.js'" in source @@ -800,7 +800,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-v104" in source + assert "stackchain-dashboard-shell-v105" 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 5c8f752..5dd7720 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-v104';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v105';" 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 687f3af..895af8d 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); 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-v104" in source + assert "stackchain-dashboard-shell-v105" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 75de4aa..fd7b88b 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -356,7 +356,7 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): assert "todaySync.enqueue('add'" in html assert "todaySync.enqueue('remove'" in html assert "todaySync.enqueue('move'" in html - assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html + assert "const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&" in html assert "!lastContextSnapshot?.error &&" in html assert "onPrune: retiredIds =>" in html assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html -- 2.43.0