From 1b2a3c83af554f2186b8fc7a4b112d2e1d84d8fd Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 21:14:21 +0000 Subject: [PATCH] feat: keep My Work available offline (#228) --- README.md | 12 +++- frontend/index.html | 69 +++++++++++++++++++++++ frontend/offline-work.js | 88 +++++++++++++++++++++++++++++ frontend/service-worker.js | 3 +- tests/test_offline_work.py | 104 +++++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 6 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 frontend/offline-work.js create mode 100644 tests/test_offline_work.py 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; } +
+ + + +
@@ -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",