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-v138'; 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; const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000; const TODAY_ACTION_TTL_MS = 2 * 60 * 1000; const PRIVATE_DATABASES = self.stackchainPrivateDatabases; function createAppBadgePreference() { const dbName = 'stackchain-app-badge-preference-v1'; const storeName = 'preferences'; const open = () => new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => request.result.createObjectStore(storeName); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('App badge preference unavailable.')); }); return { async get() { const database = await open(); const enabled = await new Promise((resolve, reject) => { const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled'); request.onsuccess = () => resolve(request.result === true); request.onerror = () => reject(request.error || new Error('App badge preference unavailable.')); }); database.close(); return enabled; }, async set(enabled) { const database = await open(); await new Promise((resolve, reject) => { const transaction = database.transaction(storeName, 'readwrite'); transaction.objectStore(storeName).put(enabled === true, 'enabled'); transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.')); transaction.onabort = transaction.onerror; }); 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(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) return false; try { 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 = total; return true; } catch (_error) { return false; } } function createTodayActionStore() { const dbName = 'stackchain-today-action-mailbox-v1'; const storeName = 'commands'; const open = () => new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'}); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.')); }); return { async put(clientId, command) { const database = await open(); await new Promise((resolve, reject) => { const transaction = database.transaction(storeName, 'readwrite'); transaction.objectStore(storeName).put({...command, clientId}); transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.')); transaction.onabort = transaction.onerror; }); database.close(); }, async claim(clientId, now) { const database = await open(); let command = null; await new Promise((resolve, reject) => { const transaction = database.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); const request = store.get(clientId); request.onsuccess = () => { command = request.result || null; if (command) store.delete(clientId); }; transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.')); transaction.onabort = transaction.onerror; }); database.close(); return command && command.expiresAt > now ? command : null; }, async purge() { const database = await open(); await new Promise((resolve, reject) => { const transaction = database.transaction(storeName, 'readwrite'); transaction.objectStore(storeName).clear(); transaction.oncomplete = resolve; transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.')); transaction.onabort = transaction.onerror; }); database.close(); }, }; } const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore(); const SHELL = [ BASE, BASE + 'manifest.webmanifest', BASE + 'static/dashboard.css', BASE + 'static/dashboard.js', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', BASE + 'static/sign-out-review.js', BASE + 'static/feature-loader.js', BASE + 'static/workspace-bootstrap.js', BASE + 'static/conversation-action-hydrator.js', BASE + 'static/security-center.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/saved-searches.js', BASE + 'static/search-preview.js', BASE + 'static/following.js', BASE + 'static/search-reply-draft-store.js', BASE + 'static/conversation-reply-draft-store.js', BASE + 'static/conversation-photo-drafts.js', BASE + 'static/search-defer.js', BASE + 'static/widgets.js', BASE + 'static/drafts.js', BASE + 'static/unfiled-captures.js', BASE + 'static/unfiled-draft-sync.js', BASE + 'static/shared-image-capture.js', BASE + 'static/draft-filing-session.js', BASE + 'static/draft-capacity-dialog.js', BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', BASE + 'static/reconnect-outboxes.js', BASE + 'static/authored-outbox.js', BASE + 'static/offline-issue-close.js', BASE + 'static/offline-issue-blocker.js', BASE + 'static/notification-read-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', BASE + 'static/agenda-replan.js', BASE + 'static/agenda-calendar.js', BASE + 'static/protect-today.js', BASE + 'static/notification-undo.js', BASE + 'static/card-planning.js', BASE + 'static/work-selection.js', BASE + 'static/today-work.js', BASE + 'static/today-timer.js', BASE + 'static/today-break.js', BASE + 'static/today-progress.js', BASE + 'static/today-lock-screen.js', BASE + 'static/today-session-sync.js', BASE + 'static/today-recap.js', BASE + 'static/today-wrap-up.js', BASE + 'static/today-summary.js', BASE + 'static/today-handoff.js', BASE + 'static/today-completion.js', BASE + 'static/today-readiness.js', BASE + 'static/comment-next.js', BASE + 'static/update-reply-read-next.js', BASE + 'static/plan-today.js', BASE + 'static/plan-today-readiness.js', BASE + 'static/plan-today-preview.js', BASE + 'static/tomorrow-plan.js', BASE + 'static/week-calendar.js', BASE + 'static/week-calendar-import.js', BASE + 'static/week-plan.js', BASE + 'static/today-week-reschedule.js', BASE + 'static/search-week-plan.js', BASE + 'static/today-sync.js', BASE + 'static/today-rollover.js', BASE + 'static/update-ownership.js', BASE + 'static/update-follow-up.js', BASE + 'static/later-work.js', BASE + 'static/later-sync.js', BASE + 'static/later-and-start.js', BASE + 'static/detail-defer.js', BASE + 'static/later-picker.js', BASE + 'static/pick-work.js', BASE + 'static/batch-find-work.js', BASE + 'static/search-batch-plan.js', BASE + 'static/conversation.js', BASE + 'static/comment-actions.js', BASE + 'static/issue-evidence-review.js', BASE + 'static/issue-evidence-editor.js', BASE + 'static/issue-attachment.js', BASE + 'static/issue-filing-review.js', BASE + 'static/issue-sheet.js', BASE + 'static/mobile-issue-detail-nav.js', BASE + 'static/mobile-update-detail-nav.js', BASE + 'static/mobile-review-detail-nav.js', BASE + 'static/mobile-search-preview-nav.js', BASE + 'static/mobile-plan-today-nav.js', BASE + 'static/mobile-find-work-nav.js', BASE + 'static/mobile-pull-refresh.js', BASE + 'static/checklist-conflict.js', BASE + 'static/voice-transcript-store.js', BASE + 'static/voice-issue-capture.js', BASE + 'static/voice-conversation-capture.js', BASE + 'static/create-issue-sheet.js', BASE + 'static/mobile-create-issue-nav.js', BASE + 'static/create-and-start.js', BASE + 'static/assign-and-start.js', BASE + 'static/filed-claim.js', BASE + 'static/queue-today.js', BASE + 'static/pull-sheet.js', BASE + 'static/review-sheet.js', BASE + 'static/release-receipt.js', BASE + 'static/work-route.js', BASE + 'static/task-overlay-history.js', BASE + 'static/context-poller.js', BASE + 'static/live-data-status.js', BASE + 'static/mobile-today-command-bar.js', BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-first-task.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', BASE + 'static/mobile-delivery-recovery.js', BASE + 'static/mobile-start-day.js', BASE + 'static/update-triage-session.js', BASE + 'static/update-review-handoff.js', BASE + 'static/update-read-position.js', BASE + 'static/work-detail-position.js', BASE + 'static/update-triage-launcher.js', BASE + 'static/update-triage-gesture.js', BASE + 'static/update-decision-transaction.js', BASE + 'static/agenda-session-launcher.js', 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', BASE + 'static/device-storage.js', BASE + 'static/mobile-device-setup.js', BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', BASE + 'static/mention-composer.js', BASE + 'static/push-notifications.js', BASE + 'static/issue-filing-receipt.js', BASE + 'static/background-issue-sync.js', ]; const OPTIONAL_FEATURES = [ ]; const SHARED_IMAGE_ID = 'shared-image'; const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024; const SHARE_TARGET_PATH = BASE + 'share-target'; const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore(); function boundedShareField(form, name, limit) { const value = form.get(name); return typeof value === 'string' ? value.trim().slice(0, limit) : ''; } function sharedContentRedirect(marker) { const target = new URL(BASE, self.location.origin); target.searchParams.set('launch', 'new'); if (marker) target.searchParams.set('shared', marker); return new Response(null, {status:303, headers:{Location:target.pathname + target.search}}); } async function acceptSharedContent(request) { if (request.headers.get('Sec-Fetch-Site') === 'cross-site') { return new Response('Cross-site share submissions are not accepted.', { status:403, headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}, }); } const form = await request.formData(); const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0); const content = { title:boundedShareField(form, 'title', 255), text:boundedShareField(form, 'text', 10000), url:boundedShareField(form, 'url', 2048), }; if (images.length > 5) return sharedContentRedirect('multiple'); const supported = images.every(image => SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES ); const totalBytes = images.reduce((total, image) => total + image.size, 0); if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported'); if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported'); const attachments = images.map(image => ({ filename:String(image.name || 'shared-screenshot').slice(0, 255), contentType:String(image.type), blob:image, })); try { await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments}); } catch (_error) { return sharedContentRedirect('unavailable'); } return sharedContentRedirect('bundle'); } async function fetchNavigation(request) { const controller = new AbortController(); let timeout; const deadline = new Promise((resolve, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new Error('Dashboard navigation timed out.')); }, NAVIGATION_TIMEOUT_MS); }); try { return await Promise.race([ fetch(request, { signal: controller.signal }), deadline, ]); } finally { clearTimeout(timeout); } } async function sessionCsrf(signal) { const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), { headers: { Accept: 'application/json' }, signal, }); if (!response.ok) return ''; const payload = await response.json().catch(() => ({})); return typeof payload.csrf_token === 'string' ? payload.csrf_token : ''; } let batchedCsrf = null; let batchedCsrfController = null; async function withSessionCsrf(work) { batchedCsrfController = new AbortController(); batchedCsrf = sessionCsrf(batchedCsrfController.signal); try { return await work(); } finally { batchedCsrfController.abort(); batchedCsrf = null; batchedCsrfController = null; } } async function deletePrivateDatabase(name) { if (!indexedDB) return; await new Promise((resolve, reject) => { let request; try { request = indexedDB.deleteDatabase(name); } catch (error) { reject(error); return; } request.onsuccess = () => resolve(); request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.')); request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.')); }); } async function deletePrivateDatabases() { for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name); } async function purgeRevokedSessionData() { await issueSync.purge(); await deletePrivateDatabases(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) ); const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' })); } async function notifyIdleSession() { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' })); } async function storeOfflineLease(expiresAt, idleExpiresAt) { if ( !Number.isInteger(expiresAt) || expiresAt <= 0 || !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0 ) return; const cache = await caches.open(CACHE); await cache.put(OFFLINE_LEASE_URL, new Response( JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, )); } async function offlineLeaseState(cache) { const response = await cache.match(OFFLINE_LEASE_URL); const payload = await response?.json?.().catch(() => ({})) || {}; const current = Math.floor(Date.now() / 1000); if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired'; if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle'; return 'valid'; } async function expiredOfflineResponse() { await issueSync.purge(); await deletePrivateDatabases(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) ); const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' })); return new Response( 'Your Stackchain session expired. Reconnect and sign in.', { status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } }, ); } async function idleOfflineResponse() { await notifyIdleSession(); return new Response( 'Your Stackchain session is locked. Reconnect and sign in.', { status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } }, ); } async function cachedShellWithValidLease(cache) { const state = await offlineLeaseState(cache); if (state === 'expired') return expiredOfflineResponse(); if (state === 'idle') return idleOfflineResponse(); return cache.match(BASE); } async function fetchJson(url, options = {}) { const requestOptions = { ...options }; const method = String(options.method || 'GET').toUpperCase(); if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { const headers = new Headers(options.headers || {}); const cancelBatchedCsrf = () => batchedCsrfController?.abort(); if (batchedCsrf && options.signal) { if (options.signal.aborted) cancelBatchedCsrf(); else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true }); } let csrf; try { csrf = await (batchedCsrf || sessionCsrf(options.signal)); } finally { options.signal?.removeEventListener('abort', cancelBatchedCsrf); } if (options.signal?.aborted) throw new Error('Background request aborted.'); if (csrf) headers.set('X-CSRF-Token', csrf); requestOptions.headers = headers; } const response = await fetch(new URL(url, self.location.origin), requestOptions); const payload = await response.json().catch(() => ({})); if (!response.ok) { const code = payload.code || payload.detail?.code; if (response.status === 401 && code === 'session_revoked') { await purgeRevokedSessionData(); } if (response.status === 401 && code === 'session_idle') { await notifyIdleSession(); } const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); error.status = response.status; error.code = code; throw error; } return payload; } const issueSync = self.__issueSync || createBackgroundIssueSync({ store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf, }); async function flushAndNotify() { const result = await issueSync.flush(); if (!result?.login || !result.receipts?.length || !await issueSync.getReceiptPreference?.(result.login)) return; for (const receipt of result.receipts) { const needsAttention = receipt.status === 'attention'; const needsAuthorization = receipt.status === 'authorization'; const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention ? 'Queued work needs attention' : receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent'; await self.registration.showNotification(title, { body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' : needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.', tag: 'stackchain-delivery-' + receipt.id, data: receipt.url ? { url: receipt.url } : { route: receipt.route }, }); } } self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); async function warmOptionalFeature(cache, staleCacheNames, asset) { for (const cacheName of staleCacheNames) { const staleCache = await caches.open(cacheName); const cached = await staleCache.match(asset); if (cached) { await cache.put(asset, cached); return; } } await cache.add(asset); } async function cachedOptionalFeature(request) { const cached = await caches.match(request); if (cached) return cached; const response = await fetch(request); if (response.ok) { const cache = await caches.open(CACHE); await cache.put(request, response.clone()); } return response; } self.addEventListener('activate', event => { event.waitUntil((async () => { const keys = await caches.keys(); const staleCacheNames = keys.filter( key => key.startsWith('stackchain-dashboard-') && key !== CACHE ); const cache = await caches.open(CACHE); await Promise.allSettled( OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset)) ); await Promise.all(staleCacheNames.map(key => caches.delete(key))); await self.clients.claim(); })()); }); self.addEventListener('sync', event => { if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); }); async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) { const tag = 'stackchain-today-session'; if (!active) { const notifications = await self.registration.getNotifications({ tag }); notifications.forEach(notification => notification.close()); return; } const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : ''; const now = Date.now(); const breakDeadline = Number(rawBreakDeadline); const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) && breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000; const title = onBreak ? 'On a Today break' : running ? 'Today session running' : 'Today session paused'; const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], { hour:'numeric', minute:'2-digit', }) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.'; await self.registration.showNotification(title, { body, tag, renotify:false, silent:true, actions: onBreak ? [ { action:'resume-today', title:'Resume now' }, { action:'open-today', title:'Open Today' }, ] : [ { action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' }, ...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []), ], data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) }, }); } 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; const enabled = event.data.enabled === true; try { 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. */ } })()); } if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => { await issueSync.resume(); await flushAndNotify(); })()); if (event.data?.type === 'stackchain-session-lease') { event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt)); } if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { try { await issueSync.purge(); await todayActionStore.purge(); await deletePrivateDatabases(); await updateTodayLockScreen(false, false); event.ports?.[0]?.postMessage({ ok: true }); } catch (error) { event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') }); } })()); if (event.data?.type === 'stackchain-today-lock-screen') { event.waitUntil((async () => { const active = event.data.active === true; if (!active) await todayActionStore.purge(); await updateTodayLockScreen( active, event.data.running === true, String(event.data.actionToken || ''), Number(event.data.breakDeadlineAt || 0) ); })()); } if (event.data?.type === 'stackchain-claim-today-action') { event.waitUntil((async () => { const source = event.source; if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return; let command; try { command = await todayActionStore.claim(source.id, Date.now()); } catch (_error) { return; } if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return; if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return; if (command.action === 'complete' && !command.actionToken) return; source.postMessage?.({ type:'stackchain-today-timer-action', action:command.action, ...(command.actionToken ? {actionToken:command.actionToken} : {}), }); })()); } }); self.addEventListener('push', event => { let payload; try { payload = event.data?.json?.() || {}; } catch (_error) { return; } const route = String(payload.route || ''); const protectRoute = String(payload.protect_route || ''); const tag = String(payload.tag || ''); const notificationId = Number(payload.notification_id); const updateCount = Number(payload.update_count); const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN; const deadlineCount = Number(payload.deadline_count); const followingCount = Number(payload.following_count); const planDate = String(payload.plan_date || ''); if ( route === '#/my-work/start-day' && /^\d{4}-\d{2}-\d{2}$/.test(planDate) && tag === 'stackchain-start-day-' + planDate ) { event.waitUntil(self.registration.showNotification('Your planned day is ready', { body: 'Open Stackchain to prepare Today.', tag, actions: [{ action: 'prepare-today', title: 'Prepare Today' }], data: {route}, })); return; } if ( route === '#/my-work/agenda' && protectRoute === '#/my-work/agenda/protect-today' && /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag) && Number.isSafeInteger(deadlineCount) && deadlineCount > 0 && deadlineCount <= 50 ) { event.waitUntil(self.registration.showNotification( deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention', { body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'), tag, actions: [ { action: 'protect-today', title: 'Protect Today' }, { action: 'snooze-deadline', title: 'Remind in 1 hour' }, ], data: {route, protectRoute}, } )); return; } if ( route === '#/my-work/following' && /^stackchain-following-[0-9a-f]{16}$/.test(tag) && Number.isSafeInteger(followingCount) && followingCount > 0 && followingCount <= 50 ) { 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 ( route === '#/my-work/updates' && tag === 'stackchain-update-digest' && Number.isSafeInteger(updateCount) && updateCount > 0 && updateCount <= 50 ) { event.waitUntil(Promise.all([ reconcileBackgroundAppBadge('updates', unreadCount), self.registration.showNotification(updateCount + ' new work updates', { body: 'Tap to review them in Stackchain.', tag, data: {route}, }), ])); return; } if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return; const options = { body: 'Tap to review it in Stackchain.', tag, data: {route}, }; if ( Number.isSafeInteger(notificationId) && notificationId > 0 && route === '#/my-work/update/' + notificationId && tag === 'stackchain-update-' + notificationId ) { options.actions = [ { action: 'mark-read', title: 'Mark read' }, { action: 'tomorrow', title: 'Tomorrow' }, ]; options.data.notificationId = notificationId; } event.waitUntil(Promise.all([ reconcileBackgroundAppBadge('updates', unreadCount), self.registration.showNotification('New work update', options), ])); }); async function openWorkRoute(route) { const target = new URL(BASE + route, self.location.origin).href; const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); if (!client) return self.clients.openWindow(target); if (client.navigate) await client.navigate(target); return client.focus(); } async function openCanonicalIssueUrl(rawUrl) { let target; try { target = new URL(rawUrl); } catch (_error) { return; } if ( target.origin !== self.location.origin || !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname) || target.search || target.hash ) return; const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); if (!client) return self.clients.openWindow(target.href); if (client.navigate) await client.navigate(target.href); return client.focus(); } async function applyTodayTimerAction(action, actionToken = '') { if (!['pause', 'resume', 'complete'].includes(action)) return; if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return; if (action === 'complete' && !actionToken) return; const route = '#/my-work/today'; const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true }); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); if (client) { client.postMessage?.({ type:'stackchain-today-timer-action', action, ...(actionToken ? { actionToken } : {}), }); return client.focus?.(); } const target = new URL(BASE + route, self.location.origin).href; const opened = await self.clients.openWindow(target); if (!opened?.id) return opened; try { await todayActionStore.put(opened.id, { action, ...(actionToken ? {actionToken} : {}), expiresAt:Date.now() + TODAY_ACTION_TTL_MS, }); } catch (_error) { /* The clean Today route remains safe; fail the action closed. */ } return opened; } self.addEventListener('notificationclick', event => { const route = String(event.notification.data?.route || ''); const issueUrl = String(event.notification.data?.url || ''); if ( event.notification.tag === 'stackchain-today-session' && route === '#/my-work/today' && ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action) ) { event.notification.close(); if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) { const action = event.action === 'pause-today' ? 'pause' : event.action === 'resume-today' ? 'resume' : 'complete'; event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || ''))); } else { event.waitUntil(openWorkRoute(route)); } return; } if (issueUrl) { event.notification.close(); event.waitUntil(openCanonicalIssueUrl(issueUrl)); return; } if (event.action === 'protect-today') { const protectRoute = String(event.notification.data?.protectRoute || route); if ( protectRoute !== '#/my-work/agenda/protect-today' || !['#/my-work/agenda', protectRoute].includes(route) ) return; event.notification.close(); event.waitUntil(openWorkRoute(protectRoute)); return; } if (event.action === 'snooze-deadline') { if ( route !== '#/my-work/agenda' || !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag) ) return; event.waitUntil((async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS); try { await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', { method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal, }); event.notification.close(); } catch (_error) { await openWorkRoute(route); event.notification.close(); } finally { clearTimeout(timeout); } })()); return; } if (event.action === 'tomorrow') { const notificationId = Number(event.notification.data?.notificationId); if ( !Number.isSafeInteger(notificationId) || notificationId <= 0 || route !== '#/my-work/update/' + notificationId || event.notification.tag !== 'stackchain-update-' + notificationId ) return; event.waitUntil((async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS); try { const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now()); wake.setDate(wake.getDate() + 1); wake.setHours(9, 0, 0, 0); await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', { method: 'PATCH', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ wake_at: wake.toISOString() }), signal: controller.signal, }); event.notification.close(); } catch (_error) { await openWorkRoute(route); event.notification.close(); } finally { clearTimeout(timeout); } })()); return; } if (event.action === 'mark-read') { const notificationId = Number(event.notification.data?.notificationId); if ( !Number.isSafeInteger(notificationId) || notificationId <= 0 || route !== '#/my-work/update/' + notificationId ) return; event.waitUntil((async () => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS); try { await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', { method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal, }); event.notification.close(); } catch (_error) { await openWorkRoute(route); event.notification.close(); } finally { clearTimeout(timeout); } })()); return; } event.notification.close(); if (!route.startsWith('#/my-work/')) return; event.waitUntil(openWorkRoute(route)); }); self.addEventListener('fetch', event => { const request = event.request; const requestUrl = new URL(request.url); if ( request.method === 'POST' && request.mode === 'navigate' && requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH ) { event.respondWith(acceptSharedContent(request)); return; } if (request.method !== 'GET' || request.url.includes('/api/')) return; const url = new URL(request.url); if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; if (request.mode === 'navigate') { event.respondWith( fetchNavigation(request).then(async response => { const cache = await caches.open(CACHE); const responseUrl = new URL(response.url || request.url); const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE; if (response.ok && !response.redirected && isDashboardShell) { await cache.put(BASE, response.clone()); } else if (OUTAGE_STATUSES.has(response.status)) { const cached = await cachedShellWithValidLease(cache); if (cached) return cached; } return response; }).catch(async () => { const cache = await caches.open(CACHE); const cached = await cachedShellWithValidLease(cache); return cached || new Response( 'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.', { status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, ); }) ); return; } if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { event.respondWith(caches.match(request).then(cached => cached || fetch(request))); return; } if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) { event.respondWith(cachedOptionalFeature(request)); } });