feat: combine review attention app badge (Closes #1324)
All checks were successful
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m24s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 22:38:21 +00:00
parent 157703527a
commit 33c8761e80
18 changed files with 287 additions and 89 deletions

View File

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

View File

@ -215,7 +215,7 @@
<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>
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show review attention 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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},
]

View File

@ -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():

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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