feat: pause Today during mobile Insights (Closes #1082)
This commit is contained in:
parent
d7b8cd5e71
commit
472371d5a8
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 & view</h2></summary>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user