From 05405bf2e8900f28713413384996fe3f53988ad3 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 22:11:52 +0000 Subject: [PATCH] feat: keep background Updates badge current (Closes #1281) --- frontend/dashboard.js | 1 + frontend/mobile-app-badge.js | 13 ++++ frontend/private-data-registry.js | 1 + frontend/service-worker.js | 81 ++++++++++++++++++++++-- src/push_notifications.py | 3 + tests/test_dashboard_session_frontend.py | 10 +-- tests/test_mobile_app_badge.py | 24 +++++++ tests/test_private_data_registry.py | 1 + tests/test_private_device_data.py | 1 + tests/test_push_notifications.py | 4 ++ tests/test_service_worker.py | 45 ++++++++++++- 11 files changed, 172 insertions(+), 12 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 749242a..c7e24f2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6,6 +6,7 @@ const mobileAppBadge = createMobileAppBadge({ control:qs('#app-badge-control'), status:qs('#app-badge-status'), container:qs('#app-badge-setting'), navigator, storage:localStorage, + serviceWorker:navigator.serviceWorker, }); mobileAppBadge.start(); const cardPlanning = createCardPlanning(document); diff --git a/frontend/mobile-app-badge.js b/frontend/mobile-app-badge.js index f650ab9..ef0b223 100644 --- a/frontend/mobile-app-badge.js +++ b/frontend/mobile-app-badge.js @@ -7,6 +7,7 @@ container, navigator, storage, + serviceWorker, }) { const ENABLED_KEY = 'stackchain.app-badge.enabled.v1'; let enabled = false; @@ -18,6 +19,16 @@ && typeof navigator?.clearAppBadge === 'function'; } + async function syncPreference() { + try { + const registration = await serviceWorker?.ready; + registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled}); + return Boolean(registration?.active); + } catch (_error) { + return false; + } + } + async function render() { if (!enabled || !available() || renderedCount === confirmedCount) return true; try { @@ -36,6 +47,7 @@ enabled = Boolean(control?.checked); if (enabled) storage?.setItem(ENABLED_KEY, 'true'); else storage?.removeItem(ENABLED_KEY); + await syncPreference(); if (!enabled && available()) { try { await navigator.clearAppBadge(); @@ -58,6 +70,7 @@ return false; } enabled = storage?.getItem(ENABLED_KEY) === 'true'; + if (enabled) void syncPreference(); if (control) { control.checked = enabled; control.addEventListener('change', change); diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js index 001923f..d359b90 100644 --- a/frontend/private-data-registry.js +++ b/frontend/private-data-registry.js @@ -7,6 +7,7 @@ 'stackchain-search-reply-drafts-v1', 'stackchain-conversation-reply-drafts-v1', 'stackchain-today-action-mailbox-v1', + 'stackchain-app-badge-preference-v1', ]); if (typeof module !== 'undefined' && module.exports) module.exports = databases; else root.stackchainPrivateDatabases = databases; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c867bdd..337f500 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -9,6 +9,57 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000; const TODAY_ACTION_TTL_MS = 2 * 60 * 1000; const PRIVATE_DATABASES = self.stackchainPrivateDatabases; +function createAppBadgePreference() { + const dbName = 'stackchain-app-badge-preference-v1'; + const storeName = 'preferences'; + const open = () => new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('App badge preference unavailable.')); + }); + return { + async get() { + const database = await open(); + const enabled = await new Promise((resolve, reject) => { + const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled'); + request.onsuccess = () => resolve(request.result === true); + request.onerror = () => reject(request.error || new Error('App badge preference unavailable.')); + }); + database.close(); + return enabled; + }, + async set(enabled) { + const database = await open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put(enabled === true, 'enabled'); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.')); + transaction.onabort = transaction.onerror; + }); + database.close(); + }, + }; +} +const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference(); +let renderedBackgroundBadgeCount = null; + +async function reconcileBackgroundAppBadge(count) { + if (!Number.isSafeInteger(count) || count < 0 || count > 9999 + || typeof self.registration.setAppBadge !== 'function' + || typeof self.registration.clearAppBadge !== 'function') return false; + let enabled = false; + try { enabled = await appBadgePreference.get(); } catch (_error) { return false; } + if (!enabled || renderedBackgroundBadgeCount === count) return false; + try { + if (count > 0) await self.registration.setAppBadge(count); + else await self.registration.clearAppBadge(); + renderedBackgroundBadgeCount = count; + return true; + } catch (_error) { return false; } +} + function createTodayActionStore() { const dbName = 'stackchain-today-action-mailbox-v1'; const storeName = 'commands'; @@ -524,6 +575,19 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr } self.addEventListener('message', event => { + if (event.data?.type === 'stackchain-app-badge-preference') { + event.waitUntil((async () => { + if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return; + const enabled = event.data.enabled === true; + try { + await appBadgePreference.set(enabled); + renderedBackgroundBadgeCount = null; + if (!enabled && typeof self.registration.clearAppBadge === 'function') { + await self.registration.clearAppBadge(); + } + } catch (_error) { /* Page preference remains authoritative on next launch. */ } + })()); + } if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => { await issueSync.resume(); await flushAndNotify(); @@ -582,6 +646,7 @@ self.addEventListener('push', event => { const tag = String(payload.tag || ''); const notificationId = Number(payload.notification_id); const updateCount = Number(payload.update_count); + const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN; const deadlineCount = Number(payload.deadline_count); const planDate = String(payload.plan_date || ''); if ( @@ -626,11 +691,12 @@ self.addEventListener('push', event => { && updateCount > 0 && updateCount <= 50 ) { - event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', { - body: 'Tap to review them in Stackchain.', - tag, - data: {route}, - })); + event.waitUntil(Promise.all([ + reconcileBackgroundAppBadge(unreadCount), + self.registration.showNotification(updateCount + ' new work updates', { + body: 'Tap to review them in Stackchain.', tag, data: {route}, + }), + ])); return; } if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return; @@ -650,7 +716,10 @@ self.addEventListener('push', event => { ]; options.data.notificationId = notificationId; } - event.waitUntil(self.registration.showNotification('New work update', options)); + event.waitUntil(Promise.all([ + reconcileBackgroundAppBadge(unreadCount), + self.registration.showNotification('New work update', options), + ])); }); async function openWorkRoute(route) { diff --git a/src/push_notifications.py b/src/push_notifications.py index 0975670..c8ebabf 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -133,6 +133,7 @@ async def dispatch_unread_updates( for item in page.get("items", []) if isinstance(item, dict) and str(item.get("id", "")).isdigit() } + unread_count = min(len(thread_revisions), 9999) await asyncio.to_thread(store.reconcile_unread, thread_revisions) deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions) if session_statuses is not None: @@ -201,6 +202,7 @@ async def dispatch_unread_updates( "route": f"#/my-work/update/{thread_id}", "tag": f"stackchain-update-{thread_id}", "notification_id": thread_id, + "unread_count": unread_count, }, separators=(",", ":"), ) @@ -258,6 +260,7 @@ async def dispatch_unread_updates( "route": "#/my-work/updates", "tag": "stackchain-update-digest", "update_count": len(overflow_revisions), + "unread_count": unread_count, }, separators=(",", ":"), ) diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 5572a37..a93ea72 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", - "stackchain-today-action-mailbox-v1", + "stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1", ] assert result["remaining"] == ["gitea.preference"] assert result["replaced"] == [ @@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", - "stackchain-today-action-mailbox-v1", + "stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["replaced"] == ["/dashboard/login?reason=session-expired"] @@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", - "stackchain-today-action-mailbox-v1", + "stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", - "stackchain-today-action-mailbox-v1", + "stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", - "stackchain-today-action-mailbox-v1", + "stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["assigned"] == "/dashboard/login" diff --git a/tests/test_mobile_app_badge.py b/tests/test_mobile_app_badge.py index 2a508f0..a6db31d 100644 --- a/tests/test_mobile_app_badge.py +++ b/tests/test_mobile_app_badge.py @@ -186,3 +186,27 @@ console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabl "saved": False, "status": "App icon badge is off, but the browser could not clear the old count.", } + + +def test_app_badge_synchronizes_opt_in_with_the_active_service_worker(): + result = run_badge(""" +const listeners = {}; +const messages = []; +const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }}; +const controller = createMobileAppBadge({ + control, status:{textContent:''}, container:{hidden:false}, + navigator:{async setAppBadge() {}, async clearAppBadge() {}}, + storage:{getItem() { return null; }, setItem() {}, removeItem() {}}, + serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})}, +}); +controller.start(); +control.checked = true; +await listeners.change(); +control.checked = false; +await listeners.change(); +console.log(JSON.stringify(messages)); +""") + assert result == [ + {"type": "stackchain-app-badge-preference", "enabled": True}, + {"type": "stackchain-app-badge-preference", "enabled": False}, + ] diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index f231905..00c51a8 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases)); "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", "stackchain-today-action-mailbox-v1", + "stackchain-app-badge-preference-v1", } diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index b17fa8b..fd67258 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{ "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", "stackchain-today-action-mailbox-v1", + "stackchain-app-badge-preference-v1", ] assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 5103e2e..1be7dcf 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -613,6 +613,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path "route": "#/my-work/update/42", "tag": "stackchain-update-42", "notification_id": 42, + "unread_count": 1, } assert "private/repo" not in json.dumps(sent) assert "Secret title" not in json.dumps(sent) @@ -692,7 +693,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige "route": "#/my-work/updates", "tag": "stackchain-update-digest", "update_count": 3, + "unread_count": 5, } + assert [payload["unread_count"] for payload in sent] == [5, 5, 5] assert "private/repo" not in json.dumps(sent) assert "Secret update" not in json.dumps(sent) assert await dispatch_unread_updates( @@ -792,6 +795,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path) "route": "#/my-work/update/42", "tag": "stackchain-update-42", "notification_id": 42, + "unread_count": 1, }] assert "private/repo" not in json.dumps(sent) assert "Secret title" not in json.dumps(sent) diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 6de5a72..b438b83 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -78,6 +78,10 @@ const context = {{ }}, purge: async () => {{ state.todayCommands = {{}}; }}, }}, + __STACKCHAIN_APP_BADGE_PREFERENCE: {{ + get: async () => state.badgeEnabled, + set: async enabled => {{ state.badgeEnabled = enabled; }}, + }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, clients: {{ @@ -88,6 +92,8 @@ const context = {{ registration: {{ showNotification: async (title, options) => state.notifications.push({{title,options}}), getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}], + setAppBadge: async value => state.appBadges.push(value), + clearAppBadge: async () => {{ state.clearedAppBadges += 1; }}, }}, __issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }}, @@ -479,6 +485,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", "stackchain-today-action-mailbox-v1", + "stackchain-app-badge-preference-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["clientMessages"] == [ @@ -800,6 +807,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", "stackchain-today-action-mailbox-v1", + "stackchain-app-badge-preference-v1", ] assert result["replies"] == [{"ok": True}] @@ -973,6 +981,40 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c assert "must-not-render" not in json.dumps(result["notifications"]) +def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'}); + await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7}); + await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7}); + await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99}); + await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["badgeEnabled"] is True + assert result["appBadges"] == [7] + assert result["clearedAppBadges"] == 0 + assert len(result["notifications"]) == 4 + + +def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off(): + result = run_worker_scenario( + """ + state.badgeEnabled = true; + await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3}); + await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'}); + await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["badgeEnabled"] is False + assert result["appBadges"] == [3] + assert result["clearedAppBadges"] == 1 + + def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy(): result = run_worker_scenario( """ @@ -1565,6 +1607,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", "stackchain-today-action-mailbox-v1", + "stackchain-app-badge-preference-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert "private cached dashboard" not in result["body"]