feat: Plausible Analytics mit 18 Custom Events
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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() {
|
||||
<InvitationAcceptance
|
||||
onAccepted={(logbookId, title) => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
setIsAcceptingInvite(false)
|
||||
selectLogbook(logbookId, title)
|
||||
// Clean URL query parameters and hash anchor
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<string, string | number | boolean>
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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<RegistrationResult
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
rememberUsername(username)
|
||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -545,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
logoutUser()
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -7,3 +7,11 @@ declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
Reference in New Issue
Block a user