From ae6cde29c28dd5547a2a60f4d4ea1e30ecbd349b Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 22:42:20 +0000 Subject: [PATCH] feat: continue unread updates offline (#347) --- README.md | 6 ++- frontend/dashboard.js | 34 +++++++++++++-- frontend/my-work.js | 10 +++-- frontend/offline-work.js | 5 +++ frontend/service-worker.js | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 47 ++++++++++++++++++++ tests/test_offline_work.py | 53 +++++++++++++++++++++++ tests/test_readme.py | 8 ++++ tests/test_service_worker.py | 6 +-- 11 files changed, 161 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 876fc4f..58df1b9 100644 --- a/README.md +++ b/README.md @@ -210,11 +210,15 @@ Users can explicitly enable **Keep My Work available offline**. Each healthy liv 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. 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, +newest 20 comments. A previously opened unread update is retained by notification +identity with allowlisted subject context and its newest 20 conversation messages. +This account-bound cache is limited to ten records across all detail kinds; 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. +Cached unread updates use the same phone conversation sheet and replies enter the +account-bound durable outbox, while mark read, ownership, deferral, and older-message loading 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. diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7135cea..812f82c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -429,6 +429,7 @@ qs('#send-update-reply').disabled = false; qs('#update-ownership-action').hidden = true; qs('#retry-update-load').hidden = true; + setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); }, onDetail: detail => { @@ -437,7 +438,14 @@ qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.'); qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; - updateOwnership.open(detail, selectedUpdate); + if (offlineWorkMode) { + setOfflineUpdateControls(true); + } else { + updateOwnership.open(detail, selectedUpdate); + if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) { + offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail); + } + } qs('#retry-update-load').hidden = true; }, onConversation: renderUpdateConversation, @@ -470,7 +478,8 @@ const offlineLogin = planningOwnerLogin || confirmedOwnerLogin || String(offlineWorkStore.load()?.user?.login || '').trim(); const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item); - if (!todayWork.contains(item) || !savedDetail) { + const savedUpdate = item.kind === 'update' && item.has_update; + if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) { qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; return; } @@ -480,6 +489,14 @@ } else if (item.kind === 'pull' && !item.is_review) { pullTrigger = trigger; openPullSheet(item, trigger, savedDetail); + } else if (savedUpdate) { + updateTrigger = trigger; + notificationReader.open(item, savedDetail).then(opened => { + if (opened && selectedUpdate === item) { + qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) + + ' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.'; + } + }); } else { qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.'; } @@ -634,6 +651,13 @@ } } + function setOfflineUpdateControls(offline) { + qs('#mark-update-read-next').disabled = offline; + qs('#update-ownership-action').disabled = offline; + qs('#load-older-update-comments').disabled = offline; + qs('#update-sheet .detail-defer').inert = offline; + } + function renderContextSnapshot(data) { liveMode = true; hasContextSnapshot = true; @@ -959,7 +983,7 @@ const item = lastMyWork[Number(button.dataset.updateIndex)]; if (!item) return; updateTrigger = button; - workRoute.open({ ...item, kind:'update' }); + openRoutedWork(item, button); }); }); document.querySelectorAll('[data-notification-id]').forEach(button => { @@ -3018,6 +3042,10 @@ 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; }); + if (value) { + document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]') + .forEach(button => { button.disabled = true; }); + } } function hydrateOfflineWork(mode = 'offline') { const saved = offlineWorkStore.load(); diff --git a/frontend/my-work.js b/frontend/my-work.js index 10d354a..52124dd 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -288,14 +288,16 @@ function createNotificationReader({ let loadVersion = 0; let marking = false; let conversationPager = null; + let offlineHydrated = false; - async function open(item) { + async function open(item, savedDetail = null) { selected = item; + offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail)); const version = ++loadVersion; onOpen(item); onStatus('Loading update…'); try { - const detail = await load(item.notification_id); + const detail = offlineHydrated ? savedDetail : await load(item.notification_id); if (selected !== item || version !== loadVersion) return false; onDetail(detail); if (createPager && loadConversation && detail.conversation) { @@ -324,7 +326,7 @@ function createNotificationReader({ return true; }, async loadOlder() { - if (!conversationPager || !selected) return false; + if (!conversationPager || !selected || offlineHydrated) return false; const pager = conversationPager; const version = loadVersion; onStatus('Loading older messages…'); @@ -342,7 +344,7 @@ function createNotificationReader({ } }, async markReadAndNext(items) { - if (!selected || marking) return false; + if (!selected || marking || offlineHydrated) return false; const current = selected; marking = true; onStatus('Marking update read…'); diff --git a/frontend/offline-work.js b/frontend/offline-work.js index 82c8454..76f2471 100644 --- a/frontend/offline-work.js +++ b/frontend/offline-work.js @@ -19,6 +19,7 @@ ]; const DETAIL_FIELDS = [ 'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone', + 'id', 'repository', 'subject_type', 'subject_body', ]; const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url']; @@ -48,6 +49,10 @@ } function detailKey(item) { + if (item?.kind === 'update') { + const notificationId = Number(item?.notification_id || 0); + return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : ''; + } const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : ''; const repository = String(item?.repository || ''); const number = Number(item?.number || 0); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c88b7c4..e8246de 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-v39'; +const CACHE = 'stackchain-dashboard-shell-v40'; 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 dea07ec..448a1b6 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-v39" in worker + assert "stackchain-dashboard-shell-v40" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 9a9a529..bb696b8 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-v39" in worker + assert "stackchain-dashboard-shell-v40" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 609fe03..ba48d20 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2359,6 +2359,53 @@ reader.open(firstItem).then(async () => {{ assert output["states"] == [[41], [90]] +def test_notification_reader_hydrates_saved_conversation_without_server_state_actions(): + script = f""" +const build = require({json.dumps(str(MY_WORK))}); +const createPager = require({json.dumps(str(CONVERSATION))}); +const item = {{kind:'update', notification_id:42, has_update:true}}; +const saved = {{ + id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z', + conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}}, +}}; +let detailLoads = 0; +let conversationLoads = 0; +let markReads = 0; +const details = []; +const conversations = []; +const reader = build.createNotificationReader({{ + load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }}, + loadConversation: async () => {{ conversationLoads += 1; return {{}}; }}, + markRead: async () => {{ markReads += 1; }}, createPager, + onOpen: () => {{}}, onDetail: detail => details.push(detail.title), + onConversation: state => conversations.push(state.comments.map(comment => comment.id)), + onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}}, +}}); +(async () => {{ + const opened = await reader.open(item, saved); + const older = await reader.loadOlder(); + const marked = await reader.markReadAndNext([item]); + process.stdout.write(JSON.stringify({{ + opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations, + }})); +}})(); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + + assert json.loads(result.stdout) == { + "opened": True, + "older": False, + "marked": False, + "detailLoads": 0, + "conversationLoads": 0, + "markReads": 0, + "details": ["Saved update"], + "conversations": [[41]], + } + + def test_notification_reader_appends_a_confirmed_reply_exactly_once(): script = f""" const build = require({json.dumps(str(MY_WORK))}); diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index d58679e..402f551 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -117,6 +117,43 @@ process.stdout.write(JSON.stringify({ assert "private diff" not in result["raw"] +def test_unread_update_conversation_detail_is_private_bounded_and_account_bound(): + result = run_scenario(""" +const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')}); +store.setEnabled(true); +const update = {kind:'update', notification_id:42}; +store.saveDetail('timmy', update, { + id:42, repository:'stackchain/dashboard', title:'Deployment blocked', + subject_type:'Issue', state:'open', subject_body:'Safe subject context', + url:'https://forge.example/issues/8#issuecomment-9', token:'secret', + issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}], + conversation:{ + comments:Array.from({length:25}, (_, index) => ({ + id:index + 1, author:'alexander', body:'Message ' + (index + 1), + created_at:'2026-08-07T11:00:00Z', token:'comment-secret', + })), + page:1, older_page:2, total:40, + }, +}); +const raw = [...values.values()].join(' '); +process.stdout.write(JSON.stringify({ + loaded:store.loadDetail('timmy', update), + wrongUser:store.loadDetail('alexander', update), + raw, +})); +""") + + assert result["wrongUser"] is None + assert result["loaded"]["title"] == "Deployment blocked" + assert result["loaded"]["subject_body"] == "Safe subject context" + assert result["loaded"]["subject_type"] == "Issue" + assert len(result["loaded"]["conversation"]["comments"]) == 20 + assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6" + 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() @@ -165,3 +202,19 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac 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 + + +@pytest.mark.anyio +async def test_saved_unread_update_opens_offline_with_reply_only_controls(): + html = await dashboard() + + assert "notificationReader.open(item, savedDetail)" in html + assert "openRoutedWork(item, button);" in html + assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html + assert "Offline update · saved " in html + assert "setOfflineUpdateControls(true)" in html + assert "qs('#mark-update-read-next').disabled = offline;" in html + assert "qs('#update-ownership-action').disabled = offline;" in html + assert "qs('#load-older-update-comments').disabled = offline;" in html + assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html + assert "Reconnect to mark read, take ownership, defer, or load older messages." in html diff --git a/tests/test_readme.py b/tests/test_readme.py index 286bc99..b8e5371 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -41,3 +41,11 @@ def test_readme_documents_bounded_offline_today_details_and_safe_actions(): 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 + + +def test_readme_documents_offline_unread_update_conversations_and_boundaries(): + text = " ".join(README.read_text().split()) + + assert "previously opened unread update" in text + assert "replies enter the account-bound durable outbox" in text + assert "mark read, ownership, deferral, and older-message loading remain disabled" in text diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3618e16..76b55e0 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-v39" in source + assert "stackchain-dashboard-shell-v40" 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-v39" in source + assert "stackchain-dashboard-shell-v40" 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-v39" in source + assert "stackchain-dashboard-shell-v40" in source assert "BASE + 'static/update-ownership.js'" in source