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