Add Plausible analytics for live journal and NMEA upload.

Track Live Log Opened/Event Logged with action types, NMEA Uploaded on parse success, and align NMEA Imported properties with docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 21:43:30 +02:00
parent b7a1085d52
commit a36ca2facb
4 changed files with 77 additions and 24 deletions
+28 -20
View File
@@ -185,6 +185,12 @@ export default function LiveLogView({
return () => { cancelled = true } return () => { cancelled = true }
}, [logbookId, refreshEntry, t]) }, [logbookId, refreshEntry, t])
useEffect(() => {
if (!loading && entryId) {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
}
}, [loading, entryId])
useEffect(() => { useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length]) }, [events.length])
@@ -226,7 +232,7 @@ export default function LiveLogView({
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<void>, action: () => Promise<void>,
trackEvent?: string, trackAction?: string,
withUndo = true withUndo = true
) => { ) => {
if (!entryId || busy) return if (!entryId || busy) return
@@ -236,7 +242,9 @@ export default function LiveLogView({
await action() await action()
await refreshEntry(entryId) await refreshEntry(entryId)
if (withUndo) showUndo() if (withUndo) showUndo()
if (trackEvent) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED, { context: trackEvent }) if (trackAction) {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: trackAction })
}
} catch (err: unknown) { } catch (err: unknown) {
console.error('Live log action failed:', err) console.error('Live log action failed:', err)
setError(err instanceof Error ? err.message : t('logs.live_action_error')) setError(err instanceof Error ? err.message : t('logs.live_action_error'))
@@ -264,28 +272,28 @@ export default function LiveLogView({
const handleMotorToggle = () => { const handleMotorToggle = () => {
hapticPulse() hapticPulse()
const starting = !motorRunning
void runQuickAction(async () => { void runQuickAction(async () => {
if (!entryId) return if (!entryId) return
const starting = !motorRunning
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
sailsOrMotor: starting ? motorLabel : '', sailsOrMotor: starting ? motorLabel : '',
remarks: starting ? LIVE_EVENT_CODES.MOTOR_START : LIVE_EVENT_CODES.MOTOR_STOP remarks: starting ? LIVE_EVENT_CODES.MOTOR_START : LIVE_EVENT_CODES.MOTOR_STOP
}) })
}, 'live_motor') }, starting ? 'motor_start' : 'motor_stop')
} }
const handleCastOff = () => { const handleCastOff = () => {
void runQuickAction(async () => { void runQuickAction(async () => {
if (!entryId) return if (!entryId) return
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.CAST_OFF }) await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.CAST_OFF })
}, 'live_cast_off') }, 'cast_off')
} }
const handleMoor = () => { const handleMoor = () => {
void runQuickAction(async () => { void runQuickAction(async () => {
if (!entryId) return if (!entryId) return
await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.MOOR }) await appendQuickEvent(logbookId, entryId, { remarks: LIVE_EVENT_CODES.MOOR })
}, 'live_moor') }, 'moor')
} }
const handleFix = () => { const handleFix = () => {
@@ -301,7 +309,7 @@ export default function LiveLogView({
} catch { } catch {
await showAlert(t('logs.live_gps_error'), t('logs.live_fix')) await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
} }
}, 'live_fix') }, 'fix')
} }
const handleUndo = () => { const handleUndo = () => {
@@ -313,7 +321,7 @@ export default function LiveLogView({
} }
void runQuickAction(async () => { void runQuickAction(async () => {
await removeLastEvent(logbookId, entryId) await removeLastEvent(logbookId, entryId)
}, 'live_undo', false) }, 'undo', false)
} }
const confirmSails = () => { const confirmSails = () => {
@@ -330,7 +338,7 @@ export default function LiveLogView({
sailsOrMotor: sailsLabel, sailsOrMotor: sailsLabel,
remarks: liveSailsRemark(sailsLabel) remarks: liveSailsRemark(sailsLabel)
}) })
}, 'live_sails') }, 'sails')
} }
const confirmComment = () => { const confirmComment = () => {
@@ -344,7 +352,7 @@ export default function LiveLogView({
void runQuickAction(async () => { void runQuickAction(async () => {
if (!entryId) return if (!entryId) return
await appendQuickEvent(logbookId, entryId, { remarks: liveCommentRemark(text) }) await appendQuickEvent(logbookId, entryId, { remarks: liveCommentRemark(text) })
}, 'live_comment') }, 'comment')
} }
const confirmValueModal = () => { const confirmValueModal = () => {
@@ -362,7 +370,7 @@ export default function LiveLogView({
windStrength: secondary, windStrength: secondary,
remarks: LIVE_EVENT_CODES.WIND remarks: LIVE_EVENT_CODES.WIND
}) })
}, 'live_wind') }, 'wind')
break break
case 'pressure': case 'pressure':
if (!primary) return if (!primary) return
@@ -372,21 +380,21 @@ export default function LiveLogView({
windPressure: primary, windPressure: primary,
remarks: LIVE_EVENT_CODES.PRESSURE remarks: LIVE_EVENT_CODES.PRESSURE
}) })
}, 'live_pressure') }, 'pressure')
break break
case 'temp': case 'temp':
if (!primary) return if (!primary) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { remarks: liveTempRemark(primary) }) await appendQuickEvent(logbookId, entryId, { remarks: liveTempRemark(primary) })
}, 'live_temp') }, 'temp')
break break
case 'precip': case 'precip':
if (!primary) return if (!primary) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { remarks: livePrecipRemark(primary) }) await appendQuickEvent(logbookId, entryId, { remarks: livePrecipRemark(primary) })
}, 'live_precip') }, 'precip')
break break
case 'sea_state': case 'sea_state':
if (!primary) return if (!primary) return
@@ -396,7 +404,7 @@ export default function LiveLogView({
seaState: primary, seaState: primary,
remarks: LIVE_EVENT_CODES.SEA_STATE remarks: LIVE_EVENT_CODES.SEA_STATE
}) })
}, 'live_sea_state') }, 'sea_state')
break break
case 'course': { case 'course': {
const course = primary || lastCourseFromEvents(events) const course = primary || lastCourseFromEvents(events)
@@ -407,7 +415,7 @@ export default function LiveLogView({
mgk: course, mgk: course,
remarks: LIVE_EVENT_CODES.COURSE remarks: LIVE_EVENT_CODES.COURSE
}) })
}, 'live_course') }, 'course')
break break
} }
case 'fuel': { case 'fuel': {
@@ -418,7 +426,7 @@ export default function LiveLogView({
await appendTankRefill(logbookId, entryId, 'fuel', liters, { await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters)) remarks: liveFuelRemark(String(liters))
}) })
}, 'live_fuel') }, 'fuel')
break break
} }
case 'water': { case 'water': {
@@ -429,7 +437,7 @@ export default function LiveLogView({
await appendTankRefill(logbookId, entryId, 'freshwater', liters, { await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters)) remarks: liveWaterRemark(String(liters))
}) })
}, 'live_water') }, 'water')
break break
} }
case 'sog': { case 'sog': {
@@ -440,7 +448,7 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveSogRemark(String(speedKn)) remarks: liveSogRemark(String(speedKn))
}) })
}, 'live_sog') }, 'sog')
break break
} }
case 'stw': { case 'stw': {
@@ -451,7 +459,7 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveStwRemark(String(speedKn)) remarks: liveStwRemark(String(speedKn))
}) })
}, 'live_stw') }, 'stw')
break break
} }
default: default:
+7 -1
View File
@@ -101,6 +101,12 @@ export default function NmeaImportWizard({
t t
}).candidates }).candidates
setSelectedIds(new Set(generated.map((c) => c.id))) setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse')) setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
} }
@@ -154,7 +160,7 @@ export default function NmeaImportWizard({
} }
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode, mode,
candidates: picked.length, events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0 track: importTrack && (waypoints?.length ?? 0) > 0
}) })
setStep('archive') setStep('archive')
+4 -1
View File
@@ -36,7 +36,10 @@ export const PlausibleEvents = {
DEVICE_FORGOTTEN: 'Device Forgotten', DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated', RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed', LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported' NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
} as const } as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+38 -2
View File
@@ -21,7 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — | | Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` | | Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — | | GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
| NMEA Imported | NMEA-Protokoll in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events`, `track` (Anzahlen/Flags, keine Koordinaten) | | NMEA Uploaded | NMEA-Datei erfolgreich gelesen und geparst (`NmeaImportWizard.tsx`) | `lines` (Anzahl Sätze), `candidates` (Vorschläge für Reisetag), `duplicate` (Datei schon importiert), `has_position` |
| NMEA Imported | NMEA-Vorschläge in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events` (übernommene Einträge), `track` (GPS-Track mit importiert) |
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — | | Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` | | Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — | | Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
@@ -51,6 +52,33 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — | | Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — | | Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
| 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 Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
### Live-Log-Aktionen
Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel, keine Inhalte (kein Kurs, kein Kommentartext, keine Koordinaten):
| `action` | Button / Auslöser |
|----------|-------------------|
| `motor_start` | Motor Start |
| `motor_stop` | Motor Stop |
| `cast_off` | Ablegen |
| `moor` | Anlegen |
| `sails` | Segel (Modal bestätigt) |
| `course` | Kurs (Dial/Modal bestätigt) |
| `sog` | SOG |
| `stw` | STW |
| `fuel` | Diesel-Tank |
| `water` | Wasser-Tank |
| `wind` | Wind (Richtung/Stärke) |
| `pressure` | Luftdruck |
| `temp` | Temperatur |
| `precip` | Niederschlag |
| `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) |
| `comment` | Kommentar |
| `undo` | Letztes Ereignis rückgängig |
## Bewusst nicht getrackt ## Bewusst nicht getrackt
@@ -59,6 +87,10 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties. - **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular). - **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button. - **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
- **Live-Log Auto-Position:** Hintergrund-GPS alle 3 h (`LIVE_EVENT_CODES.AUTO_POSITION`) — automatisch, best-effort, kein Nutzer-Tap.
- **Live-Log Modals:** Öffnen/Abbrechen von Dialogen ohne Speichern; Wechsel Liste ↔ Live (nur `Live Log Opened` beim erneuten Mount).
- **Live-Log Editor-Link:** Öffnen des vollständigen Editors aus der Live-Ansicht.
- **NMEA-Import:** Abbrechen, Vorschau ohne Übernahme, Archiv-Entscheid (Archivieren/Verwerfen); fehlgeschlagene Datei-Lesevorgänge.
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde. - **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
## Typische Funnels (Plausible Goals) ## Typische Funnels (Plausible Goals)
@@ -73,7 +105,8 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
6. **Datensicherung:** Backup Exported → Backup Restored 6. **Datensicherung:** Backup Exported → Backup Restored
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig) 7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback) 8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Imported (Modus, Anzahl übernommener Ereignisse, optional Track) 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`)
## Entwicklung ## Entwicklung
@@ -83,6 +116,9 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' }) trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' }) trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
``` ```
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen. Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.