Merge pull request 'Pause Today while checking Live Data Status' (#1139) from timmy/1138-live-data-status-detour into main
All checks were successful
CI / lint (push) Successful in 3m13s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m29s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-19 16:44:43 +00:00
commit 8731682090
7 changed files with 250 additions and 21 deletions

View File

@ -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 { 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 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-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 { 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; } .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; } .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; }

View File

@ -7451,24 +7451,7 @@
'Next automatic retry in ' + retrySeconds + 's.' : 'Next automatic retry in ' + retrySeconds + 's.' :
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.'); (pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
} }
function closeLiveDataStatus() { liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
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(); }
});
const liveDataRefresh = liveDataStatus.createRefreshController({ const liveDataRefresh = liveDataStatus.createRefreshController({
button: qs('#refresh-live-data'), button: qs('#refresh-live-data'),
output: qs('#live-data-status-result'), output: qs('#live-data-status-result'),

View File

@ -44,10 +44,12 @@
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div> <div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button> <button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
</div> </div>
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
<div id="live-data-status-feeds" class="live-data-status-feeds"></div> <div id="live-data-status-feeds" class="live-data-status-feeds"></div>
<p id="live-data-status-retry" class="small muted"></p> <p id="live-data-status-retry" class="small muted"></p>
<div class="live-data-status-actions"> <div class="live-data-status-actions">
<button id="refresh-live-data" type="button">Refresh now</button> <button id="refresh-live-data" type="button">Refresh now</button>
<button id="return-from-live-data-status" type="button">Return to Today</button>
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span> <span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
</div> </div>
</section> </section>

View File

@ -74,5 +74,112 @@
return { run }; 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 };
}); });

View File

@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
const validDetour = state => { const validDetour = state => {
const pending = state.detour_interruption; const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity && 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; { identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
}; };
const validBreak = state => { const validBreak = state => {
@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
return write(state) ? { identity:pending.identity, resumed } : null; return write(state) ? { identity:pending.identity, resumed } : null;
}, },
beginDetour(reason) { 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 state = read();
const existing = validDetour(state); const existing = validDetour(state);
if (existing) return existing; if (existing) return existing;

View File

@ -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(): def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
html = HTML.read_text() html = HTML.read_text()
css = CSS.read_text() css = CSS.read_text()
dashboard = DASHBOARD.read_text() dashboard = DASHBOARD.read_text()
status_source = STATUS.read_text()
assert 'id="open-live-data-status"' in html assert 'id="open-live-data-status"' in html
assert 'aria-controls="live-data-status-sheet"' 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 'aria-labelledby="live-data-status-heading"' in html
assert 'id="live-data-status-feeds"' in html assert 'id="live-data-status-feeds"' in html
assert 'id="refresh-live-data"' 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 '<script src="static/live-data-status.js"></script>' in html assert '<script src="static/live-data-status.js"></script>' in html
assert ".live-data-status-panel" in css assert ".live-data-status-panel" in css
assert "width:min(560px,100%)" in css assert "width:min(560px,100%)" in css
assert "min-height:44px" in css assert "min-height:44px" in css
assert "liveDataStatus.describe" in dashboard assert "liveDataStatus.describe" in dashboard
assert "liveDataStatus.createRefreshController" 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 assert "contextPoller.getState()" in dashboard

View File

@ -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(): def test_mobile_detour_view_names_work_and_returns_to_today():
script = SOURCE.read_text() + r""" script = SOURCE.read_text() + r"""
const values = new Map(); const values = new Map();