Pause Today while checking Live Data Status #1139
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</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>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 '<script src="static/live-data-status.js"></script>' 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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user