From 2926d743fb5ed2bb2570d62c97d028fc8dd2b100 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 19:02:41 +0200 Subject: [PATCH] feat: Plausible Analytics mit 18 Custom Events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trackt zentrale Nutzeraktionen (Auth, Logbuch, Reisetage, Kollaboration, Onboarding, Export) über einen typisierten Analytics-Service und dokumentiert alle Events für Plausible Goals. Co-authored-by: Cursor --- client/index.html | 1 + client/src/App.tsx | 3 + client/src/components/CrewForm.tsx | 3 + .../src/components/InvitationAcceptance.tsx | 2 + client/src/components/LogEntriesList.tsx | 5 ++ client/src/components/LogEntryEditor.tsx | 6 ++ client/src/components/LogbookDashboard.tsx | 2 + client/src/components/SettingsForm.tsx | 2 + client/src/components/VesselForm.tsx | 2 + client/src/context/AppTourContext.tsx | 32 ++++++---- client/src/services/analytics.ts | 33 +++++++++++ client/src/services/auth.ts | 3 + client/src/services/logbook.ts | 2 + client/src/vite-env.d.ts | 8 +++ docs/plausible-events.md | 59 +++++++++++++++++++ 15 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 client/src/services/analytics.ts create mode 100644 docs/plausible-events.md 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.