From 33c8761e80edd8bbe6363a9e4fc1d2200be91f7e Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 22:38:21 +0000 Subject: [PATCH] feat: combine review attention app badge (Closes #1324) --- frontend/dashboard.js | 35 ++++---- frontend/index.html | 2 +- frontend/mobile-app-badge.js | 30 +++++-- frontend/service-worker.js | 76 +++++++++++++--- tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 10 +-- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_app_badge.py | 103 +++++++++++++++++++--- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 10 +-- tests/test_mobile_task_dock.py | 6 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 86 ++++++++++++++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 18 files changed, 287 insertions(+), 89 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a909a04..6af5946 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3,12 +3,12 @@ const qs = (s, el=document) => el.querySelector(s); const announceWork = message => qs('#my-work-action-status').textContent = message; const fmt = (d) => new Date(d).toLocaleString(); - const mobileAppBadge = createMobileAppBadge({ + const appBadge = createMobileAppBadge({ control:qs('#app-badge-control'), status:qs('#app-badge-status'), container:qs('#app-badge-setting'), navigator, storage:localStorage, serviceWorker:navigator.serviceWorker, }); - mobileAppBadge.start(); + appBadge.start(); const cardPlanning = createCardPlanning(document); const mobileComposerViewport = createMobileComposerViewport({ viewport: window.visualViewport, @@ -103,21 +103,22 @@ const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin); (authored ? applyAuthoredOutboxResult : applyOutboxResult)(result); } - let mobileQueueCounts = {}; - let mobilePreparationItems = {}; + let queueCounts = {}; + let preparationItems = {}; const followingQueue = attachFollowing(item => { searchPreviewReturnKind = 'following'; return searchPreview.open(item); }, { onCount:(count, items) => { - mobileQueueCounts.following = count; - mobileQueueCounts.followingUnavailable = false; - mobilePreparationItems.following = items.filter(item => item.has_unseen_change === true); + appBadge.reconcile('following', count, true); + queueCounts.following = count; + queueCounts.followingUnavailable = false; + preparationItems.following = items.filter(item => item.has_unseen_change === true); mobileStartDay.render(); }, onStatus:status => { if (status === 'loading') return; - mobileQueueCounts.followingUnavailable = status === 'error'; + queueCounts.followingUnavailable = status === 'error'; mobileStartDay.render(); }, onReviewComplete:() => mobileStartDay.completePhase('following'), @@ -166,7 +167,7 @@ selectFilter: selectMobileQueue, firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'), announce: announceWork, - getCounts: () => mobileQueueCounts, + getCounts: () => queueCounts, openFindWork: () => qs('#find-work').click(), }); function openMobileStartDay() { @@ -201,14 +202,14 @@ findWork: () => qs('#find-work').click(), }); const mobileStartDay = createMobileStartDay({ - getCounts: () => mobileQueueCounts, - getPhaseItems: () => mobilePreparationItems, + getCounts: () => queueCounts, + getPhaseItems: () => preparationItems, getLogin: () => confirmedOwnerLogin, openQueue: name => { const sheet = qs('#mobile-queue-sheet'); if (sheet.open) sheet.close(); return name === 'find' ? qs('#find-work').click() : - name === 'following' ? (mobileQueueCounts.followingUnavailable ? followingQueue.open() : + name === 'following' ? (queueCounts.followingUnavailable ? followingQueue.open() : followingQueue.review()) : mobileQueueLauncher.open(name); }, onHandoff: current => { @@ -1526,7 +1527,7 @@ }, onPagination: pagination => { notificationPagination = pagination; - mobileAppBadge.reconcile(pagination.total, {authoritative:true}); + appBadge.reconcile('updates', pagination.total, true); const loaded = Math.min(pagination.total, pagination.page * 50); qs('#notification-page-status').textContent = pagination.total ? loaded + ' of ' + pagination.total + ' unread updates loaded.' : ''; @@ -3429,7 +3430,7 @@ counts.later = laterMyWork.length; counts.draft = lastDrafts.length; counts.delivery = draftInbox.partition(lastDrafts).actionable; - mobilePreparationItems = { + preparationItems = { delivery:draftInbox.partition(lastDrafts).deliveries, agenda:agendaMyWork(activeMyWork), attention:activeMyWork.filter(item => item.needs_attention), @@ -3450,7 +3451,7 @@ const element = qs('[data-work-count="' + filter + '"]'); if (element) element.textContent = count; }); - mobileQueueCounts = counts; + queueCounts = counts; mobileStartDay.reconcile({ authoritative:authoritativeMyWorkRefresh, authoritativePhases:['delivery'], @@ -8384,8 +8385,8 @@ protectStorage:() => deviceStorage.requestPersistence(), notificationReadiness:() => pushController?.notificationReadiness() || {state:'unavailable', detail:'Update notifications are unavailable.'}, - appBadgeReadiness:() => mobileAppBadge.readiness(), - enableAppBadge:() => mobileAppBadge.enable(), + appBadgeReadiness:() => appBadge.readiness(), + enableAppBadge:() => appBadge.enable(), enablePush:async () => { const controller = await pushControllerReady; if (!controller) return; diff --git a/frontend/index.html b/frontend/index.html index 3b63734..e26b55b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -215,7 +215,7 @@ - + diff --git a/frontend/mobile-app-badge.js b/frontend/mobile-app-badge.js index ef0b223..5aa9331 100644 --- a/frontend/mobile-app-badge.js +++ b/frontend/mobile-app-badge.js @@ -11,9 +11,10 @@ }) { const ENABLED_KEY = 'stackchain.app-badge.enabled.v1'; let enabled = false; - let confirmedCount = 0; + const confirmedCounts = {updates:0, following:0}; let renderedCount = null; + function available() { return typeof navigator?.setAppBadge === 'function' && typeof navigator?.clearAppBadge === 'function'; @@ -29,7 +30,16 @@ } } + async function syncCount(channel, count) { + if (!enabled) return; + try { + const registration = await serviceWorker?.ready; + registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count}); + } catch (_error) { /* A later refresh can retry. */ } + } + async function render() { + const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following); if (!enabled || !available() || renderedCount === confirmedCount) return true; try { if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount); @@ -48,7 +58,13 @@ if (enabled) storage?.setItem(ENABLED_KEY, 'true'); else storage?.removeItem(ENABLED_KEY); await syncPreference(); + if (enabled) { + if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates); + if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following); + } if (!enabled && available()) { + confirmedCounts.updates = 0; + confirmedCounts.following = 0; try { await navigator.clearAppBadge(); renderedCount = null; @@ -82,8 +98,8 @@ function readiness() { if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'}; return enabled - ? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'} - : {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'}; + ? {state:'complete', detail:'The app icon shows review attention.'} + : {state:'incomplete', detail:'Show review attention without opening Stackchain.'}; } async function enable() { @@ -93,9 +109,11 @@ return true; } - async function reconcile(count, {authoritative = false} = {}) { - if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false; - confirmedCount = count; + async function reconcile(channel, count, authoritative = false) { + if (!Object.hasOwn(confirmedCounts, channel) + || authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false; + confirmedCounts[channel] = count; + await syncCount(channel, count); return render(); } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7a58186..cc49331 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v136'; +const CACHE = 'stackchain-dashboard-shell-v137'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -40,22 +40,55 @@ function createAppBadgePreference() { }); database.close(); }, + async getCounts() { + const database = await open(); + const counts = {}; + for (const channel of ['updates', 'following']) { + counts[channel] = await new Promise((resolve, reject) => { + const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel); + request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0); + request.onerror = () => reject(request.error || new Error('App badge count unavailable.')); + }); + } + database.close(); + return counts; + }, + async setCount(channel, count) { + const database = await open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put(count, 'count:' + channel); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.')); + transaction.onabort = transaction.onerror; + }); + database.close(); + }, + async clearCounts() { + await this.setCount('updates', 0); + await this.setCount('following', 0); + }, }; } const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference(); let renderedBackgroundBadgeCount = null; -async function reconcileBackgroundAppBadge(count) { - if (!Number.isSafeInteger(count) || count < 0 || count > 9999 +async function reconcileBackgroundAppBadge(channel, count) { + if (!['updates', 'following'].includes(channel) + || !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; + if (!enabled) return false; try { - if (count > 0) await self.registration.setAppBadge(count); + await appBadgePreference.setCount(channel, count); + const counts = await appBadgePreference.getCounts(); + const total = Math.min(9999, counts.updates + counts.following); + if (renderedBackgroundBadgeCount === total) return false; + if (total > 0) await self.registration.setAppBadge(total); else await self.registration.clearAppBadge(); - renderedBackgroundBadgeCount = count; + renderedBackgroundBadgeCount = total; return true; } catch (_error) { return false; } } @@ -576,6 +609,21 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr } self.addEventListener('message', event => { + if (event.data?.type === 'stackchain-app-badge-count') { + event.waitUntil((async () => { + if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return; + const channel = event.data.channel; + const count = event.data.count; + if (!['updates', 'following'].includes(channel) + || !Number.isSafeInteger(count) || count < 0 || count > 9999) return; + try { + if (await appBadgePreference.get()) { + await appBadgePreference.setCount(channel, count); + renderedBackgroundBadgeCount = null; + } + } catch (_error) { /* A later authoritative refresh can restore the count. */ } + })()); + } if (event.data?.type === 'stackchain-app-badge-preference') { event.waitUntil((async () => { if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return; @@ -584,6 +632,7 @@ self.addEventListener('message', event => { await appBadgePreference.set(enabled); renderedBackgroundBadgeCount = null; if (!enabled && typeof self.registration.clearAppBadge === 'function') { + await appBadgePreference.clearCounts(); await self.registration.clearAppBadge(); } } catch (_error) { /* Page preference remains authoritative on next launch. */ } @@ -693,14 +742,15 @@ self.addEventListener('push', event => { && followingCount > 0 && followingCount <= 50 ) { - event.waitUntil(self.registration.showNotification( - followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', - { + event.waitUntil(Promise.all([ + reconcileBackgroundAppBadge('following', followingCount), + self.registration.showNotification( + followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', { body: 'Open Following to review the latest activity.', tag, data: {route}, - } - )); + }), + ])); return; } if ( @@ -711,7 +761,7 @@ self.addEventListener('push', event => { && updateCount <= 50 ) { event.waitUntil(Promise.all([ - reconcileBackgroundAppBadge(unreadCount), + reconcileBackgroundAppBadge('updates', unreadCount), self.registration.showNotification(updateCount + ' new work updates', { body: 'Tap to review them in Stackchain.', tag, data: {route}, }), @@ -736,7 +786,7 @@ self.addEventListener('push', event => { options.data.notificationId = notificationId; } event.waitUntil(Promise.all([ - reconcileBackgroundAppBadge(unreadCount), + reconcileBackgroundAppBadge('updates', unreadCount), self.registration.showNotification('New work update', options), ])); }); diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 5ed80be..83e23ef 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v136" in worker + assert "stackchain-dashboard-shell-v137" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 7b9d867..49db033 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -489,7 +489,7 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow(): assert "'Back to Following'" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v136" in service_worker + assert "stackchain-dashboard-shell-v137" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): @@ -504,8 +504,8 @@ def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): assert "const completed = feature.finishReview()" in following assert "if (completed) return 'completed-following'" in following assert "followingQueue.load().catch(() => {})" in dashboard - assert "mobileQueueCounts.following = count" in dashboard - assert "mobileQueueCounts.followingUnavailable = status === 'error'" in dashboard - assert "mobilePreparationItems.following = items.filter" in dashboard - assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in dashboard + assert "queueCounts.following = count" in dashboard + assert "queueCounts.followingUnavailable = status === 'error'" in dashboard + assert "preparationItems.following = items.filter" in dashboard + assert "queueCounts.followingUnavailable ? followingQueue.open()" in dashboard assert "mobileStartDay.completePhase('following')" in dashboard diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 471d050..83f31d2 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index d8c844d..a4219da 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,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-v136" in worker + assert "stackchain-dashboard-shell-v137" in worker diff --git a/tests/test_mobile_app_badge.py b/tests/test_mobile_app_badge.py index a6db31d..849c217 100644 --- a/tests/test_mobile_app_badge.py +++ b/tests/test_mobile_app_badge.py @@ -45,12 +45,12 @@ const controller = createMobileAppBadge({ }, }); controller.start(); -await controller.reconcile(7, {authoritative:true}); -await controller.reconcile(12, {authoritative:false}); +await controller.reconcile('updates', 7, true); +await controller.reconcile('updates', 12, false); control.checked = true; await listeners.change(); -await controller.reconcile(7, {authoritative:true}); -await controller.reconcile(0, {authoritative:true}); +await controller.reconcile('updates', 7, true); +await controller.reconcile('updates', 0, true); console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent})); """) assert result == { @@ -61,6 +61,33 @@ console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-ba } +def test_app_badge_combines_authoritative_update_and_following_channels_independently(): + result = run_badge(""" +const values = []; +let clears = 0; +const controller = createMobileAppBadge({ + control:{checked:true, disabled:false, addEventListener() {}}, + status:{textContent:''}, container:{hidden:false}, + navigator:{ + async setAppBadge(value) { values.push(value); }, + async clearAppBadge() { clears++; }, + }, + storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}}, +}); +controller.start(); +await controller.reconcile('updates', 3, true); +await controller.reconcile('following', 2, true); +await controller.reconcile('following', 0, true); +await controller.reconcile('following', 4, false); +await controller.reconcile('updates', 0, true); +await controller.reconcile('following', 2, true); +await controller.reconcile('updates', 5, true); +await controller.reconcile('following', 2, true); +console.log(JSON.stringify({values, clears})); +""") + assert result == {"values": [3, 5, 3, 2, 7], "clears": 1} + + def test_app_badge_hides_unsupported_device_control_without_touching_storage(): result = run_badge(""" const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }}; @@ -92,9 +119,11 @@ def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total(): assert 'id="app-badge-control"' in index assert 'id="app-badge-status" role="status"' in index assert ".app-badge-control { min-height:44px;" in css - assert "const mobileAppBadge = createMobileAppBadge({" in dashboard - assert "mobileAppBadge.start();" in dashboard - assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard + assert "const appBadge = createMobileAppBadge({" in dashboard + assert "appBadge.start();" in dashboard + assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard + assert "appBadge.reconcile('following', count, true);" in dashboard + assert "Show review attention on the app icon" in index def test_app_badge_exposes_device_setup_readiness_and_enable_action(): @@ -113,7 +142,7 @@ const controller = createMobileAppBadge({ }, }); controller.start(); -await controller.reconcile(4, {authoritative:true}); +await controller.reconcile('updates', 4, true); const before = controller.readiness(); await controller.enable(); const after = controller.readiness(); @@ -122,11 +151,11 @@ console.log(JSON.stringify({before, after, checked:control.checked, values})); assert result == { "before": { "state": "incomplete", - "detail": "Show the confirmed unread Updates count without opening Stackchain.", + "detail": "Show review attention without opening Stackchain.", }, "after": { "state": "complete", - "detail": "The app icon shows the confirmed unread Updates count.", + "detail": "The app icon shows review attention.", }, "checked": True, "values": [4], @@ -142,8 +171,8 @@ def test_device_setup_offers_app_badge_as_an_optional_supported_step(): assert 'id="device-setup-app-badge" type="button"' in index assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup assert "appBadge:options.appBadgeReadiness()," in setup - assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard - assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard + assert "appBadgeReadiness:() => appBadge.readiness()," in dashboard + assert "enableAppBadge:() => appBadge.enable()," in dashboard def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh(): @@ -156,7 +185,7 @@ const controller = createMobileAppBadge({ storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}}, }); controller.start(); -const reconciled = await controller.reconcile(3, {authoritative:true}); +const reconciled = await controller.reconcile('updates', 3, true); console.log(JSON.stringify({reconciled, status:status.textContent})); """) assert result == { @@ -210,3 +239,51 @@ console.log(JSON.stringify(messages)); {"type": "stackchain-app-badge-preference", "enabled": True}, {"type": "stackchain-app-badge-preference", "enabled": False}, ] + + +def test_app_badge_synchronizes_authoritative_channel_counts_with_worker(): + result = run_badge(""" +const messages = []; +const controller = createMobileAppBadge({ + control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false}, + navigator:{async setAppBadge() {}, async clearAppBadge() {}}, + storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}}, + serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})}, +}); +controller.start(); +await controller.reconcile('updates', 3, true); +await controller.reconcile('following', 2, true); +await controller.reconcile('following', 9, false); +await new Promise(resolve => setImmediate(resolve)); +console.log(JSON.stringify(messages)); +""") + assert result == [ + {"type": "stackchain-app-badge-preference", "enabled": True}, + {"type": "stackchain-app-badge-count", "channel": "updates", "count": 3}, + {"type": "stackchain-app-badge-count", "channel": "following", "count": 2}, + ] + + +def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out(): + 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(); +await controller.reconcile('updates', 3, true); +await controller.reconcile('following', 2, true); +control.checked = true; +await listeners.change(); +console.log(JSON.stringify(messages)); +""") + assert result == [ + {"type": "stackchain-app-badge-preference", "enabled": True}, + {"type": "stackchain-app-badge-count", "channel": "updates", "count": 3}, + {"type": "stackchain-app-badge-count", "channel": "following", "count": 2}, + ] diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index ce503bd..f350150 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ 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-v136" in worker + assert "stackchain-dashboard-shell-v137" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 8143398..b0b5b65 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v136" in worker + assert "stackchain-dashboard-shell-v137" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index dee74a4..ae91f1d 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v136" in worker + assert "stackchain-dashboard-shell-v137" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index b63e2de..1d152f3 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -407,14 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert "mobileStartDay.finish()" in html assert "openQueue: name =>" in html assert "if (sheet.open) sheet.close();" in html - assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html + assert "queueCounts.followingUnavailable ? followingQueue.open()" in html assert "Checking Agenda, Attention, Updates, Filed, and Following" in html assert "mobileStartDay.render();" in html assert ".mobile-start-day-action { width:100%; min-height:48px;" in html assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v136" in service_worker + assert "stackchain-dashboard-shell-v137" in service_worker @pytest.mark.anyio @@ -427,9 +427,9 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html assert "qs('#mobile-queue-sheet').showModal();" in html assert "qs('#mobile-start-day-action').focus();" in html - assert "let mobilePreparationItems = {};" in html - assert "getPhaseItems: () => mobilePreparationItems" in html - assert "mobilePreparationItems = {" in html + assert "let preparationItems = {};" in html + assert "getPhaseItems: () => preparationItems" in html + assert "preparationItems = {" in html assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html assert "agenda:agendaMyWork(activeMyWork)" in html assert "attention:activeMyWork.filter(item => item.needs_attention)" in html diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index b511c76..a6a4ae8 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -835,7 +835,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher(): assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html assert "name === 'find' ? qs('#find-work').click() :" in html - assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html + assert "queueCounts.followingUnavailable ? followingQueue.open()" in html assert '.mobile-queue-sheet' in html assert '.my-work-actions { display:none;' in html assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html @@ -873,7 +873,7 @@ async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommen assert "queueLauncher: mobileQueueLauncher" in html assert "work: () => mobileWorkEntry.open()" in html assert "continueWork: () => mobileWorkEntry.open()" in html - assert "mobileQueueCounts = counts;" in html + assert "queueCounts = counts;" in html assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html @@ -895,7 +895,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock(): assert "createMobileTaskDock({" in html assert "work: () => mobileWorkEntry.open()" in html assert "name === 'find' ? qs('#find-work').click() :" in html - assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html + assert "queueCounts.followingUnavailable ? followingQueue.open()" in html assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html assert "find: () => qs('#find-work').click()" in html assert "new: () => qs('#new-issue').click()" in html diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index d99e354..d70f448 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index e0e41c8..3fca3e5 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: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, 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, badgeCounts: {{updates:0, following:0}}, 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', @@ -81,6 +81,9 @@ const context = {{ __STACKCHAIN_APP_BADGE_PREFERENCE: {{ get: async () => state.badgeEnabled, set: async enabled => {{ state.badgeEnabled = enabled; }}, + getCounts: async () => ({{...state.badgeCounts}}), + setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }}, + clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }}, }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, @@ -186,7 +189,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -194,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -216,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -225,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -233,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -248,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -256,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -282,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" 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-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/update-ownership.js'" in source @@ -999,6 +1002,55 @@ def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn assert len(result["notifications"]) == 4 +def test_background_badge_combines_following_and_updates_without_channel_overwrite(): + result = run_worker_scenario( + """ + await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'}); + await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2}); + await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3}); + await dispatchPush({tag:'stackchain-following-fedcba9876543210', route:'#/my-work/following', following_count:'invalid'}); + await dispatchPush({tag:'stackchain-following-1111111111111111', route:'#/my-work/following', following_count:1}); + await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:1, unread_count:3}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["appBadges"] == [2, 5, 4] + assert result["badgeCounts"] == {"updates": 3, "following": 1} + assert result["clearedAppBadges"] == 0 + + +def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts(): + result = run_worker_scenario( + """ + state.badgeEnabled = true; + await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:2}, [], {url:'https://forge.example/dashboard/'}); + await dispatchMessage({type:'stackchain-app-badge-count', channel:'updates', count:8}, [], {url:'https://evil.example/dashboard/'}); + await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:-1}, [], {url:'https://forge.example/dashboard/'}); + await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["badgeCounts"] == {"updates": 3, "following": 2} + assert result["appBadges"] == [5] + + +def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push(): + result = run_worker_scenario( + """ + state.badgeEnabled = true; + await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2}); + await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:0}, [], {url:'https://forge.example/dashboard/'}); + await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:2}); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["badgeCounts"] == {"updates": 2, "following": 0} + assert result["appBadges"] == [2, 2] + + def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off(): result = run_worker_scenario( """ @@ -1302,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index b43e3dc..a1e7bf8 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v136';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v137';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index c4698e0..23e2f7a 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v136" in source + assert "stackchain-dashboard-shell-v137" in source assert "BASE + 'static/today-sync.js'" in source