From a2ba624dc207443c5be29e83fa8635e9f31536ac Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 22:03:28 +0000 Subject: [PATCH] feat: open saved Today details offline (#345) --- README.md | 14 ++-- frontend/dashboard.js | 68 +++++++++++++++++-- frontend/offline-work.js | 79 ++++++++++++++++++++++- frontend/service-worker.js | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 2 +- tests/test_offline_work.py | 51 +++++++++++++++ tests/test_readme.py | 9 +++ tests/test_service_worker.py | 6 +- 10 files changed, 214 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c31a3fb..876fc4f 100644 --- a/README.md +++ b/README.md @@ -209,11 +209,15 @@ 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 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. +and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and +newest 20 comments. This account-bound cache is limited to ten records; diffs, +credentials, repository catalogs, events, and complete API responses are excluded. +A cold offline launch labels the saved time. Cached Today details open in the existing +phone sheet, where comments can enter the account-bound durable outbox; planning, +assignment, review, merge, and close controls remain disabled until reconnection. +Cards without a saved detail explain that reconnection is required. **Clear offline +work data** deletes both stores, and opting out or seven-day expiry deletes them +automatically. API responses and mutations are never cached by the service worker. New issue captures, issue comments, pull-request comments, and unread-update replies use bounded local diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f880390..7135cea 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -467,7 +467,22 @@ 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.'; + const offlineLogin = planningOwnerLogin || confirmedOwnerLogin || + String(offlineWorkStore.load()?.user?.login || '').trim(); + const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item); + if (!todayWork.contains(item) || !savedDetail) { + qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; + return; + } + if (item.kind === 'issue') { + issueTrigger = trigger; + openIssueSheet(item, trigger, savedDetail); + } else if (item.kind === 'pull' && !item.is_review) { + pullTrigger = trigger; + openPullSheet(item, trigger, savedDetail); + } else { + qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; + } return; } if (item.has_update && item.kind === 'update') updateTrigger = trigger; @@ -598,6 +613,27 @@ }); } + function setOfflineDetailControls(kind) { + const selectors = kind === 'issue' ? [ + '#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff', + '#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date', + '#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date', + '#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments', + ] : [ + '#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments', + ]; + selectors.forEach(selector => { + const control = qs(selector); + if (control) control.disabled = true; + }); + if (kind === 'issue') { + qs('#issue-planning').inert = true; + qs('#issue-handoff').inert = true; + } else { + qs('#pull-review').inert = true; + } + } + function renderContextSnapshot(data) { liveMode = true; hasContextSnapshot = true; @@ -1135,8 +1171,10 @@ } } - async function openIssueSheet(item, trigger) { + async function openIssueSheet(item, trigger, offlineDetail = null) { if (!item) return; + qs('#issue-planning').inert = false; + qs('#issue-handoff').inert = false; selectedIssue = item; selectedIssueDetail = null; issueConversation = null; @@ -1182,7 +1220,7 @@ qs('#close-issue').disabled = false; qs('#close-issue-sheet').focus(); try { - const detail = await issueController.load(item); + const detail = offlineDetail || await issueController.load(item); if (selectedIssue !== item) return; selectedIssueDetail = detail; issueConversation = issueController.conversation(item, detail.conversation); @@ -1204,7 +1242,14 @@ qs('#clear-issue-due-date').disabled = !detail.due_date; qs('#issue-due-status').textContent = detail.due_date ? 'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.'; - if (qs('#issue-planning').open) loadIssuePlanning(); + if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning(); + if (offlineDetail) { + setOfflineDetailControls('issue'); + qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + + ' · comments queue for sync'; + } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { + offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); + } } catch (error) { if (selectedIssue !== item) return; qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; @@ -1297,8 +1342,9 @@ } } - async function openPullSheet(item, trigger) { + async function openPullSheet(item, trigger, offlineDetail = null) { if (!item) return; + qs('#pull-review').inert = false; selectedPull = item; pullTrigger = trigger; selectedPullDetail = null; @@ -1327,7 +1373,7 @@ qs('#open-pull-gitea').href = item.url || '#'; qs('#close-pull-sheet').focus(); try { - const detail = await pullController.load(item); + const detail = offlineDetail || await pullController.load(item); if (selectedPull !== item) return; selectedPullDetail = detail; pullConversation = pullController.conversation(item, detail.conversation); @@ -1336,6 +1382,13 @@ renderPullConversation(pullConversation.snapshot()); qs('#open-pull-gitea').href = detail.url || item.url || '#'; qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author'); + if (offlineDetail) { + setOfflineDetailControls('pull'); + qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) + + ' · comments queue for sync'; + } else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) { + offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail); + } } catch (error) { if (selectedPull !== item) return; qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; @@ -2970,6 +3023,9 @@ const saved = offlineWorkStore.load(); if (!saved) return false; const outage = mode === 'outage'; + confirmedOwnerLogin = String(saved.user?.login || '').trim(); + planningOwnerLogin = confirmedOwnerLogin; + updatePlanningAvailability(); lastNotifications = saved.notifications || []; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; workPagination = saved.work_pagination || {}; diff --git a/frontend/offline-work.js b/frontend/offline-work.js index 1c34b0c..82c8454 100644 --- a/frontend/offline-work.js +++ b/frontend/offline-work.js @@ -7,6 +7,7 @@ const ENABLED_KEY = 'stackchain.offline-work.enabled.v1'; const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1'; + const DETAILS_KEY = 'stackchain.offline-work.details.v1'; const VERSION = 1; const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const ITEM_FIELDS = [ @@ -16,6 +17,10 @@ const NOTIFICATION_FIELDS = [ 'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url', ]; + const DETAIL_FIELDS = [ + 'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone', + ]; + const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url']; function pick(source, fields) { const output = {}; @@ -25,18 +30,86 @@ return output; } - function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) { + function createOfflineWorkStore({ + storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10, + }) { function enabled() { try { return storage.getItem(ENABLED_KEY) === 'true'; } catch (_) { return false; } } function clear() { - try { storage.removeItem(SNAPSHOT_KEY); } + try { + storage.removeItem(SNAPSHOT_KEY); + storage.removeItem(DETAILS_KEY); + } catch (_) { return false; } return true; } + function detailKey(item) { + const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : ''; + const repository = String(item?.repository || ''); + const number = Number(item?.number || 0); + return kind && repository && number > 0 ? [kind, repository, number].join(':') : ''; + } + + function readDetails() { + try { + const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]'); + return Array.isArray(value) ? value : []; + } catch (_) { + try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ } + return []; + } + } + + function saveDetail(login, item, detail) { + const key = detailKey(item); + login = String(login || '').trim(); + if (!enabled() || !login || !key || !detail) return false; + const conversation = detail.conversation || {}; + const record = { + key, + user_login: login, + saved_at: now().toISOString(), + data: { + ...pick(detail, DETAIL_FIELDS), + conversation: { + comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)), + page: Number(conversation.page || 1), + older_page: conversation.older_page ?? null, + total: Number(conversation.total || 0), + }, + }, + }; + const records = readDetails().filter(candidate => + !(candidate?.key === key && candidate?.user_login === login) + ); + records.push(record); + try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); } + catch (_) { return false; } + return true; + } + + function loadDetail(login, item) { + const key = detailKey(item); + login = String(login || '').trim(); + if (!login || !key) return null; + const records = readDetails(); + const currentTime = now().getTime(); + const valid = records.filter(record => { + const savedAt = Date.parse(record?.saved_at || ''); + return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) && + currentTime - savedAt <= maxAgeMs; + }); + if (valid.length !== records.length) { + try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ } + } + const record = valid.find(candidate => candidate.key === key && candidate.user_login === login); + return record ? { ...record.data, saved_at: record.saved_at } : null; + } + function setEnabled(value) { try { storage.setItem(ENABLED_KEY, value ? 'true' : 'false'); @@ -81,7 +154,7 @@ return { ...record.data, saved_at: record.saved_at }; } - return { enabled, setEnabled, save, load, clear }; + return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear }; } return createOfflineWorkStore; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 01b724e..c88b7c4 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v38'; +const CACHE = 'stackchain-dashboard-shell-v39'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 037eda1..dea07ec 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v38" in worker + assert "stackchain-dashboard-shell-v39" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 55ca53e..9a9a529 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v38" in worker + assert "stackchain-dashboard-shell-v39" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 9586701..609fe03 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -829,7 +829,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands() assert 'loadIssueLabelEditor(' not in open_handler assert 'loadIssueMilestoneEditor(' not in open_handler - assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler + assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler assert "qs('#issue-planning').addEventListener('toggle'" in html assert 'planningLoader.open(selectedIssue)' in html assert 'id="retry-issue-planning"' in html diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 2ab7d77..d58679e 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -87,6 +87,36 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load( assert result == {"enabled": False, "loaded": None} +def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded(): + result = run_scenario(""" +const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2}); +store.setEnabled(true); +const issue = {kind:'issue', repository:'stackchain/dashboard', number:8}; +store.saveDetail('timmy', issue, { + title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'], + url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}], + conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z', + token:'comment-secret'}], page:1, older_page:null, total:1}, +}); +store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'}); +store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'}); +const raw = [...values.values()].join(' '); +process.stdout.write(JSON.stringify({ + evicted:store.loadDetail('timmy', issue), + loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}), + wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}), + raw, +})); +""") + + assert result["evicted"] is None + assert result["wrongUser"] is None + assert result["loaded"]["title"] == "PR 9" + assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z" + assert "secret" not in result["raw"] + assert "private diff" not in result["raw"] + + @pytest.mark.anyio async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration(): html = await dashboard() @@ -114,3 +144,24 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap assert "Live details and actions will return automatically." in html assert "function renderLiveSnapshot(snapshot, changedSections" in html assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html + + +@pytest.mark.anyio +async def test_saved_today_details_open_offline_without_enabling_server_state_actions(): + html = await dashboard() + + assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html + assert "Details not saved—reconnect to open this item." in html + assert "openIssueSheet(item, trigger, savedDetail)" in html + assert "openPullSheet(item, trigger, savedDetail)" in html + assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html + assert "todayWork.contains(item)" in html + assert "Offline copy · saved " in html + assert "setOfflineDetailControls('issue')" in html + assert "setOfflineDetailControls('pull')" in html + assert "qs('#issue-planning').inert = true;" in html + assert "qs('#issue-handoff').inert = true;" in html + assert "qs('#pull-review').inert = true;" in html + assert "qs('#issue-planning').inert = false;" in html + assert "qs('#pull-review').inert = false;" in html + assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html diff --git a/tests/test_readme.py b/tests/test_readme.py index f0374d1..286bc99 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -32,3 +32,12 @@ def test_readme_documents_liveness_and_gitea_readiness_checks(): assert "`/readyz`" in text assert "does not contact Gitea" in text assert "HTTP 503" in text + + +def test_readme_documents_bounded_offline_today_details_and_safe_actions(): + text = " ".join(README.read_text().split()) + + assert "opened Today issue or pull request" in text + assert "newest 20 comments" in text + assert "comments can enter the account-bound durable outbox" in text + assert "planning, assignment, review, merge, and close controls remain disabled" in text diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 8ffa866..3618e16 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v38" in source + assert "stackchain-dashboard-shell-v39" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v38" in source + assert "stackchain-dashboard-shell-v39" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v38" in source + assert "stackchain-dashboard-shell-v39" in source assert "BASE + 'static/update-ownership.js'" in source