- Render bucket latency rows inside #bucket-latency-rows (never replace panel); preserve heading + aria-labelledby; role=list/listitem rows - Remove fabricated 1ms floor: sub-ms yields 0, UI shows '<1 ms' - Strict latency types: reject null/bool/str/float/negative/Infinity/huge (frontend + backend); no coercion or clipping - Failed upstream-attempt duration never overwrites prior successful latency; stale-but-measured feeds labeled 'last known' truthfully - Notifications fetch starts immediately (auth-independent), concurrent with shared current_user auth; deterministic concurrency proof - Repair tests: real DOM transitions (browser-verified), production failure shape, panel-hidden assertion, malformed-value matrix
265 lines
9.8 KiB
JavaScript
265 lines
9.8 KiB
JavaScript
(function (root, factory) {
|
|
const api = factory();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else root.liveDataStatus = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
const feeds = [
|
|
['context', 'Work'],
|
|
['notifications', 'Updates'],
|
|
['events', 'Activity'],
|
|
];
|
|
|
|
function boundedSeconds(value) {
|
|
const seconds = Number(value);
|
|
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
|
}
|
|
|
|
function boundedLatencyMs(value) {
|
|
// Strict acceptance only: actual finite nonnegative bounded integers.
|
|
// Rejects null, booleans, strings, arrays, floats, fractions, negatives,
|
|
// Infinity, and huge values. No Number() coercion, no clipping.
|
|
if (typeof value !== 'number') return null;
|
|
if (!Number.isFinite(value)) return null;
|
|
if (!Number.isInteger(value)) return null;
|
|
if (value < 0 || value > 3600000) return null;
|
|
return value;
|
|
}
|
|
|
|
const latencyBuckets = [
|
|
['context', 'Work'],
|
|
['notifications', 'Updates'],
|
|
['events', 'Activity'],
|
|
];
|
|
|
|
function bucketLatencies(freshness = {}) {
|
|
const measured = freshness.latency_ms || {};
|
|
return new Map(
|
|
latencyBuckets
|
|
.map(([key]) => [key, boundedLatencyMs(measured[key])])
|
|
.filter(([, value]) => value !== null)
|
|
);
|
|
}
|
|
|
|
function describe(freshness = {}) {
|
|
const sections = freshness.sections || {};
|
|
const latencies = bucketLatencies(freshness);
|
|
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
|
const described = feeds.map(([key, label]) => {
|
|
const section = sections[key] || {};
|
|
const state = section.revalidating ? 'refreshing' :
|
|
(section.stale || section.degraded ? 'delayed' : 'live');
|
|
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds),
|
|
latencyMs: latencies.get(key) ?? null };
|
|
});
|
|
const delayed = described.filter(feed => feed.state === 'delayed');
|
|
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
|
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
|
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
|
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
|
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
|
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
|
const retryValues = described.map(feed => {
|
|
const section = sections[feed.key] || {};
|
|
return boundedSeconds(section.retry_in_seconds);
|
|
}).filter(value => value !== null && value > 0);
|
|
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
|
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
|
return {
|
|
summary,
|
|
feeds: described,
|
|
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
|
};
|
|
}
|
|
|
|
function formatLatency(ms) {
|
|
if (ms === null) return 'not measured';
|
|
if (ms === 0) return '<1 ms';
|
|
if (ms >= 10000) return Math.round(ms / 1000) + ' s';
|
|
if (ms >= 1000) return (ms / 1000).toFixed(1) + ' s';
|
|
return ms + ' ms';
|
|
}
|
|
|
|
function renderBucketLatency(description, panel) {
|
|
// Render rows *inside* the existing #bucket-latency-rows container so the
|
|
// panel heading and its aria-labelledby target are never replaced. Each
|
|
// generated row carries role=listitem so the list semantics hold.
|
|
const rowsContainer = (typeof panel.querySelector === 'function')
|
|
? (panel.querySelector('#bucket-latency-rows') || panel)
|
|
: panel;
|
|
if (rowsContainer !== panel) {
|
|
rowsContainer.innerHTML = '';
|
|
} else {
|
|
panel.innerHTML = '';
|
|
}
|
|
const rows = description.feeds.map(feed => ({
|
|
key: feed.key,
|
|
label: feed.label,
|
|
latencyMs: feed.latencyMs,
|
|
stale: feed.state === 'delayed',
|
|
}));
|
|
if (!rows.some(row => row.latencyMs !== null)) {
|
|
panel.hidden = true;
|
|
return rows;
|
|
}
|
|
panel.hidden = false;
|
|
for (const row of rows) {
|
|
const slow = row.latencyMs !== null && row.latencyMs >= 2000 ? ' slow' : '';
|
|
const value = formatLatency(row.latencyMs);
|
|
// A stale feed shows the last measurement that succeeded, so say so.
|
|
const suffix = row.stale && row.latencyMs !== null
|
|
? ' <span class="small muted">last known</span>' : '';
|
|
const html = '<div class="bucket-latency-row' + slow + '" role="listitem" data-bucket="' +
|
|
row.key + '"><strong>' + row.label + '</strong>' +
|
|
'<span class="bucket-latency-value' + slow + '">' + value + suffix + '</span></div>';
|
|
if (typeof document !== 'undefined') {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
rowsContainer.appendChild(wrapper.firstChild);
|
|
} else {
|
|
rowsContainer.appendChild({outerHTML: html});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
|
let pending = null;
|
|
function run() {
|
|
if (pending) return pending;
|
|
button.disabled = true;
|
|
output.textContent = 'Refreshing live data…';
|
|
onState('refreshing');
|
|
let request;
|
|
try {
|
|
request = refresh();
|
|
} catch (error) {
|
|
request = Promise.reject(error);
|
|
}
|
|
pending = Promise.resolve(request).then(result => {
|
|
if (!result) throw new Error('Live data remains unavailable.');
|
|
output.textContent = 'Live data refreshed.';
|
|
onState('success');
|
|
return result;
|
|
}).catch(error => {
|
|
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
|
onState('error');
|
|
return null;
|
|
}).finally(() => {
|
|
button.disabled = false;
|
|
pending = null;
|
|
});
|
|
return pending;
|
|
}
|
|
return { run };
|
|
}
|
|
|
|
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, renderBucketLatency, createRefreshController, createSheetController, mount };
|
|
});
|