Add a timed mobile break and return-to-Today flow #1037

Merged
rockachopa merged 2 commits from timmy/1036-timed-mobile-today-break into main 2026-08-17 19:37:52 +00:00
11 changed files with 478 additions and 5 deletions

View File

@ -254,6 +254,21 @@ textarea { resize: vertical; min-height: 120px; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-break-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
.today-break-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-break-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.today-break-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-break-panel h2, .today-break-panel p { margin-top:0; }
.today-break-panel header button, .today-break-actions button, .today-break-custom button { min-height:44px; }
.today-break-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px 0; }
.today-break-actions button { min-width:0; width:100%; }
.today-break-custom { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
.today-break-custom label, .today-break-custom p { grid-column:1 / -1; }
.today-break-custom input { box-sizing:border-box; width:100%; min-height:44px; }
.today-break-status { position:fixed; z-index:46; left:12px; right:12px; bottom:calc(224px + env(safe-area-inset-bottom)); box-sizing:border-box; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#102641; }
.today-break-status:has(> span[hidden]) { display:none; }
.today-break-status button { min-height:44px; min-width:112px; }
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
@ -1072,8 +1087,10 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-today-break-open] { grid-column:1 / -1; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
.today-completion-undo { bottom:calc(168px + env(safe-area-inset-bottom)); }
.today-completion-undo { bottom:calc(224px + env(safe-area-inset-bottom)); }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {

View File

@ -1828,6 +1828,11 @@
selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
},
onResume: identity => {
selectTodayWork();
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
if (!workSession.resume(item)) resumeTodaySession();
},
onComplete: identity => {
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
return completeTodayItem(item);

View File

@ -1536,8 +1536,30 @@
<button data-mobile-today-complete type="button" hidden>Done &amp; next</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-today-break-open type="button">Take break</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
</section>
<div class="today-break-status" role="status" aria-live="polite">
<span id="today-break-status" hidden></span>
<button id="resume-today-break" type="button" hidden>Resume Today</button>
</div>
<dialog class="today-break-sheet" id="today-break-sheet" aria-labelledby="today-break-title">
<section class="today-break-panel">
<header><div><p class="small muted">Today timer</p><h2 id="today-break-title">Take a timed break</h2></div><button id="cancel-today-break" type="button">Cancel</button></header>
<p class="small">Your current item stays selected and tracked time remains paused until you explicitly resume.</p>
<div class="today-break-actions" aria-label="Break duration">
<button type="button" data-today-break-minutes="5">5 minutes</button>
<button type="button" data-today-break-minutes="15">15 minutes</button>
<button type="button" data-today-break-minutes="30">30 minutes</button>
</div>
<form id="today-break-custom-form" class="today-break-custom">
<label for="today-break-custom-minutes">Custom minutes</label>
<input id="today-break-custom-minutes" type="number" inputmode="numeric" min="1" max="120" step="1" required />
<button type="submit">Start break</button>
<p id="today-break-error" class="small" role="alert"></p>
</form>
</section>
</dialog>
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
@ -1632,6 +1654,7 @@
<script src="static/work-selection.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-break.js"></script>
<script src="static/today-lock-screen.js"></script>
<script src="static/today-session-sync.js"></script>
<script src="static/today-recap.js"></script>

View File

@ -51,6 +51,7 @@ const SHELL = [
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-break.js',
BASE + 'static/today-lock-screen.js',
BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js',

106
frontend/today-break.js Normal file
View File

@ -0,0 +1,106 @@
function createTodayBreak({
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
minutesInput = null, error = null, status, resumeButton,
qs = null, queryAll = null,
historyRef = null, windowRef = null,
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
onChange = () => {}, onResume = () => {},
}) {
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
historyRef ||= globalThis.history;
windowRef ||= globalThis.window;
sheet ||= qs?.('#today-break-sheet');
openButtons ||= queryAll?.('[data-today-break-open]') || [];
cancelButton ||= qs?.('#cancel-today-break');
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
form ||= qs?.('#today-break-custom-form');
minutesInput ||= qs?.('#today-break-custom-minutes');
error ||= qs?.('#today-break-error');
status ||= qs?.('#today-break-status');
resumeButton ||= qs?.('#resume-today-break');
let ticker = null;
let historyEntry = false;
const stopTicker = () => {
if (ticker !== null) clearIntervalRef(ticker);
ticker = null;
};
const render = () => {
const pending = timer.breakSnapshot();
if (!pending) {
stopTicker();
status.hidden = true;
resumeButton.hidden = true;
openButtons.forEach(button => { button.hidden = false; });
return false;
}
status.hidden = false;
resumeButton.hidden = false;
openButtons.forEach(button => { button.hidden = true; });
const remaining = Math.max(0, pending.deadline_at - now());
if (pending.expired || remaining <= 0) {
status.textContent = 'Break over · ready to resume';
} else {
const seconds = Math.ceil(remaining / 1000);
const minutes = Math.floor(seconds / 60);
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
}
if (ticker === null) ticker = setIntervalRef(render, 1000);
return true;
};
const close = () => {
if (sheet.open) sheet.close();
if (historyEntry) {
historyEntry = false;
historyRef?.back();
}
};
const start = minutes => {
if (!timer.startBreak(Number(minutes))) return false;
if (error) error.textContent = '';
close();
render();
onChange();
return true;
};
const open = () => {
if (!timer.snapshot().identity) return false;
if (!sheet.open) {
sheet.showModal();
if (historyRef) {
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
historyEntry = true;
}
}
return true;
};
openButtons.forEach(button => button.addEventListener('click', open));
cancelButton?.addEventListener('click', close);
windowRef?.addEventListener('popstate', () => {
historyEntry = false;
if (sheet.open) sheet.close();
});
presetButtons.forEach(button => button.addEventListener('click', () =>
start(button.dataset.todayBreakMinutes)
));
form?.addEventListener('submit', event => {
event.preventDefault();
if (!start(Number(minutesInput?.value))) {
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
minutesInput?.focus();
}
});
resumeButton.addEventListener('click', () => {
const pending = timer.breakSnapshot();
if (!timer.resumeBreak()) return;
onResume(pending.identity);
render();
onChange();
});
render();
return { open, close, start, render };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;

View File

@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null,
pending_interruption:null, attention_interruption:null, timed_break:null,
});
const read = () => {
const ownerKey = key();
@ -42,6 +42,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validBreak = state => {
const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity &&
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
};
const settle = (state, at = now()) => {
const entry = state.entries[state.active_identity];
if (!entry?.running) return state;
@ -75,6 +81,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.timed_break = null;
return write(state);
},
activate(identity) {
@ -90,6 +97,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.timed_break = null;
return write(state);
},
pause() {
@ -106,6 +114,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
entry.started_at = now();
entry.running = true;
}
state.timed_break = null;
return write(state);
},
startBreak(minutes) {
const duration = Number(minutes);
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
const state = read();
const identity = state.active_identity;
if (!identity || !state.entries[identity]) return false;
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
return write(state) ? validBreak(state) : false;
},
breakSnapshot() {
return validBreak(read());
},
resumeBreak() {
const state = read();
const pending = validBreak(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
state.timed_break = null;
entry.started_at = now();
entry.running = true;
return write(state);
},
stop() {
@ -114,6 +148,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.timed_break = null;
return write(state);
},
beginAttention() {
@ -217,9 +252,13 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete }) {
let progress = null;
let runway = null;
let breakView = null;
if (typeof createTodayBreak === 'function') {
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
}
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
@ -265,6 +304,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
};
const render = () => {
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {

View File

@ -34,7 +34,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",

View File

@ -80,6 +80,53 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#plan-today-sheet")).to_be_hidden()
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
take_break = page.locator("[data-today-break-open]")
expect(take_break).to_be_visible()
take_break.click()
expect(page.locator("#today-break-sheet")).to_be_visible()
for control in page.locator(".today-break-actions button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.go_back()
expect(page.locator("#today-break-sheet")).to_be_hidden()
take_break.click()
page.locator('[data-today-break-minutes="5"]').click()
expect(page.locator("#today-break-status")).to_have_text("On break · resume in 5:00")
expect(page.locator("[data-mobile-today-toggle]")).to_have_text("Resume timer")
page.reload(wait_until="networkidle")
expect(page.locator("#my-work-status")).to_contain_text("2")
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("#resume-today-break")).to_be_visible()
page.locator("#resume-today-break").click()
expect(page.locator("#today-break-status")).to_be_hidden()
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
page.set_viewport_size({"width": 320, "height": 568})
page.locator("[data-today-break-open]").click()
expect(page.locator("#today-break-sheet")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
custom = page.locator("#today-break-custom-minutes")
bounds = custom.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#cancel-today-break").click()
expect(page.locator("#today-break-sheet")).to_be_hidden()
page.set_viewport_size({"width": 390, "height": 844})
page.locator("[data-mobile-today-open]").click()
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
page.locator('[data-issue-section="reply"]').click()
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
page.locator("#send-issue-comment-next").click()
@ -109,6 +156,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
undo.click()
expect(receipt).to_be_hidden()
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")

View File

@ -1097,6 +1097,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-break.js",
"/dashboard/static/today-lock-screen.js",
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",

232
tests/test_today_break.py Normal file
View File

@ -0,0 +1,232 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TIMER = ROOT / "frontend" / "today-timer.js"
BREAK = ROOT / "frontend" / "today-break.js"
def run_node(source: str) -> dict:
completed = subprocess.run(
["node", "-e", source], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_timed_break_pauses_immediately_and_restores_absolute_deadline():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 100000;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
now = 112000;
const started = timer.startBreak(15);
const paused = timer.snapshot();
now = 200000;
const restored = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}).breakSnapshot();
process.stdout.write(JSON.stringify({started,paused,restored}));
"""
assert run_node(script) == {
"started": {
"identity": "issue:r:42:",
"deadline_at": 1012000,
"expired": False,
},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False},
"restored": {
"identity": "issue:r:42:",
"deadline_at": 1012000,
"expired": False,
},
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 0;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
const invalid = [timer.startBreak(0), timer.startBreak(121), timer.startBreak(1.5)];
const started = timer.startBreak(5);
now = 300001;
const expired = timer.breakSnapshot();
const stillPaused = timer.snapshot();
const resumed = timer.resumeBreak();
now = 305001;
const running = timer.snapshot();
const replay = timer.resumeBreak();
process.stdout.write(JSON.stringify({invalid,started,expired,stillPaused,resumed,running,replay,remaining:timer.breakSnapshot()}));
"""
result = run_node(script)
assert result["invalid"] == [False, False, False]
assert result["started"]["deadline_at"] == 300000
assert result["expired"]["expired"] is True
assert result["stillPaused"]["running"] is False
assert result["resumed"] is True
assert result["running"] == {
"identity": "issue:r:42:", "elapsed_ms": 5000, "running": True
}
assert result["replay"] is False
assert result["remaining"] is None
def test_mobile_break_sheet_starts_counts_down_and_requires_explicit_resume():
script = f"""
const createBreak = require({json.dumps(str(BREAK))});
class Element {{
constructor() {{ this.listeners={{}}; this.hidden=false; this.textContent=''; this.value=''; this.open=false; this.dataset={{}}; }}
addEventListener(name, fn) {{ this.listeners[name]=fn; }}
click() {{ return this.listeners.click?.({{preventDefault(){{}}}}); }}
showModal() {{ this.open=true; }}
close() {{ this.open=false; this.listeners.close?.(); }}
focus() {{}}
}}
let now=100000;
let pending=null;
const calls=[];
const timer={{
snapshot:()=>({{identity:'issue:r:42:',elapsed_ms:5000,running:!pending}}),
breakSnapshot:()=>pending && {{...pending,expired:now>=pending.deadline_at}},
startBreak:minutes=>{{calls.push(['start',minutes]);pending={{identity:'issue:r:42:',deadline_at:now+minutes*60000}};return {{...pending,expired:false}};}},
resumeBreak:()=>{{if(!pending)return false;calls.push(['resume']);pending=null;return true;}},
}};
const sheet=new Element(), opener=new Element(), cancel=new Element(), preset=new Element(), status=new Element(), resume=new Element();
preset.dataset.todayBreakMinutes='15';
let tick;
const controller=createBreak({{timer,sheet,openButtons:[opener],cancelButton:cancel,presetButtons:[preset],status,resumeButton:resume,now:()=>now,setIntervalRef:fn=>(tick=fn,1),clearIntervalRef:()=>{{}},onChange:()=>calls.push(['change']),onResume:identity=>calls.push(['return',identity])}});
opener.click();
const opened=sheet.open;
cancel.click();
const cancelled={{open:sheet.open,calls:[...calls]}};
opener.click(); preset.click();
const started={{open:sheet.open,text:status.textContent,statusHidden:status.hidden,resumeHidden:resume.hidden,openerHidden:opener.hidden}};
now=100000+15*60000+1; tick();
const expired={{text:status.textContent,resumeHidden:resume.hidden,running:timer.snapshot().running}};
resume.click();
process.stdout.write(JSON.stringify({{opened,cancelled,started,expired,final:{{calls,statusHidden:status.hidden,openerHidden:opener.hidden,pending}}}}));
"""
assert run_node(script) == {
"opened": True,
"cancelled": {"open": False, "calls": []},
"started": {
"open": False,
"text": "On break · resume in 15:00",
"statusHidden": False,
"resumeHidden": False,
"openerHidden": True,
},
"expired": {
"text": "Break over · ready to resume",
"resumeHidden": False,
"running": False,
},
"final": {
"calls": [["start", 15], ["change"], ["resume"], ["return", "issue:r:42:"], ["change"]],
"statusHidden": True,
"openerHidden": False,
"pending": None,
},
}
def test_timed_break_is_packaged_as_an_accessible_mobile_flow():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'data-today-break-open' in html
assert 'id="today-break-sheet"' in html
assert 'data-today-break-minutes="5"' in html
assert 'data-today-break-minutes="15"' in html
assert 'data-today-break-minutes="30"' in html
assert 'id="today-break-custom-minutes"' in html
assert 'min="1" max="120" step="1"' in html
assert 'id="today-break-status"' in html
assert 'id="resume-today-break"' in html
assert '<script src="static/today-break.js"></script>' in html
assert "createTodayBreak({" in (ROOT / "frontend" / "today-timer.js").read_text()
assert '"static/today-break.js"' in bundle
assert ".today-break-panel" in css
assert "overflow-x:hidden" in css
assert ".today-break-actions button" in css
assert "min-height:44px" in css
def test_custom_break_validation_is_side_effect_free_until_valid():
script = f"""
const createBreak = require({json.dumps(str(BREAK))});
class Element {{
constructor() {{this.listeners={{}};this.open=true;this.hidden=false;this.value='';this.textContent='';}}
addEventListener(name,fn){{this.listeners[name]=fn;}}
close(){{this.open=false;}}
focus(){{this.focused=true;}}
}}
const form=new Element(), input=new Element(), error=new Element(), status=new Element(), resume=new Element(), sheet=new Element();
const calls=[];
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,startBreak:value=>{{calls.push(value);return value===45;}},resumeBreak:()=>false}};
createBreak({{timer,sheet,form,minutesInput:input,error,status,resumeButton:resume,setIntervalRef:()=>1,clearIntervalRef:()=>{{}}}});
input.value='2.5'; form.listeners.submit({{preventDefault(){{}}}});
const invalid={{open:sheet.open,error:error.textContent,focused:input.focused||false}};
input.value='45'; form.listeners.submit({{preventDefault(){{}}}});
process.stdout.write(JSON.stringify({{invalid,valid:{{open:sheet.open,error:error.textContent}},calls}}));
"""
assert run_node(script) == {
"invalid": {
"open": True,
"error": "Choose a whole number from 1 to 120 minutes.",
"focused": True,
},
"valid": {"open": False, "error": ""},
"calls": [2.5, 45],
}
def test_replacing_or_stopping_active_work_clears_an_old_break():
script = TIMER.read_text() + r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now=0;
const timer=createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:1:'); timer.startBreak(5); timer.activate('issue:r:2:');
const replaced=timer.breakSnapshot();
timer.startBreak(5); timer.stop();
const stopped=timer.breakSnapshot();
timer.activate('issue:r:3:'); timer.startBreak(5); timer.adopt('issue:r:4:',1000,false);
process.stdout.write(JSON.stringify({replaced,stopped,adopted:timer.breakSnapshot()}));
"""
assert run_node(script) == {"replaced": None, "stopped": None, "adopted": None}
def test_browser_back_closes_break_sheet_without_starting_a_break():
script = f"""
const createBreak=require({json.dumps(str(BREAK))});
class Element{{constructor(){{this.listeners={{}};this.open=false;this.hidden=true;}}addEventListener(n,f){{this.listeners[n]=f;}}showModal(){{this.open=true;}}close(){{this.open=false;}}}}
const sheet=new Element(), opener=new Element(), status=new Element(), resume=new Element();
const listeners={{}}, pushes=[];
const historyRef={{state:{{route:'today'}},pushState:state=>{{historyRef.state=state;pushes.push(state);}},back:()=>listeners.popstate?.()}};
const windowRef={{addEventListener:(name,fn)=>listeners[name]=fn}};
const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,resumeBreak:()=>false}};
createBreak({{timer,sheet,openButtons:[opener],status,resumeButton:resume,historyRef,windowRef,queryAll:()=>[]}});
opener.listeners.click();
const opened={{open:sheet.open,state:historyRef.state,pushes}};
listeners.popstate();
process.stdout.write(JSON.stringify({{opened,closed:!sheet.open}}));
"""
assert run_node(script) == {
"opened": {
"open": True,
"state": {"route": "today", "todayBreak": True},
"pushes": [{"route": "today", "todayBreak": True}],
},
"closed": True,
}

View File

@ -478,7 +478,7 @@ async def test_today_completion_surfaces_a_touch_safe_accessible_undo_receipt():
assert 'Removed from Today. Undo?' in html
assert "getElementById('today-completion-undo')" in TODAY_COMPLETION.read_text()
assert ".today-completion-undo button { min-height:44px" in html
assert "bottom:calc(168px + env(safe-area-inset-bottom))" in html
assert "bottom:calc(224px + env(safe-area-inset-bottom))" in html
@pytest.mark.anyio