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
+
+
+
+
+
+
+
+
+
+
+
+
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
@@ -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",