diff --git a/client/index.html b/client/index.html
index 0cb597d..d5f41aa 100644
--- a/client/index.html
+++ b/client/index.html
@@ -10,6 +10,7 @@
+
Kapteins Daagbok
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 3079e03..4af98fc 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -13,6 +13,7 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
+import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
import {
applyAppearanceToDocument,
resolveAppTheme,
@@ -153,6 +154,7 @@ function App() {
const handleAuthenticated = async () => {
setIsAuthenticated(true)
+ trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -214,6 +216,7 @@ function App() {
{
setIsAuthenticated(true)
+ trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
setIsAcceptingInvite(false)
selectLogbook(logbookId, title)
// Clean URL query parameters and hash anchor
diff --git a/client/src/components/CrewForm.tsx b/client/src/components/CrewForm.tsx
index 954cd13..3d33dc1 100644
--- a/client/src/components/CrewForm.tsx
+++ b/client/src/components/CrewForm.tsx
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -236,6 +237,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
})
setSkipperSuccess(true)
+ trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'skipper', action: 'update' })
setTimeout(() => setSkipperSuccess(false), 3000)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
@@ -337,6 +339,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
}
setShowMemberForm(false)
+ trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'crew', action: isNew ? 'create' : 'update' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save crew member:', err)
diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx
index e648408..b1416e6 100644
--- a/client/src/components/InvitationAcceptance.tsx
+++ b/client/src/components/InvitationAcceptance.tsx
@@ -12,6 +12,7 @@ import { decryptJson, encryptBuffer } from '../services/crypto.js'
import { saveLogbookKey } from '../services/logbookKeys.js'
import { syncLogbook } from '../services/sync.js'
import { db } from '../services/db.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface InvitationAcceptanceProps {
onAccepted: (logbookId: string, title: string) => void
@@ -194,6 +195,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
await syncLogbook(logbookId)
+ trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
onAccepted(logbookId, decryptedTitle)
} catch (err: any) {
console.error('Accepting invitation failed:', err)
diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx
index 882129c..90537c7 100644
--- a/client/src/components/LogEntriesList.tsx
+++ b/client/src/components/LogEntriesList.tsx
@@ -7,6 +7,7 @@ import { decryptJson, encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import { useDialog } from './ModalDialog.tsx'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
@@ -157,6 +158,7 @@ export default function LogEntriesList({
} else {
await downloadCsv(logbookId, title)
}
+ trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
@@ -175,6 +177,7 @@ export default function LogEntriesList({
} else {
await shareCsv(logbookId, title)
}
+ trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
} catch (err: any) {
if (err.message === 'share_unsupported') {
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
@@ -204,6 +207,7 @@ export default function LogEntriesList({
} else {
await downloadLogbookPagePdf(logbookId, entryId, date)
}
+ trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
@@ -291,6 +295,7 @@ export default function LogEntriesList({
// Open immediately in details editor
setSelectedEntryId(localId)
+ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx
index e94deea..931df14 100644
--- a/client/src/components/LogEntryEditor.tsx
+++ b/client/src/components/LogEntryEditor.tsx
@@ -23,6 +23,7 @@ import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -284,6 +285,7 @@ export default function LogEntryEditor({
setSignSkipper(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
+ trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
}
const handlePasskeySignCrew = async () => {
@@ -300,6 +302,7 @@ export default function LogEntryEditor({
setSignCrew(signature)
setEntryHash(hash)
lockedContentHashRef.current = hash
+ trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
}
// Auto-calculate Freshwater Consumption
@@ -465,6 +468,7 @@ export default function LogEntryEditor({
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
applyTrackStats(parsedWps)
await loadTrack()
+ trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
@@ -731,6 +735,7 @@ export default function LogEntryEditor({
setError(null)
try {
await downloadLogbookPagePdf(logbookId, entryId, date)
+ trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
@@ -782,6 +787,7 @@ export default function LogEntryEditor({
})
setSuccess(true)
+ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => {
setSuccess(false)
onBack()
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx
index 62851f3..3e61980 100644
--- a/client/src/components/LogbookDashboard.tsx
+++ b/client/src/components/LogbookDashboard.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
@@ -70,6 +71,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
const created = await createLogbook(newTitle.trim())
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
+ trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} finally {
diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx
index 28789e3..dfa80e6 100644
--- a/client/src/components/SettingsForm.tsx
+++ b/client/src/components/SettingsForm.tsx
@@ -8,6 +8,7 @@ import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -208,6 +209,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link)
+ trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
} catch (err: any) {
console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.')
diff --git a/client/src/components/VesselForm.tsx b/client/src/components/VesselForm.tsx
index 45ad134..88d9ef7 100644
--- a/client/src/components/VesselForm.tsx
+++ b/client/src/components/VesselForm.tsx
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
interface VesselFormProps {
@@ -251,6 +252,7 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
})
setSuccess(true)
+ trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED)
setTimeout(() => setSuccess(false), 3000)
// Trigger background sync task
diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx
index b108e5a..8bc05ce 100644
--- a/client/src/context/AppTourContext.tsx
+++ b/client/src/context/AppTourContext.tsx
@@ -14,6 +14,7 @@ import {
markTourCompleted
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
@@ -117,25 +118,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
setIsActive(true)
}, [])
- const finishTour = useCallback(() => {
+ const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
const userId = localStorage.getItem('active_userid')
if (userId) markTourCompleted(userId)
+ if (outcome === 'completed') {
+ trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED)
+ } else {
+ const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
+ trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
+ }
setIsActive(false)
setStepIndex(0)
}, [])
- const stopTour = finishTour
- const skipTour = finishTour
+ const stopTour = useCallback(() => {
+ dismissTour('completed', stepIndex)
+ }, [dismissTour, stepIndex])
+
+ const skipTour = useCallback(() => {
+ dismissTour('skipped', stepIndex)
+ }, [dismissTour, stepIndex])
const nextStep = useCallback(() => {
- setStepIndex((current) => {
- if (current + 1 >= STEP_ORDER.length) {
- finishTour()
- return current
- }
- return current + 1
- })
- }, [finishTour])
+ if (stepIndex + 1 >= STEP_ORDER.length) {
+ dismissTour('completed', stepIndex)
+ return
+ }
+ setStepIndex(stepIndex + 1)
+ }, [dismissTour, stepIndex])
const prevStep = useCallback(() => {
setStepIndex((current) => Math.max(0, current - 1))
diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts
new file mode 100644
index 0000000..4c25fb6
--- /dev/null
+++ b/client/src/services/analytics.ts
@@ -0,0 +1,33 @@
+export const PlausibleEvents = {
+ ACCOUNT_CREATED: 'Account Created',
+ LOGGED_IN: 'Logged In',
+ LOGBOOK_CREATED: 'Logbook Created',
+ TRAVEL_DAY_CREATED: 'Travel Day Created',
+ TRAVEL_DAY_SAVED: 'Travel Day Saved',
+ ENTRY_SIGNED: 'Entry Signed',
+ LOGBOOK_DELETED: 'Logbook Deleted',
+ ACCOUNT_DELETED: 'Account Deleted',
+ GPS_TRACK_UPLOADED: 'GPS Track Uploaded',
+ VESSEL_SAVED: 'Vessel Saved',
+ CREW_SAVED: 'Crew Saved',
+ ONBOARDING_TOUR_COMPLETED: 'Onboarding Tour Completed',
+ ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
+ INVITE_GENERATED: 'Invite Generated',
+ INVITE_ACCEPTED: 'Invite Accepted',
+ PDF_EXPORTED: 'PDF Exported',
+ CSV_EXPORTED: 'CSV Exported',
+ CSV_SHARED: 'CSV Shared'
+} as const
+
+export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+
+export type PlausibleEventProps = Record
+
+export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
+ if (typeof window.plausible !== 'function') return
+ if (props && Object.keys(props).length > 0) {
+ window.plausible(name, { props })
+ return
+ }
+ window.plausible(name)
+}
diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts
index c6587b2..25f4a71 100644
--- a/client/src/services/auth.ts
+++ b/client/src/services/auth.ts
@@ -11,6 +11,7 @@ import {
bufferToBase64
} from './crypto.js'
import { clearLogbookKeysCache } from './logbookKeys.js'
+import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
const API_BASE = '/api/auth'
@@ -255,6 +256,7 @@ export async function registerUser(username: string): Promise {
// Wipe localStorage and session variables
logoutUser()
+ trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
return true
}
} catch (err) {
diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts
index 3c0d229..2a59882 100644
--- a/client/src/services/logbook.ts
+++ b/client/src/services/logbook.ts
@@ -2,6 +2,7 @@ import { db, type LocalLogbook } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
+import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
const API_BASE = '/api/logbooks'
@@ -303,4 +304,5 @@ export async function deleteLogbook(id: string): Promise {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
+ trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
}
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
index 961b092..81f95ab 100644
--- a/client/src/vite-env.d.ts
+++ b/client/src/vite-env.d.ts
@@ -7,3 +7,11 @@ declare module '*?raw' {
const content: string
export default content
}
+
+declare global {
+ interface Window {
+ plausible?: (event: string, options?: { props?: Record }) => void
+ }
+}
+
+export {}
diff --git a/docs/plausible-events.md b/docs/plausible-events.md
new file mode 100644
index 0000000..42cf961
--- /dev/null
+++ b/docs/plausible-events.md
@@ -0,0 +1,59 @@
+# Plausible Custom Events
+
+Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
+
+**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
+
+## Setup
+
+1. Script in `client/index.html` (bereits eingebunden)
+2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
+
+## Event-Übersicht
+
+| Event | Auslöser | Properties |
+|-------|----------|------------|
+| Account Created | Erfolgreiche Registrierung (`auth.ts`) | — |
+| Logged In | Login oder Einladungs-Flow abgeschlossen (`App.tsx`) | — |
+| Logbook Created | Neues Logbuch im Dashboard (`LogbookDashboard.tsx`) | — |
+| Logbook Deleted | Logbuch gelöscht (`logbook.ts`) | — |
+| Travel Day Created | Neuer Reisetag über „+“ in der Eintragsliste (`LogEntriesList.tsx`) | — |
+| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
+| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
+| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
+| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
+| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
+| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
+| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | — |
+| Onboarding Tour Skipped | Tour übersprungen (Skip, Escape, Klick auf Backdrop) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) |
+| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
+| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
+| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
+| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
+| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
+
+## Bewusst nicht getrackt
+
+- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
+- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
+- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
+
+## Typische Funnels (Plausible Goals)
+
+Empfohlene Goal-Ketten für Auswertung:
+
+1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
+2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
+3. **Kollaboration:** Invite Generated → Invite Accepted
+4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
+
+## Entwicklung
+
+```ts
+import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
+
+trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
+trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
+```
+
+Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.