feat: keep active Today visible on mobile (Closes #599)
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 23:29:50 +00:00
1 parent 4c13e13275
commit 96e7b35419
8 files changed
+167 -10

No files matched your search

+27 -2
View File
@@ -61,13 +61,14 @@ class FakeElement {{
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const sessionHud = new FakeElement();
const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()]));
buttons.attention.hidden = true;
const overlay = new FakeElement();
const calls = [];
let observerCallback;
const dock = createDock({{
nav, buttons, overlays:[overlay],
nav, sessionHud, buttons, overlays:[overlay],
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
}});
@@ -76,10 +77,12 @@ buttons.work.click();
buttons.find.click();
overlay.classList.values.add('open'); observerCallback();
const hiddenWhileOpen = nav.hidden;
const hudHiddenWhileOpen = sessionHud.attributes['data-overlay-hidden'];
overlay.classList.values.delete('open'); observerCallback();
buttons.drafts.click();
process.stdout.write(JSON.stringify({{
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden, hudHiddenWhileOpen,
hudHiddenAfterClose:sessionHud.attributes['data-overlay-hidden'] || null,
attentionHidden:buttons.attention.hidden,
findFocuses:buttons.find.focuses,
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
@@ -94,6 +97,8 @@ process.stdout.write(JSON.stringify({{
"calls": ["work", "find", "drafts"],
"hiddenWhileOpen": True,
"hiddenAfterClose": False,
"hudHiddenWhileOpen": "true",
"hudHiddenAfterClose": None,
"attentionHidden": True,
"findFocuses": 1,
"current": {
@@ -217,6 +222,26 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@pytest.mark.anyio
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
html = await dashboard()
assert 'class="mobile-today-hud" data-mobile-today-hud' in html
assert 'data-mobile-today-open' in html
assert 'data-work-session-progress' in html
assert 'data-work-session-timer-toggle' in html
assert 'data-work-session-adjust-plan' in html
assert "getItem: identity => [...todayMyWork, ...activeMyWork].find" in html
assert "onReopen: identity =>" in html
assert "workSession.reopen(item)" in html
assert '.mobile-today-hud { display:none;' in html
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
assert '.mobile-today-hud button { min-height:44px;' in html
assert '.mobile-task-action[hidden] { display:none;' in html
assert 'max-width:100%;' in html
assert 'overflow:hidden;' in html
@pytest.mark.anyio
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
html = await dashboard()
+89 -2
View File
@@ -2245,7 +2245,9 @@ const toggle = {{hidden:false,textContent:'',setAttribute(){{}}}};
const snapshot = {{identity:'issue:r:1:',elapsed_ms:45*60000,running:true}};
const view = createTodayTimer.createView({{
timer:{{snapshot:()=>snapshot,totalElapsed:()=>45*60000}}, isActive:()=>true,
queryAll:selector => selector.includes('progress') ? [progress] : selector.includes('adjust-plan') ? [adjust] : [toggle],
queryAll:selector => selector.includes('progress') ? [progress] :
selector.includes('adjust-plan') ? [adjust] :
selector === '[data-work-session-timer-toggle]' ? [toggle] : [],
formatEstimate:minutes => minutes + 'm',
getRunway:() => ({{current_minutes:30,remaining_minutes:90,future_minutes:60,capacity_minutes:100}}),
}});
@@ -2260,6 +2262,90 @@ process.stdout.write(JSON.stringify({{text:progress.textContent,adjustHidden:adj
}
def test_today_timer_view_keeps_active_item_and_controls_visible_in_mobile_hud():
script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
class Element {{
constructor() {{ this.hidden=true; this.textContent=''; this.attributes={{}}; this.listeners={{}}; }}
setAttribute(name,value) {{ this.attributes[name]=value; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ this.listeners.click?.(); }}
}}
const hud = new Element();
const title = new Element();
const progress = new Element();
const adjust = new Element();
const toggle = new Element();
let running = true;
const calls = [];
const selectors = {{
'[data-mobile-today-hud]':[hud],
'[data-mobile-today-open]':[title],
'[data-mobile-today-toggle]':[toggle],
'[data-work-session-progress]':[progress],
'[data-work-session-adjust-plan]':[adjust],
'[data-work-session-timer-toggle]':[toggle],
}};
const view = createTodayTimer.createView({{
timer:{{
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:10*60000,running}}),
totalElapsed:()=>10*60000,
pause:()=>{{running=false;calls.push('pause');return true;}},
resume:()=>{{running=true;calls.push('resume');return true;}},
stop:()=>{{running=false;return true;}},
}},
isActive:()=>true,
queryAll:selector => selectors[selector] || [],
formatEstimate:minutes => minutes + 'm',
getItem:identity => identity === 'issue:r:1:' ? {{title:'Ship the mobile Today HUD'}} : null,
onReopen:identity => calls.push('open:' + identity),
}});
view.update({{index:1,total:2}},{{current_minutes:30,future_minutes:20,capacity_minutes:60}});
const active = {{hidden:hud.hidden,title:title.textContent,progress:progress.textContent,toggle:toggle.textContent}};
title.click();
toggle.click();
const paused = {{toggle:toggle.textContent,pressed:toggle.attributes['aria-pressed']}};
toggle.click();
view.finish();
process.stdout.write(JSON.stringify({{active,paused,calls,finishedHidden:hud.hidden}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"active": {
"hidden": False,
"title": "Ship the mobile Today HUD",
"progress": "Item 1 of 2 · 10:00 / 30m · 40m remaining",
"toggle": "Pause timer",
},
"paused": {"toggle": "Resume timer", "pressed": "true"},
"calls": ["open:issue:r:1:", "pause", "resume"],
"finishedHidden": True,
}
def test_today_timer_view_reopens_current_item_without_resuming_a_paused_timer():
script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const calls = [];
const timer = {{
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:60000,running:false}}),
totalElapsed:()=>60000,
activate:identity=>{{calls.push(identity);return true;}},
stop:()=>true,
}};
const view = createTodayTimer.createView({{
timer, isActive:()=>true, queryAll:()=>[], formatEstimate:String,
}});
view.open('issue:r:1:', true);
view.open('issue:r:2:', true);
process.stdout.write(JSON.stringify(calls));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["issue:r:2:"]
def test_today_budget_replan_pauses_once_and_restores_the_prior_timer_state():
script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
@@ -2344,7 +2430,8 @@ async def test_today_timer_is_wired_into_every_mobile_session_control():
assert 'createTodayTimer({' in html
assert 'createTodayTimerView({' in html
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
assert "if (active && current.identity !== identity) timer.activate(identity)" in timer_source
assert "else if (!active) timer.stop()" in timer_source
assert "setInterval(timerView.render, 1000)" in recap_source
assert 'timer.pause()' in timer_source
assert 'timer.resume()' in timer_source