feat: keep active Today visible on mobile (Closes #599)
All checks were successful
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 2026-08-11 23:29:50 +00:00
parent 4c13e13275
commit 96e7b35419
8 changed files with 167 additions and 10 deletions

View File

@ -503,13 +503,17 @@ textarea { resize: vertical; min-height: 120px; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[hidden] { display:none; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
@ -570,6 +574,11 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }

View File

@ -74,6 +74,7 @@
});
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
attentionBadge: qs('#mobile-attention-count'),
@ -949,6 +950,11 @@
isActive: () => workSession.checkpointed(),
queryAll: s => document.querySelectorAll(s),
formatEstimate: formatPlanMinutes,
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
onReopen: identity => {
selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
},
});
renderAttentionInterruption();
qs('#return-to-today').addEventListener('click', () => {

View File

@ -811,6 +811,12 @@
</form>
</dialog>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<div class="small" data-work-session-progress aria-live="polite"></div>
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
</section>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>

View File

@ -18,6 +18,10 @@
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
nav.hidden = hidden;
if (options.sessionHud) {
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
else options.sessionHud.removeAttribute('data-overlay-hidden');
}
if (wasHidden && !hidden && launcher) launcher.focus();
wasHidden = hidden;
}

View File

@ -200,9 +200,18 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway }) {
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen }) {
let progress = null;
let runway = null;
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onReopen?.(identity);
})
);
queryAll('[data-mobile-today-toggle]').forEach(button =>
button.addEventListener('click', () => view.toggle())
);
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
const button = document.createElement('button');
button.type = 'button';
@ -232,8 +241,14 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
};
const render = () => {
if (!progress) return;
const snapshot = timer.snapshot();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
if (!progress) return;
const sourceRunway = getRunway?.(snapshot) || runway;
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
@ -273,10 +288,15 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
});
};
return {
open(identity, active) { active ? timer.activate(identity) : timer.stop(); render(); },
finish() { timer.stop(); progress = null; runway = null; },
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
render,
};

View File

@ -26,7 +26,7 @@ FEATURE_SOURCES = {
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
"today-timer": ("static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js"),
}
CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"

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()

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