From 348f43f1bcbdb889e77173063d5218d16ee50dab Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 16:35:09 +0000 Subject: [PATCH] feat: pause Today for live data status (Closes #1138) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 19 +----- frontend/index.html | 2 + frontend/live-data-status.js | 109 +++++++++++++++++++++++++++++- frontend/today-timer.js | 4 +- tests/test_live_data_status.py | 107 +++++++++++++++++++++++++++++ tests/test_today_timer_handoff.py | 29 ++++++++ 7 files changed, 250 insertions(+), 21 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 40487bb..0003623 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -21,6 +21,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex .live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .live-data-status-header h2, .live-data-status-header p { margin-top:0; } .live-data-status-header button, .live-data-status-actions button { min-height:44px; } +.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; } .issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .issue-filing-receipt[hidden] { display:none; } .release-receipt-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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8aab6a9..ba9292d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7451,24 +7451,7 @@ 'Next automatic retry in ' + retrySeconds + 's.' : (pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.'); } - function closeLiveDataStatus() { - liveDataStatusSheet.hidden = true; - liveDataStatusTrigger.setAttribute('aria-expanded', 'false'); - liveDataStatusTrigger.focus(); - } - liveDataStatusTrigger.addEventListener('click', () => { - renderLiveDataStatus(); - liveDataStatusSheet.hidden = false; - liveDataStatusTrigger.setAttribute('aria-expanded', 'true'); - qs('#close-live-data-status').focus(); - }); - qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus); - liveDataStatusSheet.addEventListener('click', event => { - if (event.target === liveDataStatusSheet) closeLiveDataStatus(); - }); - liveDataStatusSheet.addEventListener('keydown', event => { - if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); } - }); + liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start(); const liveDataRefresh = liveDataStatus.createRefreshController({ button: qs('#refresh-live-data'), output: qs('#live-data-status-result'), diff --git a/frontend/index.html b/frontend/index.html index df8e808..5d22517 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -44,10 +44,12 @@

Live data status

Check which dashboard feeds are current before acting.

+

+
diff --git a/frontend/live-data-status.js b/frontend/live-data-status.js index 3d53d1b..c427980 100644 --- a/frontend/live-data-status.js +++ b/frontend/live-data-status.js @@ -74,5 +74,112 @@ return { run }; } - return { describe, createRefreshController }; + function createSheetController(options) { + let ownsDetour = false; + let backgroundState = null; + + function focusableControls() { + return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')] + .filter(control => !control.hidden); + } + + function containBackground() { + if (backgroundState) return; + backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert])); + backgroundState.forEach((_wasInert, element) => { element.inert = true; }); + } + + function releaseBackground() { + if (!backgroundState) return; + backgroundState.forEach((wasInert, element) => { element.inert = wasInert; }); + backgroundState = null; + } + + function finishClose() { + if (options.sheet.hidden) return; + options.sheet.hidden = true; + options.trigger.setAttribute('aria-expanded', 'false'); + if (options.pausedStatus) options.pausedStatus.hidden = true; + releaseBackground(); + if (ownsDetour) options.timerView?.finishDetour?.(); + ownsDetour = false; + options.trigger.focus?.(); + } + + function close() { + if (options.history?.state?.liveDataStatus) { + options.history.back(); + return; + } + finishClose(); + } + + function open() { + if (!options.sheet.hidden) return; + ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status'; + if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour; + if (options.returnButton) options.returnButton.hidden = !ownsDetour; + containBackground(); + options.sheet.hidden = false; + options.trigger.setAttribute('aria-expanded', 'true'); + if (options.history && !options.history.state?.liveDataStatus) { + options.history.pushState({ ...options.history.state, liveDataStatus:true }, ''); + } + options.closeButton.focus(); + } + + function start() { + options.trigger.addEventListener('click', open); + options.closeButton.addEventListener('click', close); + options.returnButton?.addEventListener('click', close); + options.sheet.addEventListener('click', event => { + if (event.target === options.sheet) close(); + }); + options.escapeTarget?.addEventListener('keydown', event => { + if (options.sheet.hidden) return; + if (event.key === 'Escape') { + event.preventDefault?.(); + close(); + return; + } + if (event.key !== 'Tab') return; + const controls = focusableControls(); + if (!controls.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && event.target === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && event.target === last) { + event.preventDefault(); + first.focus(); + } + }); + options.historyTarget?.addEventListener('popstate', event => { + if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose(); + }); + } + + return { start, open, close }; + } + + function mount(options) { + const document = options.document; + const qs = selector => document.querySelector(selector); + const trigger = qs('#open-live-data-status'); + trigger.addEventListener('click', options.onOpen); + return createSheetController({ + sheet:qs('#live-data-status-sheet'), trigger, + closeButton:qs('#close-live-data-status'), + returnButton:qs('#return-from-live-data-status'), + pausedStatus:qs('#live-data-status-today-paused'), + timerView:options.timerView, + history:options.window.history, + historyTarget:options.window, + escapeTarget:document, + backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean), + }); + } + + return { describe, createRefreshController, createSheetController, mount }; }); diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 87a51ba..0e05452 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange const validDetour = state => { const pending = state.detour_interruption; return pending && typeof pending.identity === 'string' && pending.identity && - typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ? + typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ? { identity:pending.identity, resume:pending.resume, reason:pending.reason } : null; }; const validBreak = state => { @@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange return write(state) ? { identity:pending.identity, resumed } : null; }, beginDetour(reason) { - if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null; + if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null; const state = read(); const existing = validDetour(state); if (existing) return existing; diff --git a/tests/test_live_data_status.py b/tests/test_live_data_status.py index 54977de..33b058b 100644 --- a/tests/test_live_data_status.py +++ b/tests/test_live_data_status.py @@ -81,10 +81,112 @@ const controller = status.createRefreshController({{ } +def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back(): + script = f""" +const status = require({json.dumps(str(STATUS))}); +function element(name) {{ + return {{name, hidden:false, inert:false, listeners:{{}}, focused:0, + addEventListener(type, fn) {{ this.listeners[type] = fn; }}, + focus() {{ this.focused += 1; }}, + }}; +}} +const trigger = element('trigger'); +trigger.attrs = {{}}; +trigger.setAttribute = (name, value) => trigger.attrs[name] = value; +const close = element('close'); +const refresh = element('refresh'); +const back = element('return'); +const sheet = element('sheet'); +sheet.hidden = true; +sheet.querySelectorAll = () => [close, refresh, back]; +const header = element('header'); +const main = element('main'); +const listeners = {{}}; +const history = {{state:null, pushes:0, backs:0, + pushState(state) {{ this.state=state; this.pushes += 1; }}, + back() {{ this.backs += 1; }}, +}}; +const timerView = {{begins:[], finishes:0, + beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }}, + finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }}, +}}; +const paused = element('paused'); +paused.hidden = true; +const controller = status.createSheetController({{ + sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused, + timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}}, + escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}}, + backgroundElements:[header, main], +}}); +controller.start(); +controller.open(); +const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert], + begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused}}; +let prevented = 0; +listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}}); +const trapped = {{prevented, closeFocused:close.focused}}; +controller.close(); +const requested = {{backs:history.backs, finishes:timerView.finishes}}; +history.state = null; +listeners.popstate({{state:null}}); +process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden, + expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes, + triggerFocused:trigger.focused}}}})); +""" + result = run_node(script) + + assert result == { + "opened": { + "hidden": False, + "expanded": "true", + "paused": False, + "inert": [True, True], + "begins": ["live-data-status"], + "pushes": 1, + "closeFocused": 1, + }, + "trapped": {"prevented": 1, "closeFocused": 2}, + "requested": {"backs": 1, "finishes": 0}, + "closed": { + "hidden": True, + "paused": True, + "expanded": "false", + "inert": [False, False], + "finishes": 1, + "triggerFocused": 1, + }, + } + + +def test_live_data_status_without_running_today_hides_return_and_does_not_resume(): + script = f""" +const status = require({json.dumps(str(STATUS))}); +function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}}, + addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}}, + setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }} +const sheet=element(true), trigger=element(), close=element(), returnButton=element(); +let finishes=0; +const controller=status.createSheetController({{ + sheet,trigger,closeButton:close,returnButton, + timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}}, +}}); +controller.start(); +controller.open(); +const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}}; +controller.close(); +process.stdout.write(JSON.stringify({{opened, finishes}})); +""" + assert run_node(script) == { + "opened": {"returnHidden": True, "expanded": "true"}, + "finishes": 0, + } + + def test_live_data_status_has_accessible_mobile_safe_sheet_contract(): html = HTML.read_text() css = CSS.read_text() dashboard = DASHBOARD.read_text() + status_source = STATUS.read_text() assert 'id="open-live-data-status"' in html assert 'aria-controls="live-data-status-sheet"' in html @@ -92,10 +194,15 @@ def test_live_data_status_has_accessible_mobile_safe_sheet_contract(): assert 'aria-labelledby="live-data-status-heading"' in html assert 'id="live-data-status-feeds"' in html assert 'id="refresh-live-data"' in html + assert 'id="return-from-live-data-status"' in html + assert 'id="live-data-status-today-paused"' in html assert '' in html assert ".live-data-status-panel" in css assert "width:min(560px,100%)" in css assert "min-height:44px" in css assert "liveDataStatus.describe" in dashboard assert "liveDataStatus.createRefreshController" in dashboard + assert "liveDataStatus.mount" in dashboard + assert "createSheetController" in status_source + assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source assert "contextPoller.getState()" in dashboard diff --git a/tests/test_today_timer_handoff.py b/tests/test_today_timer_handoff.py index feffe55..7584a4f 100644 --- a/tests/test_today_timer_handoff.py +++ b/tests/test_today_timer_handoff.py @@ -391,6 +391,35 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, re } +def test_live_data_status_detour_excludes_troubleshooting_time_and_resumes_once(): + script = SOURCE.read_text() + r""" +const values = new Map(); +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +let now = 1000; +const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now}); +timer.activate('issue:r:42:'); +now = 6000; +const interruption = timer.beginDetour('live-data-status'); +now = 26000; +const paused = timer.snapshot(); +const returned = timer.returnFromDetour('live-data-status'); +now = 28000; +const resumed = timer.snapshot(); +const repeated = timer.returnFromDetour('live-data-status'); +process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, repeated})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "interruption": {"identity": "issue:r:42:", "resume": True, "reason": "live-data-status"}, + "paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False}, + "returned": {"identity": "issue:r:42:", "resumed": True, "reason": "live-data-status"}, + "resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True}, + "repeated": None, + } + + def test_mobile_detour_view_names_work_and_returns_to_today(): script = SOURCE.read_text() + r""" const values = new Map();