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 =
+ '
' +
+ '
' +
+ '
Kapteins Daagbok
' +
+ '
' +
+ (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.') +
+ '
' +
+ '
' +
+ (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.') +
+ '
' +
+ '' +
+ (!isOffline
+ ? ''
+ : '') +
+ '
' +
+ '
'
+
+ 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