From 9c276fa25188e982ca1bbf27ae2e99045707a5b6 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 14:26:09 +0000 Subject: [PATCH] feat: make live data status actionable (Closes #745) --- frontend/context-poller.js | 13 +++++ frontend/dashboard.css | 12 +++- frontend/dashboard.js | 60 +++++++++++++++++++- frontend/index.html | 17 +++++- frontend/live-data-status.js | 78 +++++++++++++++++++++++++ frontend/service-worker.js | 1 + tests/test_live_data_status.py | 101 +++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 8 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 frontend/live-data-status.js create mode 100644 tests/test_live_data_status.py diff --git a/frontend/context-poller.js b/frontend/context-poller.js index 1cd2f24..e98a043 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -37,6 +37,8 @@ function createContextPoller({ let retainedSnapshot = null; let nextDelayMs = intervalMs; let failureStreak = 0; + let nextRetryAt = null; + let lastSuccessAt = null; function snapshotDelay(snapshot) { const freshness = snapshot && snapshot.freshness; @@ -69,8 +71,10 @@ function createContextPoller({ function schedule(delayMs = intervalMs) { cancelTimer(); if (stopped || isHidden()) return; + nextRetryAt = Date.now() + delayMs; timer = setTimer(() => { timer = null; + nextRetryAt = null; refresh(); }, delayMs); } @@ -125,6 +129,7 @@ function createContextPoller({ .then((snapshot) => { if (activeRequest !== requestState) return retainedSnapshot; failureStreak = 0; + lastSuccessAt = Date.now(); nextDelayMs = snapshotDelay(snapshot); const changedSections = ['context', 'events', 'notifications'].filter( (section) => Object.prototype.hasOwnProperty.call(snapshot, section) @@ -165,6 +170,14 @@ function createContextPoller({ start: refresh, refresh, setVisible, + getState() { + return { + refreshing: Boolean(activeRequest), + nextRetryAt, + lastSuccessAt, + failureStreak, + }; + }, stop() { stopped = true; cancelTimer(); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a41cf2d..b66242a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -5,7 +5,17 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; } .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } .app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; } -.app-live-status { display:inline-flex; gap:6px; align-items:center; } +.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; } +.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } +.live-data-status-sheet[hidden] { display:none; } +.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; } +.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-feeds { display:grid; gap:8px; margin:14px 0; } +.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; } +.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; } +.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; } .app-menu { margin-left:auto; } .app-menu > summary { display:none; } .app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 1314693..521d6f5 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4065,6 +4065,7 @@ } else if (!eventsFreshness && snapshot.freshness?.revalidating) { setEventStreamStatus('Refreshing · showing recent snapshot'); } + renderLiveDataStatus(snapshot.freshness || {}); } @@ -5746,6 +5747,54 @@ intervalMs: 8000, }); function load() { return contextPoller.refresh({ force: true }); } + const liveDataStatusSheet = qs('#live-data-status-sheet'); + const liveDataStatusTrigger = qs('#open-live-data-status'); + let latestLiveFreshness = {}; + function feedAge(feed) { + return feed.ageSeconds === null ? 'Snapshot age unavailable' : + (feed.ageSeconds < 60 ? feed.ageSeconds + 's old' : Math.floor(feed.ageSeconds / 60) + 'm old'); + } + function renderLiveDataStatus(freshness = latestLiveFreshness) { + latestLiveFreshness = freshness; + const description = liveDataStatus.describe(freshness); + setStatus(description.summary); + qs('#live-data-status-feeds').innerHTML = description.feeds.map(feed => + '
' + escapeHtml(feed.label) + '' + + escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) : + feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '
' + ).join(''); + const pollState = contextPoller.getState(); + const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ? + Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null); + qs('#live-data-status-retry').textContent = retrySeconds ? + '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(); } + }); + const liveDataRefresh = liveDataStatus.createRefreshController({ + button: qs('#refresh-live-data'), + output: qs('#live-data-status-result'), + refresh: () => contextPoller.refresh({ force: true }), + onState: state => { if (state === 'refreshing') setStatus('Refreshing live data'); }, + }); + qs('#refresh-live-data').addEventListener('click', liveDataRefresh.run); const offlineStatus = qs('#offline-status'); const keepWorkOffline = qs('#keep-work-offline'); @@ -5828,10 +5877,15 @@ if (!hasContextSnapshot) await hydrateOfflineWork(); } function reconnectLiveData() { - offlineStatus.hidden = true; - setOfflineWorkMode(false); setStatus('Reconnecting…'); - contextPoller.refresh({ force: true }).then(() => { + contextPoller.refresh({ force: true }).then(snapshot => { + if (!snapshot) { + offlineStatus.hidden = false; + setOfflineWorkMode(true); + return; + } + offlineStatus.hidden = true; + setOfflineWorkMode(false); if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); }); } diff --git a/frontend/index.html b/frontend/index.html index 9d66e5c..96fcad3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,7 +12,7 @@
StackchainDashboard
-
Live
+
Menu
@@ -26,6 +26,20 @@
+ @@ -1053,6 +1067,7 @@ + diff --git a/frontend/live-data-status.js b/frontend/live-data-status.js new file mode 100644 index 0000000..3d53d1b --- /dev/null +++ b/frontend/live-data-status.js @@ -0,0 +1,78 @@ +(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 describe(freshness = {}) { + const sections = freshness.sections || {}; + 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) }; + }); + 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 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 }; + } + + return { describe, createRefreshController }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f056dbc..0232c6a 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -72,6 +72,7 @@ const SHELL = [ BASE + 'static/work-route.js', BASE + 'static/task-overlay-history.js', BASE + 'static/context-poller.js', + BASE + 'static/live-data-status.js', BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', diff --git a/tests/test_live_data_status.py b/tests/test_live_data_status.py new file mode 100644 index 0000000..54977de --- /dev/null +++ b/tests/test_live_data_status.py @@ -0,0 +1,101 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +STATUS = ROOT / "frontend" / "live-data-status.js" +HTML = ROOT / "frontend" / "index.html" +CSS = ROOT / "frontend" / "dashboard.css" +DASHBOARD = ROOT / "frontend" / "dashboard.js" + + +def run_node(script: str) -> dict: + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + return json.loads(result.stdout) + + +def test_live_data_status_summarizes_each_feed_without_claiming_live(): + script = f""" +const status = require({json.dumps(str(STATUS))}); +const healthy = {{fresh_for_seconds:8, sections:{{ + context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}} +}}}}; +const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{ + context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}} +}}}}; +const twoDelayed = {{fresh_for_seconds:8, sections:{{ + context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}} +}}}}; +process.stdout.write(JSON.stringify({{ + healthy:status.describe(healthy), + one:status.describe(oneDelayed), + two:status.describe(twoDelayed), + unavailable:status.describe({{}}), +}})); +""" + result = run_node(script) + + assert result["healthy"]["summary"] == "Live" + assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3 + assert result["one"]["summary"] == "Updates delayed" + assert result["one"]["nextRetrySeconds"] == 30 + assert result["one"]["feeds"][1] == { + "key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14 + } + assert result["two"]["summary"] == "2 data feeds delayed" + assert result["unavailable"]["summary"] == "Live data unavailable" + + +def test_live_data_status_controller_is_single_flight_and_reports_result(): + script = f""" +const status = require({json.dumps(str(STATUS))}); +let resolveRefresh; +let calls = 0; +const states = []; +const button = {{disabled:false}}; +const output = {{textContent:''}}; +const controller = status.createRefreshController({{ + button, output, + refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }}, + onState:value => states.push(value), +}}); +(async () => {{ + const first = controller.run(); + const second = controller.run(); + const pending = {{calls, disabled:button.disabled, text:output.textContent}}; + resolveRefresh({{context:{{}}}}); + await Promise.all([first, second]); + process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}})); +}})(); +""" + + assert run_node(script) == { + "pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"}, + "calls": 1, + "disabled": False, + "text": "Live data refreshed.", + "states": ["refreshing", "success"], + } + + +def test_live_data_status_has_accessible_mobile_safe_sheet_contract(): + html = HTML.read_text() + css = CSS.read_text() + dashboard = DASHBOARD.read_text() + + assert 'id="open-live-data-status"' in html + assert 'aria-controls="live-data-status-sheet"' in html + assert 'id="live-data-status-sheet"' in html + 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 '' 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 "contextPoller.getState()" in dashboard diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 27f4e0e..5036a5a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -842,6 +842,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/work-route.js", "/dashboard/static/task-overlay-history.js", "/dashboard/static/context-poller.js", + "/dashboard/static/live-data-status.js", "/dashboard/static/mobile-task-dock.js", "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-queue-launcher.js", -- 2.43.0