diff --git a/README.md b/README.md
index 565237e..f7586e0 100644
--- a/README.md
+++ b/README.md
@@ -76,8 +76,16 @@ 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;
+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
+user identity and queue-card metadata for issues, pull requests, unread updates,
+and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
+events, and complete API responses are excluded. A cold offline launch labels the
+saved time and renders this snapshot read-only; opening live details, pagination,
+and server mutations remain disabled until reconnection. **Clear offline work
+data** deletes the snapshot, and opting out deletes it automatically.
+
+API responses and mutations are never cached by the service worker 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.
diff --git a/frontend/index.html b/frontend/index.html
index 6a6b27e..13d5605 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -57,6 +57,10 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
+.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
+.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
+.offline-work-controls input { width:20px; height:20px; }
+.offline-work-controls button { min-height:44px; }
.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; }
@@ -305,6 +309,11 @@ textarea { resize: vertical; min-height: 120px; }
Unplanned
+
+ Keep My Work available offline
+ Clear offline work data
+
+
@@ -709,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; }
+
@@ -744,6 +754,7 @@ textarea { resize: vertical; min-height: 120px; }
});
});
let liveMode = true;
+ let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
@@ -822,6 +833,7 @@ textarea { resize: vertical; min-height: 120px; }
issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage });
+ const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
@@ -1024,6 +1036,10 @@ textarea { resize: vertical; min-height: 120px; }
function openRoutedWork(item, trigger, options = {}) {
if (!item) return;
+ if (offlineWorkMode) {
+ qs('#my-work-action-status').textContent = 'This saved item is read-only. Reconnect to open live details.';
+ return;
+ }
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
else if (item.is_review) reviewTrigger = trigger;
else if (item.kind === 'issue') issueTrigger = trigger;
@@ -2103,10 +2119,19 @@ textarea { resize: vertical; min-height: 120px; }
}
}
if (snapshot.context) {
+ setOfflineWorkMode(false);
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale();
+ if (!contextFreshness?.stale && notificationsFresh) {
+ offlineWorkStore.save({
+ ...snapshot.context,
+ notifications: snapshot.notifications,
+ notification_pagination: snapshot.notification_pagination,
+ });
+ updateOfflineWorkControls();
+ }
} else handleContextError(new Error('Context section unavailable'));
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
@@ -2995,15 +3020,59 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
const offlineStatus = qs('#offline-status');
+ const keepWorkOffline = qs('#keep-work-offline');
+ const offlineWorkStatus = qs('#offline-work-status');
+ function updateOfflineWorkControls(message) {
+ keepWorkOffline.checked = offlineWorkStore.enabled();
+ const saved = offlineWorkStore.load();
+ offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
+ (keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
+ }
+ function setOfflineWorkMode(value) {
+ offlineWorkMode = value;
+ ['#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() {
+ const saved = offlineWorkStore.load();
+ if (!saved) return false;
+ lastNotifications = saved.notifications || [];
+ notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
+ workPagination = saved.work_pagination || {};
+ lastContextSnapshot = saved;
+ hasContextSnapshot = true;
+ 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.';
+ return true;
+ }
function showOfflineStatus() {
offlineStatus.hidden = false;
setStatus('Offline');
+ if (!hasContextSnapshot) hydrateOfflineWork();
}
function reconnectLiveData() {
offlineStatus.hidden = true;
+ setOfflineWorkMode(false);
setStatus('Reconnecting…');
contextPoller.refresh();
}
+ keepWorkOffline.addEventListener('change', () => {
+ offlineWorkStore.setEnabled(keepWorkOffline.checked);
+ if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
+ offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
+ notification_pagination:notificationPagination });
+ }
+ updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
+ });
+ qs('#clear-offline-work').addEventListener('click', () => {
+ offlineWorkStore.clear();
+ updateOfflineWorkControls('Offline work data cleared.');
+ });
+ updateOfflineWorkControls();
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
diff --git a/frontend/offline-work.js b/frontend/offline-work.js
new file mode 100644
index 0000000..1c34b0c
--- /dev/null
+++ b/frontend/offline-work.js
@@ -0,0 +1,88 @@
+(function (root, factory) {
+ const api = factory();
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ else root.createOfflineWorkStore = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
+ const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
+ const VERSION = 1;
+ const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
+ const ITEM_FIELDS = [
+ 'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
+ 'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
+ ];
+ const NOTIFICATION_FIELDS = [
+ 'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
+ ];
+
+ function pick(source, fields) {
+ const output = {};
+ fields.forEach(field => {
+ if (source && source[field] !== undefined) output[field] = source[field];
+ });
+ return output;
+ }
+
+ function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
+ function enabled() {
+ try { return storage.getItem(ENABLED_KEY) === 'true'; }
+ catch (_) { return false; }
+ }
+
+ function clear() {
+ try { storage.removeItem(SNAPSHOT_KEY); }
+ catch (_) { return false; }
+ return true;
+ }
+
+ function setEnabled(value) {
+ try {
+ storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
+ if (!value) clear();
+ return true;
+ } catch (_) { return false; }
+ }
+
+ function save(snapshot) {
+ if (!enabled() || !snapshot?.user?.login) return false;
+ const record = {
+ version: VERSION,
+ user_login: String(snapshot.user.login),
+ saved_at: now().toISOString(),
+ data: {
+ user: pick(snapshot.user, ['login', 'full_name']),
+ issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
+ pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
+ notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
+ work_pagination: snapshot.work_pagination || {},
+ notification_pagination: snapshot.notification_pagination || {},
+ },
+ };
+ try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
+ catch (_) { return false; }
+ return true;
+ }
+
+ function load(expectedLogin) {
+ let record;
+ try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
+ catch (_) { clear(); return null; }
+ const savedAt = Date.parse(record?.saved_at || '');
+ const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
+ !Number.isFinite(savedAt);
+ const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
+ if (invalid || expired) {
+ clear();
+ return null;
+ }
+ if (expectedLogin && record.user_login !== expectedLogin) return null;
+ return { ...record.data, saved_at: record.saved_at };
+ }
+
+ return { enabled, setEnabled, save, load, clear };
+ }
+
+ return createOfflineWorkStore;
+});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index f4091f9..ce0f224 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE = 'stackchain-dashboard-shell-v2';
+const CACHE = 'stackchain-dashboard-shell-v3';
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
BASE,
@@ -10,6 +10,7 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
+ BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py
new file mode 100644
index 0000000..3feb987
--- /dev/null
+++ b/tests/test_offline_work.py
@@ -0,0 +1,104 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from src.views import dashboard
+
+
+OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
+
+
+def run_scenario(scenario: str) -> dict:
+ script = f"""
+const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
+const values = new Map();
+const storage = {{
+ getItem: key => values.has(key) ? values.get(key) : null,
+ setItem: (key, value) => values.set(key, value),
+ removeItem: key => values.delete(key),
+}};
+{scenario}
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ return json.loads(result.stdout)
+
+
+def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
+ result = run_scenario("""
+const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
+store.setEnabled(true);
+store.save({
+ user:{login:'timmy', full_name:'Timmy', token:'secret'},
+ issues:[{id:1, number:8, title:'Ship offline work', body:'private body', state:'open',
+ repository:'stackchain/dashboard', labels:['P0'], assignees:['timmy'],
+ updated_at:'2026-08-07T11:00:00Z', url:'https://forge.example/issues/8'}],
+ pull_requests:[],
+ notifications:[{id:9, number:8, title:'Updated', unread:true, subject_body:'private comment',
+ repository:'stackchain/dashboard', subject_type:'Issue', updated_at:'2026-08-07T11:30:00Z',
+ url:'https://forge.example/issues/8'}],
+ work_pagination:{issue:{page:1,total:1,has_more:false}},
+ repos:[{private:true}], events:[{body:'private event'}],
+});
+const restarted = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:01:00Z')});
+process.stdout.write(JSON.stringify({loaded:restarted.load('timmy'), raw:[...values.values()].join(' ')}));
+""")
+
+ assert result["loaded"]["user"] == {"login": "timmy", "full_name": "Timmy"}
+ assert result["loaded"]["issues"][0]["title"] == "Ship offline work"
+ assert result["loaded"]["notifications"][0]["unread"] is True
+ assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
+ assert "private body" not in result["raw"]
+ assert "private comment" not in result["raw"]
+ assert "private event" not in result["raw"]
+ assert "secret" not in result["raw"]
+
+
+def test_expired_snapshot_is_deleted_and_wrong_user_cannot_load_it():
+ result = run_scenario("""
+const writer = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:00Z'), maxAgeMs:1000});
+writer.setEnabled(true);
+writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
+const wrongUser = writer.load('alexander');
+const expired = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:02Z'), maxAgeMs:1000});
+const expiredValue = expired.load('timmy');
+process.stdout.write(JSON.stringify({wrongUser, expiredValue, snapshotStillStored:values.has('stackchain.offline-work.snapshot.v1')}));
+""")
+
+ assert result == {
+ "wrongUser": None,
+ "expiredValue": None,
+ "snapshotStillStored": False,
+ }
+
+
+def test_opting_out_deletes_the_persisted_snapshot():
+ result = run_scenario("""
+const store = createOfflineWorkStore({storage});
+store.setEnabled(true);
+store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
+store.setEnabled(false);
+process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load()}));
+""")
+
+ assert result == {"enabled": False, "loaded": None}
+
+
+@pytest.mark.anyio
+async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
+ html = await dashboard()
+
+ assert '' in html
+ assert 'id="keep-work-offline"' in html
+ assert 'Keep My Work available offline' in html
+ assert 'id="clear-offline-work"' in html
+ assert 'id="offline-work-status"' in html
+ assert 'offlineWorkStore.save({' in html
+ assert 'offlineWorkStore.load()' in html
+ assert 'Offline · saved ' in html
+ assert "setOfflineWorkMode(true)" in html
+ assert "if (offlineWorkMode)" in html
+ assert '.offline-work-controls button { min-height:44px;' in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index fae40a9..af73fef 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -81,6 +81,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
+ "/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",