Keep My Work available offline with a private read-only snapshot #229

Merged
timmy merged 1 commits from timmy/228-offline-my-work-snapshot into main 2026-08-07 21:15:32 +00:00
6 changed files with 274 additions and 3 deletions

View File

@ -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 accessible offline notice distinguishes this mode from live Gitea data, and the
dashboard refreshes its live snapshot when connectivity returns. dashboard refreshes its live snapshot when connectivity returns.
The offline guarantee covers only the HTML/JavaScript shell, manifest, icons, and Users can explicitly enable **Keep My Work available offline**. Each healthy live
browser-local drafts. API responses and mutations are never cached or queued; 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 submissions still require connectivity. Service-worker upgrades are atomic and
remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
the same origin. the same origin.

View File

@ -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; } .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 { 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-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 { 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; } .widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; } .event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
@ -305,6 +309,11 @@ textarea { resize: vertical; min-height: 120px; }
<option value="unplanned">Unplanned</option> <option value="unplanned">Unplanned</option>
</select> </select>
</label> </label>
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
</div>
</div> </div>
<div class="my-work-list" id="my-work-list"></div> <div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div> <div class="small" id="work-page-status" aria-live="polite"></div>
@ -709,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script> <script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script> <script src="static/conversation.js"></script>
@ -744,6 +754,7 @@ textarea { resize: vertical; min-height: 120px; }
}); });
}); });
let liveMode = true; let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all'; let selectedWorkFilter = 'all';
@ -822,6 +833,7 @@ textarea { resize: vertical; min-height: 120px; }
issueCapture.stageSharedContent(sharedLaunch) : null; issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage });
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const findWorkController = createFindWork({ const findWorkController = createFindWork({
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
onItems: renderAvailableIssues, onItems: renderAvailableIssues,
@ -1024,6 +1036,10 @@ textarea { resize: vertical; min-height: 120px; }
function openRoutedWork(item, trigger, options = {}) { function openRoutedWork(item, trigger, options = {}) {
if (!item) return; 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; if (item.has_update && item.kind === 'update') updateTrigger = trigger;
else if (item.is_review) reviewTrigger = trigger; else if (item.is_review) reviewTrigger = trigger;
else if (item.kind === 'issue') issueTrigger = trigger; else if (item.kind === 'issue') issueTrigger = trigger;
@ -2103,10 +2119,19 @@ textarea { resize: vertical; min-height: 120px; }
} }
} }
if (snapshot.context) { if (snapshot.context) {
setOfflineWorkMode(false);
snapshot.context.notifications = lastNotifications; snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context); renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale(); if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale(); 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')); } else handleContextError(new Error('Context section unavailable'));
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) { if (eventsFreshness?.revalidating) {
@ -2995,15 +3020,59 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); } function load() { return contextPoller.refresh(); }
const offlineStatus = qs('#offline-status'); 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() { function showOfflineStatus() {
offlineStatus.hidden = false; offlineStatus.hidden = false;
setStatus('Offline'); setStatus('Offline');
if (!hasContextSnapshot) hydrateOfflineWork();
} }
function reconnectLiveData() { function reconnectLiveData() {
offlineStatus.hidden = true; offlineStatus.hidden = true;
setOfflineWorkMode(false);
setStatus('Reconnecting…'); setStatus('Reconnecting…');
contextPoller.refresh(); 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(); if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus); window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData); window.addEventListener('online', reconnectLiveData);

88
frontend/offline-work.js Normal file
View File

@ -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;
});

View File

@ -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 BASE = new URL('./', self.location.href).pathname;
const SHELL = [ const SHELL = [
BASE, BASE,
@ -10,6 +10,7 @@ const SHELL = [
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js', BASE + 'static/my-work.js',
BASE + 'static/pick-work.js', BASE + 'static/pick-work.js',
BASE + 'static/conversation.js', BASE + 'static/conversation.js',

104
tests/test_offline_work.py Normal file
View File

@ -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 '<script src="static/offline-work.js"></script>' 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

View File

@ -81,6 +81,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js", "/dashboard/static/my-work.js",
"/dashboard/static/pick-work.js", "/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js", "/dashboard/static/conversation.js",