diff --git a/README.md b/README.md
index db19336..565237e 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,20 @@ API process is running and does not contact Gitea. GET `/readyz` is the
readiness check: it validates the configured Gitea credentials and returns
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.
+
+The offline guarantee covers only the HTML/JavaScript shell, manifest, icons, and
+browser-local drafts. API responses and mutations are never cached or queued;
+submissions still require connectivity. Service-worker upgrades are atomic and
+remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
+the same origin.
+
Run the test suite with:
```bash
diff --git a/frontend/index.html b/frontend/index.html
index 5adb840..6a6b27e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -55,6 +55,8 @@ textarea { resize: vertical; min-height: 120px; }
.small { font-size: 12px; color: #94a3b8; }
.status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
+.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
+.offline-status[hidden] { display:none; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
@@ -273,6 +275,9 @@ textarea { resize: vertical; min-height: 120px; }
+
+ Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
+
@@ -2989,6 +2994,20 @@ textarea { resize: vertical; min-height: 120px; }
});
function load() { return contextPoller.refresh(); }
+ const offlineStatus = qs('#offline-status');
+ function showOfflineStatus() {
+ offlineStatus.hidden = false;
+ setStatus('Offline');
+ }
+ function reconnectLiveData() {
+ offlineStatus.hidden = true;
+ setStatus('Reconnecting…');
+ contextPoller.refresh();
+ }
+ if (!navigator.onLine) showOfflineStatus();
+ window.addEventListener('offline', showOfflineStatus);
+ window.addEventListener('online', reconnectLiveData);
+
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index f4bff97..f4091f9 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,9 +1,24 @@
-const CACHE = 'stackchain-shell-v1';
+const CACHE = 'stackchain-dashboard-shell-v2';
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
+ BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
+ BASE + 'static/markdown.js',
+ BASE + 'static/commands.js',
+ BASE + 'static/search-preview.js',
+ BASE + 'static/widgets.js',
+ BASE + 'static/drafts.js',
+ BASE + 'static/my-work.js',
+ BASE + 'static/pick-work.js',
+ BASE + 'static/conversation.js',
+ BASE + 'static/issue-sheet.js',
+ BASE + 'static/create-issue-sheet.js',
+ BASE + 'static/pull-sheet.js',
+ BASE + 'static/review-sheet.js',
+ BASE + 'static/work-route.js',
+ BASE + 'static/context-poller.js',
];
self.addEventListener('install', event => {
@@ -12,7 +27,8 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
- keys.filter(key => key !== CACHE).map(key => caches.delete(key))
+ keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
+ .map(key => caches.delete(key))
)).then(() => self.clients.claim()));
});
@@ -20,8 +36,20 @@ self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
+ if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') {
- event.respondWith(fetch(request));
+ event.respondWith(
+ fetch(request).then(async response => {
+ if (response.ok) {
+ const cache = await caches.open(CACHE);
+ await cache.put(BASE, response.clone());
+ }
+ return response;
+ }).catch(async () => {
+ const cache = await caches.open(CACHE);
+ return cache.match(BASE);
+ })
+ );
return;
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
diff --git a/src/views.py b/src/views.py
index ce810e3..357ec7e 100644
--- a/src/views.py
+++ b/src/views.py
@@ -9,7 +9,14 @@ MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
-@router.get("/", response_class=HTMLResponse)
+class RevalidatingHTMLResponse(HTMLResponse):
+ def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
+ response_headers = dict(headers or {})
+ response_headers["Cache-Control"] = "no-cache"
+ super().__init__(content, status_code, response_headers, media_type, background)
+
+
+@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
new file mode 100644
index 0000000..fae40a9
--- /dev/null
+++ b/tests/test_service_worker.py
@@ -0,0 +1,154 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
+
+
+def run_worker_scenario(scenario: str) -> dict:
+ harness = f"""
+const fs = require('fs');
+const vm = require('vm');
+const listeners = {{}};
+const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
+const cache = {{
+ addAll: async urls => {{ state.added = urls; }},
+ match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
+ put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
+}};
+const context = {{
+ URL, Request, Response,
+ console,
+ self: {{
+ location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
+ addEventListener: (name, handler) => {{ listeners[name] = handler; }},
+ skipWaiting: async () => {{ state.skipped = true; }},
+ clients: {{ claim: async () => {{ state.claimed = true; }} }},
+ }},
+ caches: {{
+ open: async () => cache,
+ keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
+ delete: async key => {{ state.deleted.push(key); return true; }},
+ match: async request => cache.match(request),
+ }},
+ fetch: async request => {{
+ state.fetches.push(String(request.url || request));
+ if (state.failFetch) throw new Error('offline');
+ return new Response('network', {{ status: state.fetchStatus }});
+ }},
+}};
+vm.createContext(context);
+vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
+async function dispatch(name, request) {{
+ let pending;
+ let response;
+ listeners[name]({{
+ request,
+ waitUntil: promise => {{ pending = promise; }},
+ respondWith: promise => {{ response = promise; }},
+ }});
+ if (pending) await pending;
+ return response ? await response : null;
+}}
+(async () => {{
+{scenario}
+}})().catch(error => {{ console.error(error); process.exit(1); }});
+"""
+ completed = subprocess.run(
+ ["node", "-e", harness], capture_output=True, check=True, text=True
+ )
+ return json.loads(completed.stdout)
+
+
+def test_install_precaches_complete_subpath_scoped_app_shell():
+ result = run_worker_scenario(
+ """
+ await dispatch('install');
+ process.stdout.write(JSON.stringify(state));
+"""
+ )
+
+ assert result["skipped"] is True
+ assert result["added"][0] == "/dashboard/"
+ assert set(result["added"]) == {
+ "/dashboard/",
+ "/dashboard/manifest.webmanifest",
+ "/dashboard/static/icons/stackchain-192.png",
+ "/dashboard/static/icons/stackchain-512.png",
+ "/dashboard/static/markdown.js",
+ "/dashboard/static/commands.js",
+ "/dashboard/static/search-preview.js",
+ "/dashboard/static/widgets.js",
+ "/dashboard/static/drafts.js",
+ "/dashboard/static/my-work.js",
+ "/dashboard/static/pick-work.js",
+ "/dashboard/static/conversation.js",
+ "/dashboard/static/issue-sheet.js",
+ "/dashboard/static/create-issue-sheet.js",
+ "/dashboard/static/pull-sheet.js",
+ "/dashboard/static/review-sheet.js",
+ "/dashboard/static/work-route.js",
+ "/dashboard/static/context-poller.js",
+ }
+
+
+def test_activate_deletes_only_stale_stackchain_caches():
+ result = run_worker_scenario(
+ """
+ await dispatch('activate');
+ process.stdout.write(JSON.stringify(state));
+"""
+ )
+
+ assert result["claimed"] is True
+ assert result["deleted"] == ["stackchain-dashboard-old"]
+
+
+def test_offline_navigation_returns_cached_shell_for_share_target_url():
+ result = run_worker_scenario(
+ """
+ state.failFetch = true;
+ state.cachedBody = 'cached dashboard';
+ const response = await dispatch('fetch', {
+ method: 'GET', mode: 'navigate',
+ url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
+ });
+ process.stdout.write(JSON.stringify({ body: await response.text(), state }));
+"""
+ )
+
+ assert result["body"] == "cached dashboard"
+ assert result["state"]["fetches"] == [
+ "https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
+ ]
+
+
+def test_cross_origin_navigation_is_not_intercepted_or_cached():
+ result = run_worker_scenario(
+ """
+ const response = await dispatch('fetch', {
+ method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
+ });
+ process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
+"""
+ )
+
+ assert result["intercepted"] is False
+ assert result["state"]["fetches"] == []
+ assert result["state"]["puts"] == []
+
+
+def test_failed_online_navigation_does_not_replace_working_cached_shell():
+ 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 }));
+"""
+ )
+
+ assert result["status"] == 503
+ assert result["state"]["puts"] == []
diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py
index 033676c..fdce2dc 100644
--- a/tests/test_work_pages.py
+++ b/tests/test_work_pages.py
@@ -85,4 +85,29 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text
- assert "new URL('./', self.location.href).pathname" in worker.text
\ No newline at end of file
+ assert "new URL('./', self.location.href).pathname" in worker.text
+
+
+@pytest.mark.anyio
+async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get("/")
+
+ assert response.status_code == 200
+ assert 'id="offline-status"' in response.text
+ assert 'role="status"' in response.text
+ assert 'aria-live="polite"' in response.text
+ assert "window.addEventListener('offline'" in response.text
+ assert "window.addEventListener('online'" in response.text
+ assert "contextPoller.refresh()" in response.text
+
+
+@pytest.mark.anyio
+async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh():
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.get("/")
+
+ assert response.status_code == 200
+ assert response.headers["cache-control"] == "no-cache"
\ No newline at end of file