feat: pause Today during mobile work detours (Closes #1080)
CI / lint (pull_request) Successful in 3m12s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 13:51:40 +00:00
1 parent 38efd4eb7c
commit 58cee5ea16
8 files changed
+338 -7

No files matched your search

@@ -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
+55 -1
View File
@@ -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
+98
View File
@@ -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:"],
},
}