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 @@
+
-
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();