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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 22:03:42 +02:00
parent cdcef2e106
commit ddeb69437a
5 changed files with 314 additions and 1 deletions
+1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script> <script src="/appearance-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" /> <meta property="og:site_name" content="Kapteins Daagbok" />
+221
View File
@@ -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 =
'<div class="auth-screen">' +
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
(isOffline
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
'</p>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
(isOffline
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
'</p>' +
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
'Neu laden' +
'</button>' +
(!isOffline
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
'App-Reparatur (Cache + Service Worker)' +
'</button>'
: '') +
'</div>' +
'</div>'
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()
})()
+16
View File
@@ -7,6 +7,7 @@ import './App.css'
import './i18n' import './i18n'
import App from './App.tsx' import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts' import { applyAppearanceToDocument } from './services/appearance.ts'
import { flushPendingPwaBootEvents } from './services/analytics.ts'
import { import {
installStaleAssetRecovery, installStaleAssetRecovery,
markReloadAttempt, markReloadAttempt,
@@ -14,6 +15,15 @@ import {
} from './services/pwaStartup.ts' } from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.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. */ /** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> { async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
applyAppearanceToDocument() applyAppearanceToDocument()
installStaleAssetRecovery() installStaleAssetRecovery()
flushPendingPwaBootEvents()
window.addEventListener('load', () => {
flushPendingPwaBootEvents()
}, { once: true })
await clearDevServiceWorkerCaches() await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup() const startupResult = await reconcileVersionOnStartup()
@@ -69,6 +83,7 @@ async function bootstrap(): Promise<void> {
<App /> <App />
</StrictMode>, </StrictMode>,
) )
window.__KDB_APP_BOOTSTRAPPED = true
} }
void bootstrap().catch((err) => { void bootstrap().catch((err) => {
@@ -76,4 +91,5 @@ void bootstrap().catch((err) => {
renderBootstrapError( renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.', 'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
) )
window.__KDB_APP_BOOTSTRAPPED = true
}) })
+61 -1
View File
@@ -41,7 +41,11 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', 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 } as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */ /** 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 PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean> export type PlausibleEventProps = Record<string, string | number | boolean>
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 { export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return if (typeof window.plausible !== 'function') return
@@ -59,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
} }
window.plausible(name) 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
}
+15
View File
@@ -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`) | | 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 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) | | 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 ### 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. 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 ## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen. - **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) 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 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) 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 ## Entwicklung