diff --git a/frontend/dashboard.css b/frontend/dashboard.css index bdfdae0..a91791e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; } .offline-status[hidden] { display:none; } .offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; } .offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; } +.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } +.app-badge-control { min-height:44px; } .push-update-control { min-height:44px; } .offline-work-controls input { width:20px; height:20px; } .offline-work-controls button { min-height:44px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 863a34a..749242a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3,6 +3,11 @@ 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({ + control:qs('#app-badge-control'), status:qs('#app-badge-status'), + container:qs('#app-badge-setting'), navigator, storage:localStorage, + }); + mobileAppBadge.start(); const cardPlanning = createCardPlanning(document); const mobileComposerViewport = createMobileComposerViewport({ viewport: window.visualViewport, @@ -1487,6 +1492,7 @@ }, onPagination: pagination => { notificationPagination = pagination; + mobileAppBadge.reconcile(pagination.total, {authoritative:true}); const loaded = Math.min(pagination.total, pagination.page * 50); qs('#notification-page-status').textContent = pagination.total ? loaded + ' of ' + pagination.total + ' unread updates loaded.' : ''; @@ -8292,6 +8298,8 @@ protectStorage:() => deviceStorage.requestPersistence(), notificationReadiness:() => pushController?.notificationReadiness() || {state:'unavailable', detail:'Update notifications are unavailable.'}, + appBadgeReadiness:() => mobileAppBadge.readiness(), + enableAppBadge:() => mobileAppBadge.enable(), enablePush:async () => { const controller = await pushControllerReady; if (!controller) return; diff --git a/frontend/index.html b/frontend/index.html index aabc1be..179e38b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -133,6 +133,10 @@
Notify me about new updates

+
  • +
    Show unread Updates on the app icon

    + +
  • Remind me about deadlines

    @@ -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",