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.
+ Today paused while you check live data.
+
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();