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:
@@ -10,6 +10,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||||
<meta name="theme-color" content="#1e293b" />
|
<meta name="theme-color" content="#1e293b" />
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
|
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||||
<title>Kapteins Daagbok</title>
|
<title>Kapteins Daagbok</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
|||||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||||
import {
|
import {
|
||||||
applyAppearanceToDocument,
|
applyAppearanceToDocument,
|
||||||
resolveAppTheme,
|
resolveAppTheme,
|
||||||
@@ -153,6 +154,7 @@ function App() {
|
|||||||
|
|
||||||
const handleAuthenticated = async () => {
|
const handleAuthenticated = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const demo = await seedDemoLogbookIfNeeded()
|
const demo = await seedDemoLogbookIfNeeded()
|
||||||
@@ -214,6 +216,7 @@ function App() {
|
|||||||
<InvitationAcceptance
|
<InvitationAcceptance
|
||||||
onAccepted={(logbookId, title) => {
|
onAccepted={(logbookId, title) => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
setIsAcceptingInvite(false)
|
setIsAcceptingInvite(false)
|
||||||
selectLogbook(logbookId, title)
|
selectLogbook(logbookId, title)
|
||||||
// Clean URL query parameters and hash anchor
|
// Clean URL query parameters and hash anchor
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getActiveMasterKey } from '../services/auth.js'
|
|||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
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)
|
setSkipperSuccess(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'skipper', action: 'update' })
|
||||||
setTimeout(() => setSkipperSuccess(false), 3000)
|
setTimeout(() => setSkipperSuccess(false), 3000)
|
||||||
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
@@ -337,6 +339,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
setShowMemberForm(false)
|
setShowMemberForm(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'crew', action: isNew ? 'create' : 'update' })
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save crew member:', err)
|
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 { saveLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
interface InvitationAcceptanceProps {
|
interface InvitationAcceptanceProps {
|
||||||
onAccepted: (logbookId: string, title: string) => void
|
onAccepted: (logbookId: string, title: string) => void
|
||||||
@@ -194,6 +195,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
await syncLogbook(logbookId)
|
await syncLogbook(logbookId)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
|
||||||
onAccepted(logbookId, decryptedTitle)
|
onAccepted(logbookId, decryptedTitle)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Accepting invitation failed:', err)
|
console.error('Accepting invitation failed:', err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { decryptJson, encryptJson } from '../services/crypto.js'
|
|||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||||
@@ -157,6 +158,7 @@ export default function LogEntriesList({
|
|||||||
} else {
|
} else {
|
||||||
await downloadCsv(logbookId, title)
|
await downloadCsv(logbookId, title)
|
||||||
}
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(err.message || 'Failed to generate CSV export.')
|
||||||
@@ -175,6 +177,7 @@ export default function LogEntriesList({
|
|||||||
} else {
|
} else {
|
||||||
await shareCsv(logbookId, title)
|
await shareCsv(logbookId, title)
|
||||||
}
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message === 'share_unsupported') {
|
if (err.message === 'share_unsupported') {
|
||||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
@@ -204,6 +207,7 @@ export default function LogEntriesList({
|
|||||||
} else {
|
} else {
|
||||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||||
}
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(err.message || 'Failed to generate PDF export.')
|
||||||
@@ -291,6 +295,7 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
// Open immediately in details editor
|
// Open immediately in details editor
|
||||||
setSelectedEntryId(localId)
|
setSelectedEntryId(localId)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED)
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create entry:', err)
|
console.error('Failed to create entry:', err)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
|
|||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import {
|
import {
|
||||||
getDecryptedTrack,
|
getDecryptedTrack,
|
||||||
saveUploadedTrack,
|
saveUploadedTrack,
|
||||||
@@ -284,6 +285,7 @@ export default function LogEntryEditor({
|
|||||||
setSignSkipper(signature)
|
setSignSkipper(signature)
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasskeySignCrew = async () => {
|
const handlePasskeySignCrew = async () => {
|
||||||
@@ -300,6 +302,7 @@ export default function LogEntryEditor({
|
|||||||
setSignCrew(signature)
|
setSignCrew(signature)
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-calculate Freshwater Consumption
|
// Auto-calculate Freshwater Consumption
|
||||||
@@ -465,6 +468,7 @@ export default function LogEntryEditor({
|
|||||||
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||||
applyTrackStats(parsedWps)
|
applyTrackStats(parsedWps)
|
||||||
await loadTrack()
|
await loadTrack()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('File parsing failed:', err)
|
console.error('File parsing failed:', err)
|
||||||
setUploadError(err.message || 'Failed to parse track file.')
|
setUploadError(err.message || 'Failed to parse track file.')
|
||||||
@@ -731,6 +735,7 @@ export default function LogEntryEditor({
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(err.message || 'Failed to generate PDF export.')
|
||||||
@@ -782,6 +787,7 @@ export default function LogEntryEditor({
|
|||||||
})
|
})
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
onBack()
|
onBack()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.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 { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
@@ -70,6 +71,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
const created = await createLogbook(newTitle.trim())
|
const created = await createLogbook(newTitle.trim())
|
||||||
setLogbooks((prev) => [created, ...prev])
|
setLogbooks((prev) => [created, ...prev])
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to create logbook')
|
setError(err.message || 'Failed to create logbook')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||||
import ThemedSelect from './ThemedSelect.tsx'
|
import ThemedSelect from './ThemedSelect.tsx'
|
||||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -208,6 +209,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
|||||||
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
||||||
|
|
||||||
setInviteLink(link)
|
setInviteLink(link)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to generate invite:', err)
|
console.error('Failed to generate invite:', err)
|
||||||
showAlert(err.message || 'Failed to generate invite link.')
|
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 { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.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'
|
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
interface VesselFormProps {
|
interface VesselFormProps {
|
||||||
@@ -251,6 +252,7 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
})
|
})
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED)
|
||||||
setTimeout(() => setSuccess(false), 3000)
|
setTimeout(() => setSuccess(false), 3000)
|
||||||
|
|
||||||
// Trigger background sync task
|
// Trigger background sync task
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
markTourCompleted
|
markTourCompleted
|
||||||
} from '../services/appTourStorage.js'
|
} from '../services/appTourStorage.js'
|
||||||
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
|
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
|
||||||
|
|
||||||
@@ -117,25 +118,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsActive(true)
|
setIsActive(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const finishTour = useCallback(() => {
|
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
|
||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (userId) markTourCompleted(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)
|
setIsActive(false)
|
||||||
setStepIndex(0)
|
setStepIndex(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stopTour = finishTour
|
const stopTour = useCallback(() => {
|
||||||
const skipTour = finishTour
|
dismissTour('completed', stepIndex)
|
||||||
|
}, [dismissTour, stepIndex])
|
||||||
|
|
||||||
|
const skipTour = useCallback(() => {
|
||||||
|
dismissTour('skipped', stepIndex)
|
||||||
|
}, [dismissTour, stepIndex])
|
||||||
|
|
||||||
const nextStep = useCallback(() => {
|
const nextStep = useCallback(() => {
|
||||||
setStepIndex((current) => {
|
if (stepIndex + 1 >= STEP_ORDER.length) {
|
||||||
if (current + 1 >= STEP_ORDER.length) {
|
dismissTour('completed', stepIndex)
|
||||||
finishTour()
|
return
|
||||||
return current
|
|
||||||
}
|
}
|
||||||
return current + 1
|
setStepIndex(stepIndex + 1)
|
||||||
})
|
}, [dismissTour, stepIndex])
|
||||||
}, [finishTour])
|
|
||||||
|
|
||||||
const prevStep = useCallback(() => {
|
const prevStep = useCallback(() => {
|
||||||
setStepIndex((current) => Math.max(0, current - 1))
|
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
|
bufferToBase64
|
||||||
} from './crypto.js'
|
} from './crypto.js'
|
||||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth'
|
const API_BASE = '/api/auth'
|
||||||
@@ -255,6 +256,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
localStorage.setItem('active_userid', result.userId)
|
localStorage.setItem('active_userid', result.userId)
|
||||||
rememberUsername(username)
|
rememberUsername(username)
|
||||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -545,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
|
|
||||||
// Wipe localStorage and session variables
|
// Wipe localStorage and session variables
|
||||||
logoutUser()
|
logoutUser()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, type LocalLogbook } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
||||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
const API_BASE = '/api/logbooks'
|
const API_BASE = '/api/logbooks'
|
||||||
|
|
||||||
@@ -303,4 +304,5 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
|
|
||||||
// Perform local cascading cleanup
|
// Perform local cascading cleanup
|
||||||
await deleteLocalLogbookCache(id)
|
await deleteLocalLogbookCache(id)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
@@ -7,3 +7,11 @@ declare module '*?raw' {
|
|||||||
const content: string
|
const content: string
|
||||||
export default content
|
export default content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user