@@ -210,6 +214,10 @@
+
+
+
+
@@ -2173,6 +2181,7 @@
+
diff --git a/frontend/mobile-app-badge.js b/frontend/mobile-app-badge.js
new file mode 100644
index 0000000..f650ab9
--- /dev/null
+++ b/frontend/mobile-app-badge.js
@@ -0,0 +1,90 @@
+(function(root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory;
+ else root.createMobileAppBadge = factory;
+})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
+ control,
+ status,
+ container,
+ navigator,
+ storage,
+}) {
+ const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
+ let enabled = false;
+ let confirmedCount = 0;
+ let renderedCount = null;
+
+ function available() {
+ return typeof navigator?.setAppBadge === 'function'
+ && typeof navigator?.clearAppBadge === 'function';
+ }
+
+ async function render() {
+ if (!enabled || !available() || renderedCount === confirmedCount) return true;
+ try {
+ if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
+ else await navigator.clearAppBadge();
+ renderedCount = confirmedCount;
+ if (status) status.textContent = 'App icon badge is on and up to date.';
+ return true;
+ } catch (_error) {
+ if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
+ return false;
+ }
+ }
+
+ async function change() {
+ enabled = Boolean(control?.checked);
+ if (enabled) storage?.setItem(ENABLED_KEY, 'true');
+ else storage?.removeItem(ENABLED_KEY);
+ if (!enabled && available()) {
+ try {
+ await navigator.clearAppBadge();
+ renderedCount = null;
+ if (status) status.textContent = 'App icon badge is off.';
+ return true;
+ } catch (_error) {
+ if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
+ return false;
+ }
+ }
+ return render();
+ }
+
+ function start() {
+ if (!available()) {
+ if (container) container.hidden = true;
+ if (control) control.disabled = true;
+ if (status) status.textContent = 'App icon badges are unavailable in this browser.';
+ return false;
+ }
+ enabled = storage?.getItem(ENABLED_KEY) === 'true';
+ if (control) {
+ control.checked = enabled;
+ control.addEventListener('change', change);
+ }
+ if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
+ return true;
+ }
+
+ 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.'};
+ }
+
+ async function enable() {
+ if (!available()) return false;
+ if (control) control.checked = true;
+ await change();
+ return true;
+ }
+
+ async function reconcile(count, {authoritative = false} = {}) {
+ if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
+ confirmedCount = count;
+ return render();
+ }
+
+ return {start, change, reconcile, readiness, enable};
+});
diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js
index fefdab8..b506467 100644
--- a/frontend/mobile-device-setup.js
+++ b/frontend/mobile-device-setup.js
@@ -9,8 +9,9 @@
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
+ ['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
- ];
+ ].filter(([_name, button, status, action]) => button && status && action);
let trigger = options.launcher;
let ownsDetour = false;
let backgroundInert = null;
@@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
+ appBadgeButton:qs('#device-setup-app-badge'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
+ appBadgeStatus:qs('#device-setup-app-badge-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
@@ -193,12 +196,14 @@ function mountMobileDeviceSetup(options) {
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
+ appBadge:options.appBadgeReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
+ enableAppBadge:options.enableAppBadge,
enableDeadline:options.enableDeadline,
});
}
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 3d49b4e..c867bdd 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -190,6 +190,7 @@ const SHELL = [
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-insights.js',
BASE + 'static/mobile-app-shortcuts.js',
+ BASE + 'static/mobile-app-badge.js',
BASE + 'static/install-app.js',
BASE + 'static/private-data-inventory.js',
BASE + 'static/private-device-data.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 7f8cc49..e325250 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -39,7 +39,7 @@ FEATURE_SOURCES = {
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
- "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
+ "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
diff --git a/tests/test_mobile_app_badge.py b/tests/test_mobile_app_badge.py
new file mode 100644
index 0000000..2a508f0
--- /dev/null
+++ b/tests/test_mobile_app_badge.py
@@ -0,0 +1,188 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+ROOT = Path(__file__).parents[1]
+APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
+INDEX = ROOT / "frontend" / "index.html"
+DASHBOARD = ROOT / "frontend" / "dashboard.js"
+CSS = ROOT / "frontend" / "dashboard.css"
+DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
+
+
+def run_badge(scenario: str) -> dict:
+ assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
+ script = f"""
+const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
+(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
+"""
+ completed = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+ return json.loads(completed.stdout)
+
+
+def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
+ result = run_badge("""
+const listeners = {};
+const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
+const status = {textContent:''};
+const container = {hidden:false};
+const values = [];
+let clears = 0;
+const storage = new Map();
+const controller = createMobileAppBadge({
+ control, status, container,
+ navigator: {
+ async setAppBadge(value) { values.push(value); },
+ async clearAppBadge() { clears++; },
+ },
+ storage: {
+ getItem(key) { return storage.has(key) ? storage.get(key) : null; },
+ setItem(key, value) { storage.set(key, value); },
+ removeItem(key) { storage.delete(key); },
+ },
+});
+controller.start();
+await controller.reconcile(7, {authoritative:true});
+await controller.reconcile(12, {authoritative:false});
+control.checked = true;
+await listeners.change();
+await controller.reconcile(7, {authoritative:true});
+await controller.reconcile(0, {authoritative:true});
+console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
+""")
+ assert result == {
+ "values": [7],
+ "clears": 1,
+ "saved": "true",
+ "status": "App icon badge is on and up to date.",
+ }
+
+
+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'); }};
+const status = {textContent:''};
+const container = {hidden:false};
+let reads = 0;
+const controller = createMobileAppBadge({
+ control, status, container, navigator:{},
+ storage:{getItem() { reads++; return 'true'; }},
+});
+const started = controller.start();
+console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
+""")
+ assert result == {
+ "started": False,
+ "hidden": True,
+ "disabled": True,
+ "reads": 0,
+ "status": "App icon badges are unavailable in this browser.",
+ }
+
+
+def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
+ index = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+ css = CSS.read_text()
+
+ assert '' in index
+ 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
+
+
+def test_app_badge_exposes_device_setup_readiness_and_enable_action():
+ result = run_badge("""
+const control = {checked:false, disabled:false, addEventListener() {}};
+const status = {textContent:''};
+const saved = new Map();
+const values = [];
+const controller = createMobileAppBadge({
+ control, status, container:{hidden:false},
+ navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
+ storage:{
+ getItem(key) { return saved.get(key) || null; },
+ setItem(key, value) { saved.set(key, value); },
+ removeItem(key) { saved.delete(key); },
+ },
+});
+controller.start();
+await controller.reconcile(4, {authoritative:true});
+const before = controller.readiness();
+await controller.enable();
+const after = controller.readiness();
+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.",
+ },
+ "after": {
+ "state": "complete",
+ "detail": "The app icon shows the confirmed unread Updates count.",
+ },
+ "checked": True,
+ "values": [4],
+ }
+
+
+def test_device_setup_offers_app_badge_as_an_optional_supported_step():
+ index = INDEX.read_text()
+ setup = DEVICE_SETUP.read_text()
+ dashboard = DASHBOARD.read_text()
+
+ assert 'id="device-setup-app-badge-status" role="status"' in index
+ 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
+
+
+def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
+ result = run_badge("""
+const control = {checked:true, disabled:false, addEventListener() {}};
+const status = {textContent:''};
+const controller = createMobileAppBadge({
+ control, status, container:{hidden:false},
+ navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
+ storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
+});
+controller.start();
+const reconciled = await controller.reconcile(3, {authoritative:true});
+console.log(JSON.stringify({reconciled, status:status.textContent}));
+""")
+ assert result == {
+ "reconciled": False,
+ "status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
+ }
+
+
+def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
+ result = run_badge("""
+const listeners = {};
+const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
+const status = {textContent:''};
+const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
+const controller = createMobileAppBadge({
+ control, status, container:{hidden:false},
+ navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
+ storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
+});
+controller.start();
+control.checked = false;
+const changed = await listeners.change();
+console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
+""")
+ assert result == {
+ "changed": False,
+ "saved": False,
+ "status": "App icon badge is off, but the browser could not clear the old count.",
+ }
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index c328506..6de5a72 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1402,6 +1402,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-insights.js",
"/dashboard/static/mobile-app-shortcuts.js",
+ "/dashboard/static/mobile-app-badge.js",
"/dashboard/static/install-app.js",
"/dashboard/static/private-data-inventory.js",
"/dashboard/static/private-device-data.js",