Merge pull request 'Show unread Updates on the installed app icon' (#1279) from timmy/1278-unread-updates-app-icon-badge into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m39s
CI / release-candidate (push) Successful in 8s

This commit is contained in:
rockachopa 2026-08-22 21:04:51 +00:00
commit b18a50fec5
9 changed files with 306 additions and 2 deletions

View File

@ -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; }

View File

@ -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;

View File

@ -133,6 +133,10 @@
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
<li class="device-setup-step" id="device-setup-app-badge-step">
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
<div class="device-setup-deadline-controls">
@ -210,6 +214,10 @@
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<span class="app-badge-setting" id="app-badge-setting">
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
</span>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
@ -2173,6 +2181,7 @@
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/mobile-app-badge.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>

View File

@ -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};
});

View File

@ -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,
});
}

View File

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

View File

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

View File

@ -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 '<script src="static/mobile-app-badge.js"></script>' 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.",
}

View File

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