Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f1702ba2a | |||
| a4c7fcfc6f | |||
| e3aeae1966 | |||
| 760b369b39 | |||
| 166afac18a | |||
| cd2467d1fd | |||
| 9502719816 | |||
| 2926d743fb | |||
| f04a91d640 | |||
| 571c93cfe1 | |||
| 7d5d9de3c1 | |||
| ab7670c3fc | |||
| 41fb106153 | |||
| 268500237d |
+23
-2
@@ -1,16 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||||
|
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
|
||||||
|
<meta name="author" content="Markus F.J. Busche" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<meta name="application-name" content="Kapteins Daagbok" />
|
||||||
|
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<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" />
|
||||||
<title>Kapteins Daagbok</title>
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||||
|
<meta property="og:title" content="Kapteins Daagbok – Digitales Yacht-Logbuch" />
|
||||||
|
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten – mit Passkey-Anmeldung und Offline-PWA." />
|
||||||
|
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
|
||||||
|
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||||
|
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
|
||||||
|
<meta property="og:locale" content="de_DE" />
|
||||||
|
<meta property="og:locale:alternate" content="en_US" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="Kapteins Daagbok – Digitales Yacht-Logbuch" />
|
||||||
|
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten – mit Passkey-Anmeldung und Offline-PWA." />
|
||||||
|
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||||
|
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||||
|
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||||
|
<title>Kapteins Daagbok – Digitales Yacht-Logbuch</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -452,6 +455,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
try {
|
try {
|
||||||
const resized = await resizeImageFile(file)
|
const resized = await resizeImageFile(file)
|
||||||
setSkipPhoto(resized)
|
setSkipPhoto(resized)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'skipper' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setSkipPhotoError(err.message || 'Failed to process image')
|
setSkipPhotoError(err.message || 'Failed to process image')
|
||||||
}
|
}
|
||||||
@@ -659,6 +663,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
|||||||
try {
|
try {
|
||||||
const resized = await resizeImageFile(file)
|
const resized = await resizeImageFile(file)
|
||||||
setMemPhoto(resized)
|
setMemPhoto(resized)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'crew' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMemPhotoError(err.message || 'Failed to process image')
|
setMemPhotoError(err.message || 'Failed to process image')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +7,17 @@ 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'
|
||||||
import {
|
import {
|
||||||
carryOverTankLevelsFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
compareTravelDaysChronological,
|
compareTravelDaysChronological,
|
||||||
emptyTankLevels,
|
emptyTankLevels,
|
||||||
formatTankLiters,
|
formatTankLiters,
|
||||||
getNextTravelDayNumber,
|
getNextTravelDayNumber,
|
||||||
|
hasCarryOverFromPreviousDay,
|
||||||
type LogEntryTankSource,
|
type LogEntryTankSource,
|
||||||
type TravelDaySortable
|
type TravelDaySortable
|
||||||
} from '../utils/logEntryTankLevels.js'
|
} from '../utils/logEntryTankLevels.js'
|
||||||
@@ -156,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.')
|
||||||
@@ -174,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'
|
||||||
@@ -203,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.')
|
||||||
@@ -228,11 +233,12 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
decryptedEntries.sort(compareTravelDaysChronological)
|
decryptedEntries.sort(compareTravelDaysChronological)
|
||||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||||
|
|
||||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
t('logs.carry_over_tanks_confirm', {
|
t('logs.carry_over_tanks_confirm', {
|
||||||
|
departure: departure || '—',
|
||||||
fw: formatTankLiters(freshwater.morning),
|
fw: formatTankLiters(freshwater.morning),
|
||||||
fuel: formatTankLiters(fuel.morning)
|
fuel: formatTankLiters(fuel.morning)
|
||||||
}),
|
}),
|
||||||
@@ -243,6 +249,7 @@ export default function LogEntriesList({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
freshwater = emptyTankLevels()
|
freshwater = emptyTankLevels()
|
||||||
fuel = emptyTankLevels()
|
fuel = emptyTankLevels()
|
||||||
|
departure = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +262,7 @@ export default function LogEntriesList({
|
|||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||||
departure: '',
|
departure,
|
||||||
destination: '',
|
destination: '',
|
||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
@@ -288,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 {
|
||||||
|
|||||||
@@ -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 { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Trash2 } from 'lucide-react'
|
||||||
@@ -159,6 +160,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
|
|
||||||
setCaption('')
|
setCaption('')
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
|
|||||||
|
|
||||||
export default function PwaUpdatePrompt() {
|
export default function PwaUpdatePrompt() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { needRefresh, updateApp } = usePwaUpdate()
|
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [dismissed, setDismissed] = useState(false)
|
|
||||||
|
|
||||||
if (!needRefresh || dismissed) return null
|
if (!needRefresh) return null
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
setUpdating(true)
|
setUpdating(true)
|
||||||
@@ -43,7 +42,7 @@ export default function PwaUpdatePrompt() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pwa-update-link"
|
className="pwa-update-link"
|
||||||
onClick={() => setDismissed(true)}
|
onClick={dismissUpdate}
|
||||||
>
|
>
|
||||||
{t('pwa.later')}
|
{t('pwa.later')}
|
||||||
</button>
|
</button>
|
||||||
@@ -52,7 +51,7 @@ export default function PwaUpdatePrompt() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pwa-update-close"
|
className="pwa-update-close"
|
||||||
onClick={() => setDismissed(true)}
|
onClick={dismissUpdate}
|
||||||
aria-label={t('pwa.later')}
|
aria-label={t('pwa.later')}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
|
|||||||
@@ -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('skipped', 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))
|
||||||
|
|||||||
@@ -2,9 +2,27 @@ import { useEffect, useRef } from 'react'
|
|||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||||
|
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||||
|
const UPDATE_SUPPRESS_MS = 30_000
|
||||||
|
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||||
|
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||||
|
|
||||||
|
function isUpdateSuppressed(): boolean {
|
||||||
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
|
return Date.now() < suppressUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
|
||||||
|
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUpdateSuppression(): void {
|
||||||
|
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||||
const checkForUpdate = () => {
|
const checkForUpdate = () => {
|
||||||
|
if (isUpdateSuppressed()) return
|
||||||
registration.update().catch(() => {})
|
registration.update().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,28 +45,58 @@ export function usePwaUpdate() {
|
|||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
updateServiceWorker
|
updateServiceWorker
|
||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
onNeedReload() {
|
||||||
|
clearUpdateSuppression()
|
||||||
|
setNeedRefresh(false)
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
if (isUpdateSuppressed()) return
|
||||||
|
setNeedRefresh(true)
|
||||||
|
},
|
||||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||||
if (!registration) return
|
if (!registration) return
|
||||||
|
|
||||||
|
if (isUpdateSuppressed() || !registration.waiting) {
|
||||||
|
setNeedRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isUpdateSuppressed()) {
|
||||||
|
setNeedRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setNeedRefresh])
|
||||||
|
|
||||||
const updateApp = async () => {
|
const updateApp = async () => {
|
||||||
|
setNeedRefresh(false)
|
||||||
|
suppressUpdatePrompt()
|
||||||
|
|
||||||
await updateServiceWorker(true)
|
await updateServiceWorker(true)
|
||||||
|
|
||||||
|
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { needRefresh, updateApp }
|
const dismissUpdate = () => {
|
||||||
|
setNeedRefresh(false)
|
||||||
|
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { needRefresh, updateApp, dismissUpdate }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
"loading": "Journal wird geladen...",
|
"loading": "Journal wird geladen...",
|
||||||
"delete_entry": "Tag löschen",
|
"delete_entry": "Tag löschen",
|
||||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
|
|||||||
@@ -159,8 +159,8 @@
|
|||||||
"loading": "Loading journal...",
|
"loading": "Loading journal...",
|
||||||
"delete_entry": "Delete Day",
|
"delete_entry": "Delete Day",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||||
"carry_over_tanks_title": "Carry over tank levels?",
|
"carry_over_tanks_title": "Carry over from previous day?",
|
||||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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',
|
||||||
|
PHOTO_UPLOADED: 'Photo Uploaded'
|
||||||
|
} 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
|||||||
export interface LogEntryTankSource {
|
export interface LogEntryTankSource {
|
||||||
freshwater?: Partial<TankLevels>
|
freshwater?: Partial<TankLevels>
|
||||||
fuel?: Partial<TankLevels>
|
fuel?: Partial<TankLevels>
|
||||||
|
destination?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarryOverFromPreviousDay {
|
||||||
|
freshwater: TankLevels
|
||||||
|
fuel: TankLevels
|
||||||
|
departure: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyTankLevels(morning = 0): TankLevels {
|
export function emptyTankLevels(morning = 0): TankLevels {
|
||||||
@@ -62,3 +69,14 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
|||||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||||
|
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||||
|
const departure = previousEntry?.destination?.trim() || ''
|
||||||
|
|
||||||
|
return { freshwater, fuel, departure }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||||
|
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+10
-2
@@ -1,9 +1,17 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="vite-plugin-pwa/react" />
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
|
||||||
|
|
||||||
declare module '*?raw' {
|
declare module '*?raw' {
|
||||||
const content: string
|
const content: string
|
||||||
export default content
|
export default content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const __APP_VERSION__: string
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 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 vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `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`) | — |
|
||||||
|
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||||
|
|
||||||
|
## 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