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"] == []