diff --git a/frontend/mobile-task-dock.js b/frontend/mobile-task-dock.js
index db85bc2..60b37bf 100644
--- a/frontend/mobile-task-dock.js
+++ b/frontend/mobile-task-dock.js
@@ -33,8 +33,9 @@
options.queueSheet.showModal();
}
- function closeQueues() {
+ function closeQueues(returnToToday = false) {
options.queueSheet?.open && options.queueSheet.close();
+ if (returnToToday) options.detour?.()?.finishDetour();
}
function start() {
@@ -42,14 +43,16 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
- name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
+ if (name === 'queues') options.detour?.()?.beginDetour(name);
+ options.actions?.[name]?.();
+ if (name === 'queues' && options.queueSheet) openQueues();
});
});
if (options.queueSheet) {
- options.queueClose?.addEventListener('click', closeQueues);
+ options.queueClose?.addEventListener('click', () => closeQueues(true));
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
- closeQueues();
+ closeQueues(true);
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
diff --git a/frontend/today-timer.js b/frontend/today-timer.js
index 1a40c5d..4a4fb4d 100644
--- a/frontend/today-timer.js
+++ b/frontend/today-timer.js
@@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
- pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, timed_break:null,
+ pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
});
const read = () => {
const ownerKey = key();
@@ -54,6 +54,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
+ const validDetour = state => {
+ const pending = state.detour_interruption;
+ return pending && typeof pending.identity === 'string' && pending.identity &&
+ typeof pending.resume === 'boolean' && ['find', 'queues'].includes(pending.reason) ?
+ { identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
+ };
const validBreak = state => {
const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity &&
@@ -101,6 +107,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
+ state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -118,6 +125,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
+ state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -170,6 +178,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
+ state.detour_interruption = null;
state.timed_break = null;
return write(state);
},
@@ -279,6 +288,36 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
+ beginDetour(reason) {
+ if (!['find', 'queues'].includes(reason)) return null;
+ const state = read();
+ const existing = validDetour(state);
+ if (existing) return existing;
+ const identity = state.active_identity;
+ const entry = state.entries[identity];
+ if (!identity || !entry?.running) return null;
+ settle(state);
+ state.away_at = null;
+ state.detour_interruption = { identity, resume:true, reason };
+ return write(state) ? { ...state.detour_interruption } : null;
+ },
+ detourInterruption() {
+ return validDetour(read());
+ },
+ returnFromDetour() {
+ const state = read();
+ const pending = validDetour(state);
+ const entry = pending && state.entries[pending.identity];
+ if (!pending || !entry || state.active_identity !== pending.identity) return null;
+ const resumed = pending.resume && !entry.running;
+ if (resumed) {
+ entry.started_at = now();
+ entry.running = true;
+ }
+ state.detour_interruption = null;
+ state.away_at = null;
+ return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : null;
+ },
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
@@ -368,6 +407,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
+ const detourView = createTodayDetourInterruption({
+ timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
+ onReturn:identity => onReopen?.(identity),
+ });
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
@@ -477,6 +520,8 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
},
reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); },
+ beginDetour(reason) { return detourView.open(reason); },
+ finishDetour() { return detourView.finish(); },
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
@@ -503,6 +548,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
}));
if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
+ detourView.restore();
return view;
}
@@ -638,11 +684,50 @@ function createTodayCaptureInterruption({ timer, banner, label, getItem, getItem
};
}
+function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
+ const render = (pending = timer.detourInterruption?.()) => {
+ queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
+ queryAll('[data-today-detour-label]').forEach(element => {
+ element.textContent = pending ? 'Today paused · ' +
+ String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
+ });
+ onChange?.();
+ return pending;
+ };
+ const view = {
+ open(reason) {
+ const pending = timer.beginDetour?.(reason);
+ render(pending || timer.detourInterruption?.());
+ return pending;
+ },
+ restore() { return render(); },
+ finish() {
+ const result = timer.returnFromDetour?.();
+ render(null);
+ return result;
+ },
+ };
+ queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
+ const pending = timer.detourInterruption?.();
+ view.finish();
+ if (pending) onReturn?.(pending.identity);
+ }));
+ if (typeof MutationObserver !== 'undefined') {
+ queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
+ const pending = timer.detourInterruption?.();
+ if (sheet.classList.contains('open')) view.open('find');
+ else if (pending?.reason === 'find') view.finish();
+ }).observe(sheet, {attributes:true, attributeFilter:['class']}));
+ }
+ return view;
+}
+
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
+ createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
module.exports = createTodayTimer;
}
diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py
index ad89655..3f16d5c 100644
--- a/tests/e2e/test_mobile_today_handoff_release.py
+++ b/tests/e2e/test_mobile_today_handoff_release.py
@@ -351,6 +351,83 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
fake_thread.join(timeout=5)
+def test_release_artifact_pauses_today_across_find_and_queue_detours(tmp_path: Path):
+ archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
+ assert len(archives) == 1
+ fake = FakeGiteaServer(("127.0.0.1", 0))
+ thread = threading.Thread(target=fake.serve_forever, daemon=True)
+ thread.start()
+ fake_url = f"http://127.0.0.1:{fake.server_port}"
+
+ try:
+ with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
+ browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
+ page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
+ page.goto(origin + "/", wait_until="networkidle")
+ page.locator('input[name="device_label"]').fill("Today detour phone")
+ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
+ page.locator("#submit-sign-in").click()
+ page.wait_for_url(origin + "/", wait_until="networkidle")
+ expect(page.locator("#my-work-status")).to_contain_text("2")
+
+ page.locator('[data-mobile-task="work"]').click()
+ page.locator("#plan-today-available").fill("90")
+ page.locator("#plan-today-available").press("Tab")
+ page.locator("#build-today-plan").click()
+ missing = page.locator("[data-plan-missing-estimate]")
+ expect(missing).to_have_count(2)
+ for _ in range(2):
+ missing.nth(0).fill("30")
+ missing.nth(0).press("Tab")
+ page.locator("#save-and-start-today").click()
+ expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
+ page.locator("#close-issue-sheet").click()
+ if page.locator("#plan-today-sheet").is_visible():
+ page.locator("#cancel-plan-today").click()
+
+ page.locator('[data-mobile-task="find"]').click()
+ find_pause = page.locator("#find-work-sheet [data-today-detour]")
+ expect(find_pause).to_be_visible()
+ expect(find_pause).to_contain_text("Today paused · Ship mobile capture")
+ bounds = find_pause.locator("[data-return-from-detour]").bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.evaluate("""() => {
+ const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
+ const timer = JSON.parse(localStorage.getItem(key));
+ return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'find';
+ }""")
+ page.locator("#close-find-work").click()
+ assert page.evaluate("""() => {
+ const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
+ const timer = JSON.parse(localStorage.getItem(key));
+ return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
+ }""")
+
+ page.locator('[data-mobile-task="queues"]').click()
+ queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
+ expect(queue_pause).to_be_visible()
+ expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
+ page.locator('[data-mobile-queue="later"]').click()
+ persistent_pause = page.locator("#my-work > [data-today-detour]")
+ expect(persistent_pause).to_be_visible()
+ return_button = persistent_pause.locator("[data-return-from-detour]")
+ bounds = return_button.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ return_button.click()
+ expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
+ assert page.evaluate("""() => {
+ const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
+ const timer = JSON.parse(localStorage.getItem(key));
+ return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
+ }""")
+ browser.close()
+ finally:
+ fake.shutdown()
+ fake.server_close()
+ thread.join(timeout=5)
+
+
def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py
index a35123e..266dca2 100644
--- a/tests/test_mobile_task_dock.py
+++ b/tests/test_mobile_task_dock.py
@@ -209,6 +209,60 @@ process.stdout.write(JSON.stringify({{
}
+def test_mobile_task_dock_pauses_before_queues_and_distinguishes_close_from_selection():
+ script = f"""
+const createDock = require({json.dumps(str(DOCK))});
+class FakeElement {{
+ constructor() {{ this.listeners = {{}}; this.open = false; this.attributes = {{}}; }}
+ addEventListener(name, callback) {{ this.listeners[name] = callback; }}
+ setAttribute(name, value) {{ this.attributes[name] = value; }}
+ removeAttribute(name) {{ delete this.attributes[name]; }}
+ showModal() {{ this.open = true; }}
+ close() {{ this.open = false; this.listeners.close?.(); }}
+ focus() {{}}
+}}
+const nav = new FakeElement();
+nav.hidden = false;
+const queues = new FakeElement();
+const close = new FakeElement();
+const row = new FakeElement();
+const button = new FakeElement();
+const calls = [];
+const dock = createDock({{
+ nav, buttons:{{queues:button}}, queueSheet:queues, queueClose:close, queueRows:{{update:row}}, overlays:[],
+ detour:()=>({{beginDetour:reason=>calls.push('pause:' + reason), finishDetour:()=>calls.push('return')}}),
+ onSelectQueue:name=>calls.push('select:' + name),
+ observe:()=>({{disconnect(){{}}}}),
+}});
+dock.start();
+button.listeners.click({{currentTarget:button}});
+close.listeners.click();
+button.listeners.click({{currentTarget:button}});
+row.listeners.click();
+process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout) == {
+ "calls": ["pause:queues", "return", "pause:queues", "select:update"],
+ "open": False,
+ }
+
+
+@pytest.mark.anyio
+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"') == 3
+ assert html.count('data-return-from-detour type="button"') == 3
+ assert "detour:() => timerView" in html
+ timer_source = TIMER.read_text()
+ assert "createTodayDetourInterruption" in timer_source
+ assert "queryAll('#find-work-sheet')" in timer_source
+ assert '.today-detour-interruption button { min-height:44px;' in html
+
+
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -815,7 +869,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
- assert "queues: () => {}" in html
+ assert "detour:() => timerView" in html
assert "mobileTaskDock.updateQueues(counts)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
diff --git a/tests/test_today_timer_handoff.py b/tests/test_today_timer_handoff.py
index 0b76df4..50c9203 100644
--- a/tests/test_today_timer_handoff.py
+++ b/tests/test_today_timer_handoff.py
@@ -227,3 +227,101 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, restored, r
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
"repeated": None,
}
+
+
+def test_mobile_detour_pauses_running_today_time_and_restores_exactly_once():
+ 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;
+const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
+timer.activate('issue:r:42:');
+now = 6000;
+const interruption = timer.beginDetour('find');
+now = 26000;
+const paused = timer.snapshot();
+const durable = timer.detourInterruption();
+const returned = timer.returnFromDetour();
+now = 28000;
+const restored = timer.snapshot();
+const repeated = timer.returnFromDetour();
+process.stdout.write(JSON.stringify({interruption, paused, durable, returned, restored, repeated}));
+"""
+ 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": "find"},
+ "paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
+ "durable": {"identity": "issue:r:42:", "resume": True, "reason": "find"},
+ "returned": {"identity": "issue:r:42:", "resumed": True, "reason": "find"},
+ "restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
+ "repeated": None,
+ }
+
+
+def test_mobile_detour_never_resumes_manually_paused_or_replaced_work():
+ script = SOURCE.read_text() + r"""
+const values = new Map();
+const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
+const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
+timer.adopt('issue:r:1:', 3000, false);
+const pausedDetour = timer.beginDetour('queues');
+timer.activate('issue:r:1:');
+const runningDetour = timer.beginDetour('queues');
+timer.activate('issue:r:2:');
+const replacedReturn = timer.returnFromDetour();
+process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn, snapshot:timer.snapshot()}));
+"""
+ completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout) == {
+ "pausedDetour": None,
+ "runningDetour": {"identity": "issue:r:1:", "resume": True, "reason": "queues"},
+ "replacedReturn": None,
+ "snapshot": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
+ }
+
+
+def test_mobile_detour_view_names_work_and_returns_to_today():
+ script = SOURCE.read_text() + r"""
+const values = new Map();
+const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
+const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
+const banner = {hidden:true};
+const label = {textContent:''};
+const button = {listeners:{}, addEventListener(name, callback) { this.listeners[name] = callback; }, click() { this.listeners.click(); }};
+const returns = [];
+const view = createTodayTimer.createDetourInterruption({
+ timer,
+ queryAll:selector => ({
+ '[data-today-detour]':[banner],
+ '[data-today-detour-label]':[label],
+ '[data-return-from-detour]':[button],
+ }[selector] || []),
+ getItemLabel:identity=>identity === 'issue:r:1:' ? 'Ship release' : '',
+ onReturn:identity=>returns.push(identity),
+});
+timer.activate('issue:r:1:');
+const opened = view.open('find');
+const shown = {hidden:banner.hidden, text:label.textContent, timer:timer.snapshot()};
+button.click();
+process.stdout.write(JSON.stringify({opened, shown, closed:{hidden:banner.hidden, timer:timer.snapshot(), returns}}));
+"""
+ completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout) == {
+ "opened": {"identity": "issue:r:1:", "resume": True, "reason": "find"},
+ "shown": {
+ "hidden": False,
+ "text": "Today paused · Ship release",
+ "timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
+ },
+ "closed": {
+ "hidden": True,
+ "timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": True},
+ "returns": ["issue:r:1:"],
+ },
+ }