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:
@@ -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" />
|
||||||
|
|||||||
Vendored
+221
@@ -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()
|
||||||
|
})()
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user