diff --git a/README.md b/README.md
index f7586e0..2704da8 100644
--- a/README.md
+++ b/README.md
@@ -71,10 +71,12 @@ HTTP 503 with an error when Gitea is unavailable or authentication fails.
## Offline mobile shell
After one successful online load, the installed dashboard precaches a versioned,
-subpath-scoped application shell. During a network outage, navigation falls back
-to that shell so local issue and review drafts remain reachable. A visible,
-accessible offline notice distinguishes this mode from live Gitea data, and the
-dashboard refreshes its live snapshot when connectivity returns.
+subpath-scoped application shell. During a network outage or a dashboard HTTP
+`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
+it is available so local issue and review drafts remain reachable. Authentication
+and other client-error responses are never hidden. A visible, accessible offline
+or server-outage notice distinguishes cached mode from live Gitea data, and the
+dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in
diff --git a/frontend/index.html b/frontend/index.html
index 13d5605..e37b613 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1186,6 +1186,7 @@ textarea { resize: vertical; min-height: 120px; }
function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
+ if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
if (!hasContextSnapshot) {
qs('#context').innerHTML = '
Context unavailable.
';
@@ -2095,6 +2096,8 @@ textarea { resize: vertical; min-height: 120px; }
}
function renderLiveSnapshot(snapshot) {
+ setOfflineWorkMode(false);
+ offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications;
@@ -3033,9 +3036,10 @@ textarea { resize: vertical; min-height: 120px; }
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
}
- function hydrateOfflineWork() {
+ function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load();
if (!saved) return false;
+ const outage = mode === 'outage';
lastNotifications = saved.notifications || [];
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
workPagination = saved.work_pagination || {};
@@ -3044,9 +3048,18 @@ textarea { resize: vertical; min-height: 120px; }
paintMyWork(saved);
setOfflineWorkMode(true);
const savedLabel = fmt(saved.saved_at);
- qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
- offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
- offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
+ if (outage) {
+ qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
+ offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
+ offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
+ setStatus('Outage · saved snapshot');
+ } else {
+ qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
+ offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
+ offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
+ setStatus('Offline · saved snapshot');
+ }
+ offlineStatus.hidden = false;
return true;
}
function showOfflineStatus() {
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index ce0f224..e3db038 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,4 +1,5 @@
-const CACHE = 'stackchain-dashboard-shell-v3';
+const CACHE = 'stackchain-dashboard-shell-v4';
+const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
BASE,
@@ -41,9 +42,12 @@ self.addEventListener('fetch', event => {
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).then(async response => {
+ const cache = await caches.open(CACHE);
if (response.ok) {
- const cache = await caches.open(CACHE);
await cache.put(BASE, response.clone());
+ } else if (OUTAGE_STATUSES.has(response.status)) {
+ const cached = await cache.match(BASE);
+ if (cached) return cached;
}
return response;
}).catch(async () => {
diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py
index 3feb987..976dcb1 100644
--- a/tests/test_offline_work.py
+++ b/tests/test_offline_work.py
@@ -102,3 +102,14 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
assert "setOfflineWorkMode(true)" in html
assert "if (offlineWorkMode)" in html
assert '.offline-work-controls button { min-height:44px;' in html
+
+
+@pytest.mark.anyio
+async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
+ html = await dashboard()
+
+ assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
+ assert "Outage · saved " in html
+ assert "Server unavailable · showing private My Work saved " in html
+ assert "Live details and actions will return automatically." in html
+ assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index af73fef..e925c43 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -2,6 +2,8 @@ import json
import subprocess
from pathlib import Path
+import pytest
+
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
@@ -140,16 +142,52 @@ def test_cross_origin_navigation_is_not_intercepted_or_cached():
assert result["state"]["puts"] == []
-def test_failed_online_navigation_does_not_replace_working_cached_shell():
+@pytest.mark.parametrize("status", [500, 502, 503, 504])
+def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
+ result = run_worker_scenario(
+ f"""
+ state.fetchStatus = {status};
+ state.cachedBody = 'cached dashboard';
+ const response = await dispatch('fetch', {{
+ method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
+ }});
+ process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
+"""
+ )
+
+ assert result["status"] == 200
+ assert result["body"] == "cached dashboard"
+ assert result["state"]["puts"] == []
+
+
+def test_server_outage_without_cached_shell_preserves_network_error():
result = run_worker_scenario(
"""
state.fetchStatus = 503;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
});
- process.stdout.write(JSON.stringify({ status: response.status, state }));
+ process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
"""
)
assert result["status"] == 503
+ assert result["body"] == "network"
+ assert result["state"]["puts"] == []
+
+
+def test_client_error_navigation_is_not_hidden_by_cached_shell():
+ result = run_worker_scenario(
+ """
+ state.fetchStatus = 401;
+ state.cachedBody = 'cached dashboard';
+ const response = await dispatch('fetch', {
+ method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
+ });
+ process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
+"""
+ )
+
+ assert result["status"] == 401
+ assert result["body"] == "network"
assert result["state"]["puts"] == []