From 4a53a95b2dcf7b5fbc41ae500040b514e3288006 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 15:30:55 +0000 Subject: [PATCH] feat: pause Today during mobile device setup (Closes #1084) --- frontend/dashboard.js | 1 + frontend/index.html | 4 +++ frontend/mobile-device-setup.js | 12 +++++++- frontend/today-timer.js | 4 +-- tests/test_mobile_device_setup.py | 46 +++++++++++++++++++++++++++++-- tests/test_mobile_task_dock.py | 4 +-- tests/test_today_timer_handoff.py | 29 +++++++++++++++++++ 7 files changed, 93 insertions(+), 7 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c114c19..7f77a73 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7916,6 +7916,7 @@ installApp.start(); deviceSetup = createMobileDeviceSetup.mount({ document, installApp, promptStorage:localStorage, + timerView, offlineAvailable:() => offlineStorageReady, offlineEnabled:() => offlineWorkStore.enabled(), enableOffline:() => setOfflineWorkEnabled(true), diff --git a/frontend/index.html b/frontend/index.html index aa15374..07dac7f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -95,6 +95,10 @@ +
  1. Install Stackchain

    diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js index a0ea14a..c641086 100644 --- a/frontend/mobile-device-setup.js +++ b/frontend/mobile-device-setup.js @@ -11,6 +11,7 @@ ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline], ]; let trigger = options.launcher; + let ownsDetour = false; function readinessCounts(readiness) { const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length; @@ -50,13 +51,18 @@ async function open(event) { trigger = event?.currentTarget || options.launcher; + if (options.isMobile?.()) { + ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup'; + } await render(); options.sheet.hidden = false; options.closeButton.focus(); } - function close() { + function close(resume = true) { options.sheet.hidden = true; + if (resume && ownsDetour) options.timerView?.finishDetour?.(); + ownsDetour = false; trigger?.focus?.(); } @@ -70,6 +76,7 @@ options.promptCard.hidden = true; }); options.closeButton.addEventListener('click', close); + options.returnButton?.addEventListener('click', () => close(false)); options.sheet.addEventListener('click', event => { if (event.target === options.sheet) close(); }); @@ -108,6 +115,9 @@ function mountMobileDeviceSetup(options) { promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'), promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'), promptStorage:options.promptStorage, + returnButton:qs('#return-from-device-setup'), + timerView:options.timerView, + isMobile:options.isMobile || (() => innerWidth <= 600), escapeTarget:options.document, getReadiness:() => ({ install:options.installApp.state(), diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 84bb202..760b9e2 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange const validDetour = state => { const pending = state.detour_interruption; return pending && typeof pending.identity === 'string' && pending.identity && - typeof pending.resume === 'boolean' && ['find', 'queues', 'insights'].includes(pending.reason) ? + typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup'].includes(pending.reason) ? { identity:pending.identity, resume:pending.resume, reason:pending.reason } : null; }; const validBreak = state => { @@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange return write(state) ? { identity:pending.identity, resumed } : null; }, beginDetour(reason) { - if (!['find', 'queues', 'insights'].includes(reason)) return null; + if (!['find', 'queues', 'insights', 'device-setup'].includes(reason)) return null; const state = read(); const existing = validDetour(state); if (existing) return existing; diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index ed0114b..98d86f8 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -15,7 +15,10 @@ class FakeTarget { async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } focus() { state.focused = this; } } -const state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null}; +const state = { + installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls:0, focused:null, + events:[], beginCalls:[], finishCalls:0, +}; const launcher = new FakeTarget(); const closeButton = new FakeTarget(); const sheet = new FakeTarget(); sheet.hidden = true; @@ -33,6 +36,7 @@ const promptCard = new FakeTarget(); promptCard.hidden = true; const promptSummary = new FakeTarget(); const promptLauncher = new FakeTarget(); const promptDismiss = new FakeTarget(); +const returnButton = new FakeTarget(); let now = 1000; const promptStorage = { values:{}, @@ -49,8 +53,13 @@ const setup = createMobileDeviceSetup({ launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton, installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, promptCard, promptSummary, promptLauncher, promptDismiss, + returnButton, isMobile:() => true, + timerView:{ + beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; }, + finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; }, + }, promptStorage, now:() => now, - getReadiness:() => readiness, + getReadiness:() => { state.events.push('readiness'); return readiness; }, install:async () => { state.installCalls += 1; }, enableOffline:async () => { state.offlineCalls += 1; }, enablePush:async () => { state.pushCalls += 1; }, @@ -170,6 +179,35 @@ process.stdout.write(JSON.stringify({ assert result == {"hidden": True, "launcherFocused": True} +def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once(): + result = run_scenario(""" +state.events = []; +await launcher.dispatch('click', {currentTarget:launcher}); +const firstOpen = {events:[...state.events], reasons:[...state.beginCalls]}; +await sheet.dispatch('click', {target:sheet}); +const backdropFinish = state.finishCalls; +await promptLauncher.dispatch('click', {currentTarget:promptLauncher}); +await escapeTarget.dispatch('keydown', {key:'Escape'}); +const escapeFinish = state.finishCalls; +await launcher.dispatch('click', {currentTarget:launcher}); +returnButton.addEventListener('click', () => { state.events.push('shared-resume'); state.finishCalls += 1; }); +await returnButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + firstOpen, backdropFinish, escapeFinish, + returnFinish:state.finishCalls, + hidden:sheet.hidden, +})); +""") + + assert result == { + "firstOpen": {"events": ["pause", "readiness"], "reasons": ["device-setup"]}, + "backdropFinish": 1, + "escapeFinish": 2, + "returnFinish": 3, + "hidden": True, + } + + def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness(): result = run_scenario(""" readiness.push = {state:'complete', detail:'New update notifications are enabled.'}; @@ -207,6 +245,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert 'id="dismiss-device-readiness"' in html assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"') assert 'id="device-setup-sheet"' in html + assert 'id="device-setup-today-detour"' in html + assert 'id="return-from-device-setup"' in html assert 'aria-labelledby="device-setup-heading"' in html assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline")) assert 'id="device-storage-summary"' in html @@ -218,6 +258,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert 'id="device-setup-deadline-hour"' in html assert '' in html assert "createMobileDeviceSetup.mount({" in dashboard + assert "timerView," in dashboard + assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text() assert "createDeviceStorage.mount(document)" in dashboard assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module assert "promptStorage:localStorage" in dashboard diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index d10e908..b3d59d0 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -254,8 +254,8 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}})); async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing(): html = await dashboard() - assert html.count('data-today-detour role="status"') == 4 - assert html.count('data-return-from-detour type="button"') == 4 + assert html.count('data-today-detour role="status"') == 5 + assert html.count('data-return-from-detour type="button"') == 5 assert "detour:() => timerView" in html timer_source = TIMER.read_text() assert "createTodayDetourInterruption" in timer_source diff --git a/tests/test_today_timer_handoff.py b/tests/test_today_timer_handoff.py index 5a59000..2df5f24 100644 --- a/tests/test_today_timer_handoff.py +++ b/tests/test_today_timer_handoff.py @@ -313,6 +313,35 @@ process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn } +def test_device_setup_detour_excludes_permission_time_and_survives_reload(): + script = SOURCE.read_text() + r""" +const values = new Map(); +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +let now = 1000; +let timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now}); +timer.activate('issue:r:42:'); +now = 6000; +const interruption = timer.beginDetour('device-setup'); +now = 26000; +timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now}); +const restored = timer.detourInterruption(); +const paused = timer.snapshot(); +const returned = timer.returnFromDetour(); +now = 28000; +process.stdout.write(JSON.stringify({interruption, restored, paused, returned, resumed:timer.snapshot()})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "interruption": {"identity": "issue:r:42:", "resume": True, "reason": "device-setup"}, + "restored": {"identity": "issue:r:42:", "resume": True, "reason": "device-setup"}, + "paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False}, + "returned": {"identity": "issue:r:42:", "resumed": True, "reason": "device-setup"}, + "resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True}, + } + + def test_mobile_detour_view_names_work_and_returns_to_today(): script = SOURCE.read_text() + r""" const values = new Map();