From ddeb69437ac7037210b0eb45b721f7cffb2ebf44 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 22:03:42 +0200 Subject: [PATCH] fix(pwa): harden startup recovery and track watchdog events Adds a production boot watchdog to self-heal white/black-screen startup stalls without clearing IndexedDB data offline, reducing failure loops after frequent deploys. Also records watchdog recovery paths in Plausible and documents the new events/properties for monitoring stability. Co-authored-by: Cursor --- client/index.html | 1 + client/public/bootstrap-watchdog.js | 221 ++++++++++++++++++++++++++++ client/src/main.tsx | 16 ++ client/src/services/analytics.ts | 62 +++++++- docs/plausible-events.md | 15 ++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 client/public/bootstrap-watchdog.js diff --git a/client/index.html b/client/index.html index ed14362..9634613 100644 --- a/client/index.html +++ b/client/index.html @@ -22,6 +22,7 @@ + diff --git a/client/public/bootstrap-watchdog.js b/client/public/bootstrap-watchdog.js new file mode 100644 index 0000000..84b302d --- /dev/null +++ b/client/public/bootstrap-watchdog.js @@ -0,0 +1,221 @@ +/** + * Boot watchdog for production PWAs. + * Recovers from white/black screens when stale HTML points to missing JS chunks. + * Does not clear caches automatically while offline to protect unsynced data. + */ +(function () { + if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { + return + } + + var BOOT_TIMEOUT_MS = 12000 + var ATTEMPT_WINDOW_MS = 120000 + var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count' + var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts' + var PENDING_EVENTS_KEY = 'pwa_boot_pending_events' + var MAX_PENDING_EVENTS = 12 + + function enqueueEvent(name, props) { + try { + var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]') + if (!Array.isArray(current)) current = [] + current.push({ name: name, props: props, ts: Date.now() }) + if (current.length > MAX_PENDING_EVENTS) { + current = current.slice(current.length - MAX_PENDING_EVENTS) + } + sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current)) + } catch (_) { + /* ignore analytics queue errors */ + } + } + + function emit(name, props) { + if (typeof window.plausible === 'function') { + if (props && Object.keys(props).length > 0) { + window.plausible(name, { props: props }) + } else { + window.plausible(name) + } + return + } + enqueueEvent(name, props) + } + + function hasBootstrapped() { + return window.__KDB_APP_BOOTSTRAPPED === true + } + + function resetAttempts() { + try { + sessionStorage.removeItem(ATTEMPT_COUNT_KEY) + sessionStorage.removeItem(ATTEMPT_LAST_KEY) + } catch (_) { + /* ignore storage errors */ + } + } + + function nextAttempt() { + try { + var now = Date.now() + var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0') + var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0') + if (now - last > ATTEMPT_WINDOW_MS) { + count = 0 + } + count += 1 + sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count)) + sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now)) + return count + } catch (_) { + return 1 + } + } + + function createRecoveryUrl(reason) { + try { + var url = new URL(location.href) + url.searchParams.set('boot_recover', reason) + url.searchParams.set('_', String(Date.now())) + return url.toString() + } catch (_) { + return location.href + } + } + + async function clearServiceWorkerCaches() { + if ('serviceWorker' in navigator) { + try { + var registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all( + registrations.map(function (registration) { + return registration.unregister() + }) + ) + } catch (_) { + /* ignore SW cleanup errors */ + } + } + if ('caches' in window) { + try { + var keys = await caches.keys() + await Promise.all( + keys.map(function (key) { + return caches.delete(key) + }) + ) + } catch (_) { + /* ignore cache cleanup errors */ + } + } + } + + function renderFallback(isOffline) { + var root = document.getElementById('root') + if (!root) return + + root.innerHTML = + '
' + + '' + + '
' + + var reloadBtn = document.getElementById('boot-reload-btn') + if (reloadBtn) { + reloadBtn.addEventListener('click', function () { + location.replace(createRecoveryUrl('retry')) + }) + } + + var repairBtn = document.getElementById('boot-repair-btn') + if (repairBtn) { + repairBtn.addEventListener('click', function () { + emit('PWA Boot Watchdog Manual Repair', { + attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'), + online: navigator.onLine + }) + Promise.resolve() + .then(clearServiceWorkerCaches) + .finally(function () { + resetAttempts() + location.replace(createRecoveryUrl('manual-hard-recovery')) + }) + }) + } + } + + function runWatchdog() { + window.setTimeout(function () { + if (hasBootstrapped()) { + resetAttempts() + return + } + + var attempt = nextAttempt() + var online = navigator.onLine + + if (attempt === 1) { + emit('PWA Boot Watchdog Soft', { + attempt: attempt, + online: online, + reason: online ? 'soft-reload' : 'offline-retry' + }) + Promise.resolve() + .then(function () { + if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) { + return navigator.serviceWorker.getRegistration().then(function (registration) { + if (registration) { + return registration.update().catch(function () {}) + } + }) + } + }) + .finally(function () { + location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry')) + }) + return + } + + if (attempt === 2 && online) { + emit('PWA Boot Watchdog Hard', { + attempt: attempt, + online: online, + reason: 'hard-recovery' + }) + Promise.resolve() + .then(clearServiceWorkerCaches) + .finally(function () { + location.replace(createRecoveryUrl('hard-recovery')) + }) + return + } + + emit('PWA Boot Watchdog Fallback', { + attempt: attempt, + online: online, + reason: online ? 'retries-exhausted' : 'offline-retries-exhausted' + }) + renderFallback(!online) + }, BOOT_TIMEOUT_MS) + } + + runWatchdog() +})() diff --git a/client/src/main.tsx b/client/src/main.tsx index af3d54a..303a19d 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -7,6 +7,7 @@ import './App.css' import './i18n' import App from './App.tsx' import { applyAppearanceToDocument } from './services/appearance.ts' +import { flushPendingPwaBootEvents } from './services/analytics.ts' import { installStaleAssetRecovery, markReloadAttempt, @@ -14,6 +15,15 @@ import { } from './services/pwaStartup.ts' import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts' +declare global { + interface Window { + __KDB_MAIN_MODULE_LOADED?: boolean + __KDB_APP_BOOTSTRAPPED?: boolean + } +} + +window.__KDB_MAIN_MODULE_LOADED = true + /** Stale PWA precache on localhost can shadow Vite dev modules. */ async function clearDevServiceWorkerCaches(): Promise { if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return @@ -47,6 +57,10 @@ async function bootstrap(): Promise { applyAppearanceToDocument() installStaleAssetRecovery() + flushPendingPwaBootEvents() + window.addEventListener('load', () => { + flushPendingPwaBootEvents() + }, { once: true }) await clearDevServiceWorkerCaches() const startupResult = await reconcileVersionOnStartup() @@ -69,6 +83,7 @@ async function bootstrap(): Promise { , ) + window.__KDB_APP_BOOTSTRAPPED = true } void bootstrap().catch((err) => { @@ -76,4 +91,5 @@ void bootstrap().catch((err) => { renderBootstrapError( 'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.', ) + window.__KDB_APP_BOOTSTRAPPED = true }) diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index 4464b60..a59f6fb 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -41,7 +41,11 @@ export const PlausibleEvents = { LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', - OWM_WEATHER_FETCHED: 'OWM Weather Fetched' + OWM_WEATHER_FETCHED: 'OWM Weather Fetched', + PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', + PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard', + PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback', + PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair' } as const /** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */ @@ -50,6 +54,13 @@ export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventProps = Record +type PendingPwaBootEvent = { + name: PlausibleEventName + props?: PlausibleEventProps + ts?: number +} + +const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events' export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void { if (typeof window.plausible !== 'function') return @@ -59,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE } window.plausible(name) } + +export function flushPendingPwaBootEvents(): number { + if (typeof window.plausible !== 'function') return 0 + + let raw: string | null = null + try { + raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY) + } catch { + return 0 + } + if (!raw) return 0 + + let pending: PendingPwaBootEvent[] + try { + pending = JSON.parse(raw) as PendingPwaBootEvent[] + } catch { + try { + sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY) + } catch { + /* ignore storage errors */ + } + return 0 + } + + if (!Array.isArray(pending) || pending.length === 0) { + try { + sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY) + } catch { + /* ignore storage errors */ + } + return 0 + } + + for (const event of pending) { + if (!event || typeof event.name !== 'string') continue + if (event.props && Object.keys(event.props).length > 0) { + window.plausible(event.name, { props: event.props }) + } else { + window.plausible(event.name) + } + } + + try { + sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY) + } catch { + /* ignore storage errors */ + } + return pending.length +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index de4ce82..bc00edc 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -56,6 +56,10 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) | | Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — | | Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) | +| PWA Boot Watchdog Soft | Watchdog erkennt Start-Haenger und versucht sanfte Selbstheilung (`bootstrap-watchdog.js`) | `attempt` (Anzahl Wiederherstellungsversuche), `online` (`true`\|`false`), `reason`: `soft-reload` \| `offline-retry` | +| PWA Boot Watchdog Hard | Watchdog startet harte PWA-Reparatur (SW/Cache) nach erneutem Start-Haenger (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `hard-recovery` | +| PWA Boot Watchdog Fallback | Watchdog zeigt Recovery-UI nach ausgeschoepften Auto-Recovery-Versuchen (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `retries-exhausted` \| `offline-retries-exhausted` | +| PWA Boot Watchdog Manual Repair | Nutzer startet manuelle App-Reparatur im Fallback-Dialog (`bootstrap-watchdog.js`) | `attempt`, `online` | ### Live-Log-Aktionen @@ -94,6 +98,16 @@ Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem AP Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus. +### PWA-Boot-Watchdog-Properties + +Properties bei **PWA Boot Watchdog Soft / Hard / Fallback / Manual Repair**: + +| Property | Typ | Bedeutung | Erlaubte Werte | +|----------|-----|-----------|----------------| +| `attempt` | number | Laufende Nummer des Recovery-Versuchs im aktuellen Startfenster | `1`, `2`, `3`, ... | +| `online` | boolean | Netzwerkstatus beim Trigger | `true` \| `false` | +| `reason` | string | Grund/Recovery-Pfad (nur bei Soft/Hard/Fallback) | `soft-reload`, `offline-retry`, `hard-recovery`, `retries-exhausted`, `offline-retries-exhausted` | + ## Bewusst nicht getrackt - **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen. @@ -122,6 +136,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!): 9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch) 10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded 11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor) +12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair ## Entwicklung