feat: pause Today during mobile device setup (Closes #1084)
This commit is contained in:
parent
a6f34fbfde
commit
4a53a95b2d
|
|
@ -7916,6 +7916,7 @@
|
||||||
installApp.start();
|
installApp.start();
|
||||||
deviceSetup = createMobileDeviceSetup.mount({
|
deviceSetup = createMobileDeviceSetup.mount({
|
||||||
document, installApp, promptStorage:localStorage,
|
document, installApp, promptStorage:localStorage,
|
||||||
|
timerView,
|
||||||
offlineAvailable:() => offlineStorageReady,
|
offlineAvailable:() => offlineStorageReady,
|
||||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||||
enableOffline:() => setOfflineWorkEnabled(true),
|
enableOffline:() => setOfflineWorkEnabled(true),
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="today-detour-interruption" id="device-setup-today-detour" data-today-detour role="status" aria-live="polite" hidden>
|
||||||
|
<strong data-today-detour-label>Today paused</strong>
|
||||||
|
<button id="return-from-device-setup" data-return-from-detour type="button">Return to Today</button>
|
||||||
|
</aside>
|
||||||
<ol class="device-setup-list">
|
<ol class="device-setup-list">
|
||||||
<li class="device-setup-step">
|
<li class="device-setup-step">
|
||||||
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||||
];
|
];
|
||||||
let trigger = options.launcher;
|
let trigger = options.launcher;
|
||||||
|
let ownsDetour = false;
|
||||||
|
|
||||||
function readinessCounts(readiness) {
|
function readinessCounts(readiness) {
|
||||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||||
|
|
@ -50,13 +51,18 @@
|
||||||
|
|
||||||
async function open(event) {
|
async function open(event) {
|
||||||
trigger = event?.currentTarget || options.launcher;
|
trigger = event?.currentTarget || options.launcher;
|
||||||
|
if (options.isMobile?.()) {
|
||||||
|
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||||
|
}
|
||||||
await render();
|
await render();
|
||||||
options.sheet.hidden = false;
|
options.sheet.hidden = false;
|
||||||
options.closeButton.focus();
|
options.closeButton.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close(resume = true) {
|
||||||
options.sheet.hidden = true;
|
options.sheet.hidden = true;
|
||||||
|
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||||
|
ownsDetour = false;
|
||||||
trigger?.focus?.();
|
trigger?.focus?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +76,7 @@
|
||||||
options.promptCard.hidden = true;
|
options.promptCard.hidden = true;
|
||||||
});
|
});
|
||||||
options.closeButton.addEventListener('click', close);
|
options.closeButton.addEventListener('click', close);
|
||||||
|
options.returnButton?.addEventListener('click', () => close(false));
|
||||||
options.sheet.addEventListener('click', event => {
|
options.sheet.addEventListener('click', event => {
|
||||||
if (event.target === options.sheet) close();
|
if (event.target === options.sheet) close();
|
||||||
});
|
});
|
||||||
|
|
@ -108,6 +115,9 @@ function mountMobileDeviceSetup(options) {
|
||||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||||
promptStorage:options.promptStorage,
|
promptStorage:options.promptStorage,
|
||||||
|
returnButton:qs('#return-from-device-setup'),
|
||||||
|
timerView:options.timerView,
|
||||||
|
isMobile:options.isMobile || (() => innerWidth <= 600),
|
||||||
escapeTarget:options.document,
|
escapeTarget:options.document,
|
||||||
getReadiness:() => ({
|
getReadiness:() => ({
|
||||||
install:options.installApp.state(),
|
install:options.installApp.state(),
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
const validDetour = state => {
|
const validDetour = state => {
|
||||||
const pending = state.detour_interruption;
|
const pending = state.detour_interruption;
|
||||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
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;
|
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||||
};
|
};
|
||||||
const validBreak = state => {
|
const validBreak = state => {
|
||||||
|
|
@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||||
},
|
},
|
||||||
beginDetour(reason) {
|
beginDetour(reason) {
|
||||||
if (!['find', 'queues', 'insights'].includes(reason)) return null;
|
if (!['find', 'queues', 'insights', 'device-setup'].includes(reason)) return null;
|
||||||
const state = read();
|
const state = read();
|
||||||
const existing = validDetour(state);
|
const existing = validDetour(state);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ class FakeTarget {
|
||||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||||
focus() { state.focused = this; }
|
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 launcher = new FakeTarget();
|
||||||
const closeButton = new FakeTarget();
|
const closeButton = new FakeTarget();
|
||||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||||
|
|
@ -33,6 +36,7 @@ const promptCard = new FakeTarget(); promptCard.hidden = true;
|
||||||
const promptSummary = new FakeTarget();
|
const promptSummary = new FakeTarget();
|
||||||
const promptLauncher = new FakeTarget();
|
const promptLauncher = new FakeTarget();
|
||||||
const promptDismiss = new FakeTarget();
|
const promptDismiss = new FakeTarget();
|
||||||
|
const returnButton = new FakeTarget();
|
||||||
let now = 1000;
|
let now = 1000;
|
||||||
const promptStorage = {
|
const promptStorage = {
|
||||||
values:{},
|
values:{},
|
||||||
|
|
@ -49,8 +53,13 @@ const setup = createMobileDeviceSetup({
|
||||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
||||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
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,
|
promptStorage, now:() => now,
|
||||||
getReadiness:() => readiness,
|
getReadiness:() => { state.events.push('readiness'); return readiness; },
|
||||||
install:async () => { state.installCalls += 1; },
|
install:async () => { state.installCalls += 1; },
|
||||||
enableOffline:async () => { state.offlineCalls += 1; },
|
enableOffline:async () => { state.offlineCalls += 1; },
|
||||||
enablePush:async () => { state.pushCalls += 1; },
|
enablePush:async () => { state.pushCalls += 1; },
|
||||||
|
|
@ -170,6 +179,35 @@ process.stdout.write(JSON.stringify({
|
||||||
assert result == {"hidden": True, "launcherFocused": True}
|
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():
|
def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness():
|
||||||
result = run_scenario("""
|
result = run_scenario("""
|
||||||
readiness.push = {state:'complete', detail:'New update notifications are enabled.'};
|
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 'id="dismiss-device-readiness"' in html
|
||||||
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
|
||||||
assert 'id="device-setup-sheet"' in html
|
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 'aria-labelledby="device-setup-heading"' in html
|
||||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||||
assert 'id="device-storage-summary"' in html
|
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 'id="device-setup-deadline-hour"' in html
|
||||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
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 "createDeviceStorage.mount(document)" in dashboard
|
||||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
|
|
|
||||||
|
|
@ -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():
|
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert html.count('data-today-detour role="status"') == 4
|
assert html.count('data-today-detour role="status"') == 5
|
||||||
assert html.count('data-return-from-detour type="button"') == 4
|
assert html.count('data-return-from-detour type="button"') == 5
|
||||||
assert "detour:() => timerView" in html
|
assert "detour:() => timerView" in html
|
||||||
timer_source = TIMER.read_text()
|
timer_source = TIMER.read_text()
|
||||||
assert "createTodayDetourInterruption" in timer_source
|
assert "createTodayDetourInterruption" in timer_source
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_mobile_detour_view_names_work_and_returns_to_today():
|
||||||
script = SOURCE.read_text() + r"""
|
script = SOURCE.read_text() + r"""
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user