feat: keep active Today visible on mobile (Closes #599)
This commit is contained in:
parent
4c13e13275
commit
96e7b35419
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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})';"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user