diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 0b6de18..f512ef5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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) { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 910c7e4..7a94c8a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1828,6 +1828,7 @@ selectTodayWork(); workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity)); }, + onResume: () => resumeTodaySession(), onComplete: identity => { const item = todayMyWork.find(entry => todayWork.identity(entry) === identity); return completeTodayItem(item); diff --git a/frontend/index.html b/frontend/index.html index e2a8e52..45de51a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1536,8 +1536,30 @@
+ +
+ + +
+ +
+

Today timer

Take a timed break

+

Your current item stays selected and tracked time remains paused until you explicitly resume.

+
+ + + +
+
+ + + + +
+
+

Work queues

@@ -1632,6 +1654,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f5f2ca7..468e910 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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', diff --git a/frontend/today-break.js b/frontend/today-break.js new file mode 100644 index 0000000..8ef2d8d --- /dev/null +++ b/frontend/today-break.js @@ -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; diff --git a/frontend/today-timer.js b/frontend/today-timer.js index de307c4..180085d 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -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 => { diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index f401e6b..d07c42b 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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", diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index 231fc0f..122442f 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -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") diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 8e08d68..cfe0f7c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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", diff --git a/tests/test_today_break.py b/tests/test_today_break.py new file mode 100644 index 0000000..17c9e43 --- /dev/null +++ b/tests/test_today_break.py @@ -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 '' 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, + } diff --git a/tests/test_today_work.py b/tests/test_today_work.py index 900f5aa..0e4d01c 100644 --- a/tests/test_today_work.py +++ b/tests/test_today_work.py @@ -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