feat: pause Today during mobile Insights (Closes #1082)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m45s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 14:33:25 +00:00
parent d7b8cd5e71
commit 472371d5a8
8 changed files with 168 additions and 19 deletions

View File

@ -254,16 +254,14 @@
root: qs('#insights-sheet'),
launcher: qs('#open-insights'),
closeButton: qs('#close-insights'),
dock: qs('#mobile-task-dock'),
hud: qs('[data-mobile-today-hud]'),
backgrounds: [qs('header'), qs('#my-work')],
menu: qs('.app-menu'),
history: window.history,
location: window.location,
eventTarget: window,
mediaQuery: window.matchMedia('(max-width: 600px)'),
detour:() => timerView,
});
mobileInsights.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
@ -1841,7 +1839,8 @@
onReopen: identity => {
taskOverlayHistory.leave();
selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
const item = [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity);
if (!workSession.reopen(item)) workSession.resume(item);
},
onResume: identity => {
selectTodayWork();
@ -1854,6 +1853,7 @@
},
onCapture:() => openCreateIssueSheet(),
});
mobileInsights.start();
todaySessionSync = attachTodaySessionHandoff({
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
items:() => [...todayMyWork, ...activeMyWork],

View File

@ -309,6 +309,10 @@
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
<button id="close-insights" type="button">Close</button>
</div>
<aside class="today-detour-interruption" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button data-return-from-detour type="button">Return to Today</button>
</aside>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>

View File

@ -3,8 +3,11 @@
else root.createMobileInsights = factory;
})(typeof self !== 'undefined' ? self : this, function (o) {
const route = '#/insights';
const events = o.eventTarget || self;
const location = o.location || self.location;
let active = false;
let previous = '#/my-work';
let retries = 0;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
@ -12,7 +15,14 @@
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
const detour = active ? o.detour?.() : null;
const started = detour?.beginDetour('insights');
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
else retries = 0;
if (wasActive && !active) o.detour?.()?.finishDetour();
attr(o.root, 'data-mobile-open', active ? 'true' : null);
attr(o.root, 'role', active ? 'dialog' : null);
attr(o.root, 'aria-modal', active ? 'true' : null);
@ -26,20 +36,21 @@
}
function url(hash) {
return (o.location.pathname || '') + (o.location.search || '') + hash;
return (location.pathname || '') + (location.search || '') + hash;
}
function open() {
if (!o.mediaQuery.matches || active) return false;
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
if (o.menu) o.menu.open = false;
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
const menu = o.menu || o.launcher.closest?.('details');
if (menu) menu.open = false;
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
render(true);
return true;
}
function sync() {
render(o.location.hash === route, active && o.location.hash !== route);
render(location.hash === route, active && location.hash !== route);
}
function close() {
@ -55,12 +66,23 @@
return true;
}
function returnToToday() {
if (!active) return false;
const state = { ...(o.history.state || {}) };
delete state.mobileInsights;
delete state.previousHash;
o.history.replaceState(state, '', url(previous));
render(false);
return true;
}
function start() {
o.launcher.addEventListener('click', open);
o.closeButton.addEventListener('click', close);
o.eventTarget.addEventListener('popstate', sync);
o.eventTarget.addEventListener('hashchange', sync);
o.eventTarget.addEventListener('keydown', event => {
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
events.addEventListener('popstate', sync);
events.addEventListener('hashchange', sync);
events.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
@ -70,5 +92,5 @@
sync();
}
return { start, open, close, current:() => active };
return { start, open, close, sync, current:() => active };
});

View File

@ -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'].includes(pending.reason) ?
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights'].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'].includes(reason)) return null;
if (!['find', 'queues', 'insights'].includes(reason)) return null;
const state = read();
const existing = validDetour(state);
if (existing) return existing;

View File

@ -351,7 +351,7 @@ 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):
def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
@ -381,7 +381,7 @@ def test_release_artifact_pauses_today_across_find_and_queue_detours(tmp_path: P
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()
page.locator("#close-issue-sheet").click(force=True)
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
@ -421,6 +421,31 @@ def test_release_artifact_pauses_today_across_find_and_queue_detours(tmp_path: P
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("#app-menu-toggle").click()
page.locator("#open-insights").click()
insights_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(insights_pause).to_be_visible()
expect(insights_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = insights_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 === 'insights';
}""")
page.reload(wait_until="networkidle")
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(restored_pause).to_be_visible()
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
restored_pause.locator("[data-return-from-detour]").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()

View File

@ -150,6 +150,70 @@ process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
assert run_node(script) == {"backs": 1, "prevented": 1}
def test_insights_route_owns_the_today_detour_for_open_back_and_reload():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);return {{identity:'issue:r:42:',reason}};}},finishDetour(){{calls.push('finish');return {{identity:'issue:r:42:'}};}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start();
launcher.listeners.click();
location.hash='#/my-work/today'; history.state=null; listeners.popstate();
listeners.popstate();
location.hash='#/insights'; listeners.hashchange();
process.stdout.write(JSON.stringify({{calls,current:controller.current()}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish", "begin:insights"],
"current": True,
}
def test_return_to_today_closes_insights_and_finishes_the_detour_once():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);}},finishDetour(){{calls.push('finish');}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click(); returnButton.listeners.click();
process.stdout.write(JSON.stringify({{calls,current:controller.current(),hash:location.hash}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish"],
"current": False,
"hash": "#/my-work/today",
}
def test_direct_insights_route_retries_detour_after_identity_restores():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/insights'}};
let attempts=0; let retry;
global.setTimeout=callback=>{{retry=callback;}};
const detour={{beginDetour(){{attempts+=1;return attempts > 1 ? {{identity:'issue:r:42:'}} : null;}},finishDetour(){{}}}};
const history={{state:null,replaceState(){{}},pushState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); retry();
process.stdout.write(JSON.stringify({{attempts,current:controller.current()}}));
"""
assert run_node(script) == {"attempts": 2, "current": True}
@pytest.mark.anyio
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
html = await dashboard()
@ -157,6 +221,11 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
assert 'id="open-insights"' in html
assert 'id="insights-sheet"' in html
assert 'id="close-insights"' in html
insights = html[html.index('id="insights-sheet"'):html.index('</section>', html.index('id="insights-sheet"'))]
assert 'data-today-detour' in insights
assert 'data-return-from-detour' in insights
assert "detour:() => timerView" in html
assert html.index("const timerView = createTodayTimerView({") < html.index("mobileInsights.start();")
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')

View File

@ -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"') == 3
assert html.count('data-return-from-detour type="button"') == 3
assert html.count('data-today-detour role="status"') == 4
assert html.count('data-return-from-detour type="button"') == 4
assert "detour:() => timerView" in html
timer_source = TIMER.read_text()
assert "createTodayDetourInterruption" in timer_source

View File

@ -260,6 +260,35 @@ process.stdout.write(JSON.stringify({interruption, paused, durable, returned, re
}
def test_insights_detour_excludes_review_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('insights');
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": "insights"},
"restored": {"identity": "issue:r:42:", "resume": True, "reason": "insights"},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "insights"},
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
}
def test_mobile_detour_never_resumes_manually_paused_or_replaced_work():
script = SOURCE.read_text() + r"""
const values = new Map();