Compare commits

...

12 Commits

Author SHA1 Message Date
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
elpatron 3849b5a2f0 chore: release v0.1.0.25 2026-05-30 10:18:24 +02:00
elpatron 1225601d7a fix: Demo navigation via history API and route sync.
Replace unreliable pathname assignment with pushState and central route syncing so the demo opens from the login screen and responds to browser back/forward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:18:14 +02:00
elpatron 180e5727df chore: release v0.1.0.24 2026-05-30 10:16:50 +02:00
elpatron 94b13c8d60 fix: Add fileType to PublicDemoFixture gpsTracks type for CI build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:16:32 +02:00
elpatron 69dddf7838 chore: release v0.1.0.23 2026-05-30 10:15:20 +02:00
elpatron 53eee9a3ad Add public read-only demo at /demo without account.
Let visitors explore ship data, crew, and sample log entries from the login page, with onboarding tour support and a fix for GPS track rendering when fileType is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:11:53 +02:00
elpatron ebe21c5a6f chore: release v0.1.0.22 2026-05-30 09:47:52 +02:00
elpatron 61f04902cb fix: Screenreader-Label für gültige Skipper-Signatur-Badge
Versteckter „Skipper“-Text ergänzt, damit die nur-Icon-Badge barrierefrei bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:47:47 +02:00
elpatron 166eeaf000 chore: release v0.1.0.21 2026-05-30 09:45:28 +02:00
elpatron c1418b5981 feat: Kapitänsmütze statt Text in Skipper-Signatur-Badge
Eigenes CaptainCap-Icon im Lucide-Stil; Tooltip und aria-label bleiben für Barrierefreiheit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:45:20 +02:00
20 changed files with 724 additions and 239 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.21 0.1.0.27
+3 -1
View File
@@ -36,6 +36,9 @@
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
} }
}, },
"node_modules/@apideck/better-ajv-errors": { "node_modules/@apideck/better-ajv-errors": {
@@ -2096,7 +2099,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
+3
View File
@@ -38,5 +38,8 @@
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0"
},
"optionalDependencies": {
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
} }
} }
+14
View File
@@ -932,6 +932,7 @@ html.scheme-dark .themed-select-option.is-selected {
} }
.entry-sign-badge { .entry-sign-badge {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -947,6 +948,7 @@ html.scheme-dark .themed-select-option.is-selected {
color: #86efac; color: #86efac;
background: rgba(34, 197, 94, 0.12); background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25); border: 1px solid rgba(34, 197, 94, 0.25);
padding: 3px 7px;
} }
.entry-sign-badge--skipper.invalid { .entry-sign-badge--skipper.invalid {
@@ -955,6 +957,18 @@ html.scheme-dark .themed-select-option.is-selected {
border: 1px solid rgba(251, 191, 36, 0.28); border: 1px solid rgba(251, 191, 36, 0.28);
} }
.entry-sign-badge__sr-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.btn-delete { .btn-delete {
background: none; background: none;
border: none; border: none;
+53 -7
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import './App.css' import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx' import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx' import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -23,6 +23,7 @@ import {
} from './services/appearance.js' } from './services/appearance.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
@@ -57,6 +58,9 @@ function App() {
const [shareToken, setShareToken] = useState('') const [shareToken, setShareToken] = useState('')
const [shareKey, setShareKey] = useState('') const [shareKey, setShareKey] = useState('')
// Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const syncQueueCount = useLiveQuery( const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
[activeLogbookId] [activeLogbookId]
@@ -134,21 +138,37 @@ function App() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => { const syncRouteFromLocation = useCallback(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const hashParams = new URLSearchParams(window.location.hash.substring(1)) const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) { if (path === '/demo') {
setShareToken(params.get('token') || '') setIsDemoMode(true)
setShareKey(hashParams.get('key') || '') setIsViewerMode(false)
setIsViewerMode(true) setIsAcceptingInvite(false)
return return
} }
setIsDemoMode(false)
if (path === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '')
setIsViewerMode(true)
setIsAcceptingInvite(false)
return
}
setIsViewerMode(false)
if (params.has('token')) { if (params.has('token')) {
setIsAcceptingInvite(true) setIsAcceptingInvite(true)
return
} }
setIsAcceptingInvite(false)
const savedUser = localStorage.getItem('active_username') const savedUser = localStorage.getItem('active_username')
const key = getActiveMasterKey() const key = getActiveMasterKey()
if (savedUser && key) { if (savedUser && key) {
@@ -162,6 +182,19 @@ function App() {
} }
}, []) }, [])
useEffect(() => {
syncRouteFromLocation()
window.addEventListener('popstate', syncRouteFromLocation)
return () => window.removeEventListener('popstate', syncRouteFromLocation)
}, [syncRouteFromLocation])
const openDemo = useCallback(() => {
window.history.pushState({}, document.title, '/demo')
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
useEffect(() => { useEffect(() => {
registerNavigation({ registerNavigation({
setActiveTab, setActiveTab,
@@ -234,6 +267,19 @@ function App() {
i18n.changeLanguage(nextLang) i18n.changeLanguage(nextLang)
} }
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
syncRouteFromLocation()
}
if (isDemoMode) {
return (
<div style={{ display: 'contents' }}>
<DemoViewer onExit={handleExitDemo} />
</div>
)
}
if (isViewerMode) { if (isViewerMode) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
@@ -266,7 +312,7 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="auth-screen"> <div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} /> <AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div> </div>
) )
} }
+2 -1
View File
@@ -25,6 +25,7 @@ export default function AppTourOverlay() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
isActive, isActive,
isDemoTour,
currentStepId, currentStepId,
currentStepIndex, currentStepIndex,
totalSteps, totalSteps,
@@ -104,7 +105,7 @@ export default function AppTourOverlay() {
if (!isActive || !currentStepId) return null if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t) const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour })
const centered = isCenteredTourStep(currentStepId) const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered const tooltipStyle = centered
+12 -1
View File
@@ -16,9 +16,10 @@ import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
onOpenDemo?: () => void
} }
export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) { export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -523,6 +524,16 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div> <div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
</div> </div>
<button
type="button"
className="btn secondary"
onClick={() => onOpenDemo?.()}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.explore_demo')}
</button>
{/* Registration form */} {/* Registration form */}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}> <form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<div className="input-group"> <div className="input-group">
+148
View File
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
interface DemoViewerProps {
onExit: () => void
}
export default function DemoViewer({ onExit }: DemoViewerProps) {
const { t, i18n } = useTranslation()
const { registerNavigation, registerDemoTourContext, startTour } = useAppTour()
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [fixture, setFixture] = useState<PublicDemoFixture>(() => buildPublicDemoFixture())
useEffect(() => {
trackPlausibleEvent(PlausibleEvents.DEMO_OPENED)
}, [])
useEffect(() => {
setFixture(buildPublicDemoFixture())
}, [i18n.language])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
const timer = window.setTimeout(() => {
startTour({ force: true, demoMode: true })
}, 400)
return () => {
window.clearTimeout(timer)
registerDemoTourContext(null)
}
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
return (
<div className="app-layout">
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #f59e0b, #3b82f6)' }} />
<header className="app-header" style={{ borderBottom: '1px solid rgba(245, 158, 11, 0.25)' }}>
<div className="app-header-left">
<button className="btn-back" onClick={onExit}>
<ChevronLeft size={16} />
{t('demo.back_to_login')}
</button>
<div className="app-title-area">
<div className="app-title-row">
<h2>{title}</h2>
<span className="demo-badge">{t('demo.badge')}</span>
</div>
<p className="app-subtitle" style={{ color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{t('demo.public_banner')}</span>
</p>
</div>
</div>
<div className="header-actions">
<button
className="btn primary"
onClick={onExit}
style={{ width: 'auto', padding: '6px 14px', fontSize: '13px' }}
>
<UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')}
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button>
</div>
</header>
<div className="app-body">
<aside className="app-sidebar">
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
{t('nav.logs')}
</button>
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
{t('nav.vessel')}
</button>
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
>
<Users size={18} />
{t('nav.crew')}
</button>
</aside>
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList
logbookId="demo"
readOnly={true}
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={firstEntryId}
/>
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
)}
</main>
</div>
</div>
)
}
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AlertTriangle, Fingerprint } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import CaptainCap from './icons/CaptainCap.tsx'
import type { SkipperSignStatus } from '../utils/signatures.js' import type { SkipperSignStatus } from '../utils/signatures.js'
interface EntrySkipperSignBadgeProps { interface EntrySkipperSignBadgeProps {
@@ -12,18 +13,19 @@ export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeP
if (status === 'none') return null if (status === 'none') return null
const isValid = status === 'valid' const isValid = status === 'valid'
const label = isValid
? t('logs.sign_badge_skipper_title_valid')
: t('logs.sign_badge_skipper_title_invalid')
return ( return (
<span <span
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`} className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
title={ title={label}
isValid
? t('logs.sign_badge_skipper_title_valid')
: t('logs.sign_badge_skipper_title_invalid')
}
> >
{isValid ? <Fingerprint size={12} /> : <AlertTriangle size={12} />} {isValid ? <CaptainCap size={14} aria-hidden /> : <AlertTriangle size={12} aria-hidden />}
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')} <span className={isValid ? 'entry-sign-badge__sr-label' : undefined}>
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
</span>
</span> </span>
) )
} }
+20 -8
View File
@@ -76,6 +76,8 @@ export default function LogEntryEditor({
}: LogEntryEditorProps) { }: LogEntryEditorProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog() const { showAlert, showConfirm } = useDialog()
const showAlertRef = useRef(showAlert)
showAlertRef.current = showAlert
// General details state // General details state
const [date, setDate] = useState('') const [date, setDate] = useState('')
@@ -145,6 +147,7 @@ export default function LogEntryEditor({
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null) const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false) const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints) const stats = computeTrackStats(waypoints)
@@ -252,12 +255,15 @@ export default function LogEntryEditor({
lockedContentHashRef.current = null lockedContentHashRef.current = null
setSignSkipper('') setSignSkipper('')
setSignCrew('') setSignCrew('')
void showAlert( if (lastSignatureAlertHashRef.current !== entryHash) {
t('logs.sign_cleared_re_sign'), lastSignatureAlertHashRef.current = entryHash
t('logs.sign_cleared_re_sign_title') void showAlertRef.current(
) t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
}
} }
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t]) }, [entryHash, signSkipper, signCrew, readOnly, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => { const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm( return showConfirm(
@@ -355,6 +361,7 @@ export default function LogEntryEditor({
setError(null) setError(null)
lockedContentHashRef.current = null lockedContentHashRef.current = null
contentReadyRef.current = false contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
try { try {
if (readOnly && preloadedEntry) { if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '') setDate(preloadedEntry.date || '')
@@ -426,7 +433,12 @@ export default function LogEntryEditor({
const loadTrack = async () => { const loadTrack = async () => {
if (readOnly && preloadedTrack) { if (readOnly && preloadedTrack) {
setSavedTrack(preloadedTrack) setSavedTrack({
waypoints: preloadedTrack.waypoints ?? [],
gpxContent: preloadedTrack.gpxContent ?? '',
filename: preloadedTrack.filename ?? 'track.gpx',
fileType: preloadedTrack.fileType ?? 'gpx'
})
return return
} }
try { try {
@@ -1325,7 +1337,7 @@ export default function LogEntryEditor({
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }} style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
> >
<Plus size={16} /> <Plus size={16} />
Add Event Entry {t('logs.add_event_btn')}
</button> </button>
</div> </div>
)} )}
@@ -1367,7 +1379,7 @@ export default function LogEntryEditor({
<Upload size={16} style={{ color: '#fbbf24' }} /> <Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span> <span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats"> <span className="track-info-stats">
{savedTrack.fileType.toUpperCase()} {(savedTrack.fileType ?? 'gpx').toUpperCase()}
{savedTrack.waypoints.length > 0 && ( {savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</> <> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)} )}
+20 -10
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react' import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
interface DialogContextType { interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void> showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -25,7 +25,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const resolveRef = useRef<((val: any) => void) | null>(null) const resolveRef = useRef<((val: any) => void) | null>(null)
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => { const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg) setMessage(msg)
setTitle(headerTitle || '') setTitle(headerTitle || '')
setType('alert') setType('alert')
@@ -35,9 +35,14 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
resolveRef.current = resolve resolveRef.current = resolve
}) })
} }, [])
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => { const showConfirm = useCallback((
msg: string,
headerTitle?: string,
btnConfirm?: string,
btnCancel?: string
): Promise<boolean> => {
setMessage(msg) setMessage(msg)
setTitle(headerTitle || '') setTitle(headerTitle || '')
setType('confirm') setType('confirm')
@@ -48,26 +53,31 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
resolveRef.current = resolve resolveRef.current = resolve
}) })
} }, [])
const handleConfirm = () => { const handleConfirm = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (resolveRef.current) { if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined) resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null resolveRef.current = null
} }
} }, [type])
const handleCancel = () => { const handleCancel = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (resolveRef.current) { if (resolveRef.current) {
resolveRef.current(false) resolveRef.current(false)
resolveRef.current = null resolveRef.current = null
} }
} }, [])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
)
return ( return (
<DialogContext.Provider value={{ showAlert, showConfirm }}> <DialogContext.Provider value={contextValue}>
{children} {children}
{isOpen && ( {isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}> <div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
interface CaptainCapProps extends SVGProps<SVGSVGElement> {
size?: number | string
}
/** Skipper-/Kapitänsmütze im Lucide-Strichstil (nicht in lucide-react enthalten). */
export default function CaptainCap({ size = 24, ...props }: CaptainCapProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...props}
>
<path d="M5 11c0-3.5 3-6 7-6s7 2.5 7 6" />
<path d="M4 11h16" />
<path d="M4 11c0 2.5 3.2 4.5 8 4.5S20 13.5 20 11" />
<path d="M8 11h8" />
</svg>
)
}
+47 -10
View File
@@ -11,7 +11,8 @@ import {
import { import {
clearTourCompleted, clearTourCompleted,
isTourCompleted, isTourCompleted,
markTourCompleted markTourCompleted,
resolveTourUserId
} 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' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -33,18 +34,24 @@ interface TourNavigation {
setSelectedEntryId: (entryId: string | null) => void setSelectedEntryId: (entryId: string | null) => void
} }
interface DemoTourContext {
firstEntryId: string
}
interface AppTourContextValue { interface AppTourContextValue {
isActive: boolean isActive: boolean
isDemoTour: boolean
currentStepId: TourStepId | null currentStepId: TourStepId | null
currentStepIndex: number currentStepIndex: number
totalSteps: number totalSteps: number
startTour: (options?: { force?: boolean }) => void startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void stopTour: () => void
restartTour: () => void restartTour: () => void
nextStep: () => void nextStep: () => void
prevStep: () => void prevStep: () => void
skipTour: () => void skipTour: () => void
registerNavigation: (navigation: TourNavigation) => void registerNavigation: (navigation: TourNavigation) => void
registerDemoTourContext: (context: DemoTourContext | null) => void
requestStartAfterLogin: () => void requestStartAfterLogin: () => void
} }
@@ -74,10 +81,17 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
const [stepIndex, setStepIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false) const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const navigationRef = useRef<TourNavigation | null>(null) const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
}, [])
const applyStepSideEffects = useCallback((stepId: TourStepId) => { const applyStepSideEffects = useCallback((stepId: TourStepId) => {
const nav = navigationRef.current const nav = navigationRef.current
if (!nav) return if (!nav) return
@@ -86,7 +100,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setActiveTab('logs') nav.setActiveTab('logs')
} }
if (stepId === 'entry_open' || stepId === 'entry_track') { if (stepId === 'entry_open' || stepId === 'entry_track') {
const firstEntryId = getStoredDemoFirstEntryId() const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId) if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} }
if (stepId === 'nav_vessel') { if (stepId === 'nav_vessel') {
@@ -97,7 +111,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('crew') nav.setActiveTab('crew')
} }
}, []) }, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => { const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return if (!stepId) return
@@ -109,24 +123,32 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
}) })
}, []) }, [])
const startTour = useCallback((options?: { force?: boolean }) => { const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
const userId = localStorage.getItem('active_userid') const demoMode = options?.demoMode === true
const userId = resolveTourUserId({ demoMode })
if (!userId) return if (!userId) return
if (!options?.force && isTourCompleted(userId)) return if (!options?.force && isTourCompleted(userId)) return
tourModeRef.current = { demoMode }
setIsDemoTour(demoMode)
setStepIndex(0) setStepIndex(0)
setIsActive(true) setIsActive(true)
}, []) }, [])
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => { const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
const userId = localStorage.getItem('active_userid') const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode })
if (userId) markTourCompleted(userId) if (userId) markTourCompleted(userId)
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') { if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED) trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
} else { } else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome' const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step }) trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
} }
tourModeRef.current = { demoMode: false }
setIsDemoTour(false)
setIsActive(false) setIsActive(false)
setStepIndex(0) setStepIndex(0)
}, []) }, [])
@@ -170,6 +192,10 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
navigationRef.current = navigation navigationRef.current = navigation
}, []) }, [])
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
demoContextRef.current = context
}, [])
const requestStartAfterLogin = useCallback(() => { const requestStartAfterLogin = useCallback(() => {
setPendingAfterLogin(true) setPendingAfterLogin(true)
}, []) }, [])
@@ -191,6 +217,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const value = useMemo<AppTourContextValue>( const value = useMemo<AppTourContextValue>(
() => ({ () => ({
isActive, isActive,
isDemoTour,
currentStepId, currentStepId,
currentStepIndex: stepIndex, currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length, totalSteps: STEP_ORDER.length,
@@ -201,13 +228,16 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
prevStep, prevStep,
skipTour, skipTour,
registerNavigation, registerNavigation,
registerDemoTourContext,
requestStartAfterLogin requestStartAfterLogin
}), }),
[ [
currentStepId, currentStepId,
isActive, isActive,
isDemoTour,
nextStep, nextStep,
prevStep, prevStep,
registerDemoTourContext,
registerNavigation, registerNavigation,
requestStartAfterLogin, requestStartAfterLogin,
restartTour, restartTour,
@@ -231,8 +261,15 @@ export function useAppTour(): AppTourContextValue {
export function getTourStepCopy( export function getTourStepCopy(
stepId: TourStepId, stepId: TourStepId,
t: (key: string) => string t: (key: string) => string,
options?: { demoMode?: boolean }
): { title: string; body: string } { ): { title: string; body: string } {
if (stepId === 'welcome' && options?.demoMode) {
return {
title: t('tour.steps.welcome_public.title'),
body: t('tour.steps.welcome_public.body')
}
}
return { return {
title: t(`tour.steps.${stepId}.title`), title: t(`tour.steps.${stepId}.title`),
body: t(`tour.steps.${stepId}.body`) body: t(`tour.steps.${stepId}.body`)
+10 -1
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.", "error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.", "error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
"or_register": "oder Registrieren", "or_register": "oder Registrieren",
"explore_demo": "Demo ohne Account erkunden",
"username_placeholder": "Benutzername / Skippername", "username_placeholder": "Benutzername / Skippername",
"processing": "Verarbeitung...", "processing": "Verarbeitung...",
"help": "Hilfe", "help": "Hilfe",
@@ -114,6 +115,7 @@
"new_entry": "Neuer Reisetag", "new_entry": "Neuer Reisetag",
"travel_details": "Reisedetails", "travel_details": "Reisedetails",
"add_event": "Neuen Logbucheintrag hinzufügen", "add_event": "Neuen Logbucheintrag hinzufügen",
"add_event_btn": "Ereignis hinzufügen",
"date": "Datum", "date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag", "day_of_travel": "Tag der Reise / Reisetag",
"departure": "Start-Hafen (Reise von)", "departure": "Start-Hafen (Reise von)",
@@ -383,7 +385,10 @@
}, },
"demo": { "demo": {
"logbook_title": "Demo-Logbuch Ostsee", "logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo" "badge": "Demo",
"public_banner": "Schreibgeschützte Demo-Ansicht",
"cta_register": "Account erstellen",
"back_to_login": "Zur Anmeldung"
}, },
"stats": { "stats": {
"title": "Statistik", "title": "Statistik",
@@ -429,6 +434,10 @@
"title": "Willkommen an Bord!", "title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen." "body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
}, },
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
},
"nav_logs": { "nav_logs": {
"title": "Logbucheinträge", "title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks." "body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
+10 -1
View File
@@ -38,6 +38,7 @@
"error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.", "error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.",
"error_decryption_failed": "Decryption failed. Please check your recovery phrase.", "error_decryption_failed": "Decryption failed. Please check your recovery phrase.",
"or_register": "or register", "or_register": "or register",
"explore_demo": "Explore demo without account",
"username_placeholder": "Username / Skipper Name", "username_placeholder": "Username / Skipper Name",
"processing": "Processing...", "processing": "Processing...",
"help": "Help", "help": "Help",
@@ -114,6 +115,7 @@
"new_entry": "New Travel Day", "new_entry": "New Travel Day",
"travel_details": "Travel Details", "travel_details": "Travel Details",
"add_event": "Add Event Log Record", "add_event": "Add Event Log Record",
"add_event_btn": "Add Event Entry",
"date": "Date", "date": "Date",
"day_of_travel": "Day of Travel", "day_of_travel": "Day of Travel",
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
@@ -383,7 +385,10 @@
}, },
"demo": { "demo": {
"logbook_title": "Baltic Sea Demo Logbook", "logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo" "badge": "Demo",
"public_banner": "Read-only demo view",
"cta_register": "Create account",
"back_to_login": "Back to login"
}, },
"stats": { "stats": {
"title": "Statistics", "title": "Statistics",
@@ -429,6 +434,10 @@
"title": "Welcome aboard!", "title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features." "body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
}, },
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
},
"nav_logs": { "nav_logs": {
"title": "Log entries", "title": "Log entries",
"body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks." "body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks."
+2 -1
View File
@@ -19,7 +19,8 @@ export const PlausibleEvents = {
CSV_SHARED: 'CSV Shared', CSV_SHARED: 'CSV Shared',
PHOTO_UPLOADED: 'Photo Uploaded', PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported', BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored' BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened'
} as const } as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+9
View File
@@ -1,3 +1,5 @@
export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__'
export function getTourCompletedKey(userId: string): string { export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}` return `app_tour_completed_${userId}`
} }
@@ -14,3 +16,10 @@ export function markTourCompleted(userId: string): void {
export function clearTourCompleted(userId: string): void { export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId)) localStorage.removeItem(getTourCompletedKey(userId))
} }
export function resolveTourUserId(options?: { demoMode?: boolean }): string | null {
const activeUserId = localStorage.getItem('active_userid')
if (activeUserId) return activeUserId
if (options?.demoMode) return PUBLIC_DEMO_TOUR_USER_ID
return null
}
+10 -187
View File
@@ -3,14 +3,13 @@ import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import {
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw' buildDemoCrewRecords,
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw' buildDemoEntryPayloads,
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw' buildDemoYachtData
} from './demoLogbookData.js'
export const SEED_DEMO_FLAG = 'seed_demo_logbook' export const SEED_DEMO_FLAG = 'seed_demo_logbook'
@@ -22,120 +21,6 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}` return `demo_first_entry_id_${userId}`
} }
interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: isDe ? 'Kiel' : 'Kiel',
destination: isDe ? 'Laboe' : 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: isDe ? 'NW' : 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: isDe ? 'Schleimünde' : 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
async function putEncryptedRecord( async function putEncryptedRecord(
logbookId: string, logbookId: string,
key: ArrayBuffer, key: ArrayBuffer,
@@ -194,44 +79,12 @@ async function putEncryptedRecord(
} }
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> { async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de') const yachtData = buildDemoYachtData()
const yachtData = {
name: isDe ? 'Seeadler' : 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe
? ['Großsegel', 'Genua', 'Spinnaker']
: ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now) await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const crewId = crypto.randomUUID() for (const crew of buildDemoCrewRecords()) {
const crewData = { await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
name: isDe ? 'Anna Müller' : 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
} }
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
} }
export interface DemoSeedResult { export interface DemoSeedResult {
@@ -273,42 +126,12 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const now = new Date().toISOString() const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now) await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays() const entryPayloads = buildDemoEntryPayloads()
let firstEntryId = '' let firstEntryId = ''
for (const day of days) { for (const { entryId, entryPayload, trackData } of entryPayloads) {
const entryId = crypto.randomUUID()
if (!firstEntryId) firstEntryId = entryId if (!firstEntryId) firstEntryId = entryId
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now) await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now) await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
} }
+318
View File
@@ -0,0 +1,318 @@
import { parseTrackFile } from './trackUpload.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
/** Stable ID for the first demo travel day (public demo tour highlight). */
export const PUBLIC_DEMO_FIRST_ENTRY_ID = 'a0000001-0000-4000-8000-000000000001'
const PUBLIC_DEMO_ENTRY_IDS = [
PUBLIC_DEMO_FIRST_ENTRY_ID,
'a0000001-0000-4000-8000-000000000002',
'a0000001-0000-4000-8000-000000000003'
] as const
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
date: string
dayOfTravel: string
departure: string
destination: string
gpx: string
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
events: Array<Record<string, string>>
}
export interface DemoCrewRecord {
payloadId: string
data: {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: 'skipper' | 'crew'
photo: string | null
}
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
crews: DemoCrewRecord[]
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
firstEntryId: string
}
export function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
return [
{
date: '2026-05-29',
dayOfTravel: '1',
departure: 'Kiel',
destination: 'Laboe',
gpx: kielLaboeGpx,
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
events: [
{
time: '10:15',
mgk: '042',
rwk: '038',
windDirection: 'NW',
windStrength: '4 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
},
{
time: '11:20',
mgk: '030',
rwk: '028',
windDirection: 'N',
windStrength: '3 Bft',
seaState: isDe ? 'ruhig' : 'calm',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
}
]
},
{
date: '2026-05-30',
dayOfTravel: '2',
departure: 'Laboe',
destination: 'Damp',
gpx: laboeDampGpx,
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
events: [
{
time: '09:00',
mgk: '055',
rwk: '050',
windDirection: 'NE',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
},
{
time: '12:30',
mgk: '075',
rwk: '068',
windDirection: 'E',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
}
]
},
{
date: '2026-05-31',
dayOfTravel: '3',
departure: 'Damp',
destination: 'Schleimünde',
gpx: dampSchleimuendeGpx,
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
events: [
{
time: '08:30',
mgk: '290',
rwk: '285',
windDirection: 'W',
windStrength: '4 Bft',
seaState: isDe ? 'mäßig bewegt' : 'moderate',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
},
{
time: '14:00',
mgk: '310',
rwk: '305',
windDirection: 'NW',
windStrength: '3 Bft',
seaState: isDe ? 'leicht bewegt' : 'slight',
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
}
]
}
]
}
export function buildDemoYachtData(): Record<string, unknown> {
const isDe = i18n.language.startsWith('de')
return {
name: 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
lengthM: 12.5,
draftM: 1.9,
airDraftM: 18,
homePort: 'Kiel',
charterCompany: '',
owner: 'Demo Skipper',
registrationNumber: 'D-KI 1234',
callSign: 'DA1234',
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
}
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = i18n.language.startsWith('de')
return [
{
payloadId: 'skipper',
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
birthDate: '1980-06-15',
phone: '+49 431 987654',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C12X34Y56',
bloodType: '0+',
allergies: '',
diseases: '',
role: 'skipper',
photo: null
}
},
{
payloadId: PUBLIC_DEMO_CREW_MEMBER_ID,
data: {
name: 'Anna Müller',
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
birthDate: '1988-04-12',
phone: '+49 431 123456',
nationality: isDe ? 'Deutsch' : 'German',
passportNumber: 'C01X00T47',
bloodType: 'A+',
allergies: '',
diseases: '',
role: 'crew',
photo: null
}
}
]
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
days.forEach((day, index) => {
const entryId = PUBLIC_DEMO_ENTRY_IDS[index] ?? crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
payloadId: entryId,
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
entries.push(entryPayload as PublicDemoFixture['entries'][number])
gpsTracks.push({
entryId,
waypoints,
filename: day.filename,
gpxContent: day.gpx,
fileType: 'gpx'
})
})
return {
title,
yacht,
crews,
entries,
gpsTracks,
photos: [],
firstEntryId: PUBLIC_DEMO_FIRST_ENTRY_ID
}
}
export function getPublicDemoFirstEntryId(): string {
return PUBLIC_DEMO_FIRST_ENTRY_ID
}
/** Payloads for encrypted seeding (without payloadId on entries). */
export function buildDemoEntryPayloads(): Array<{
entryId: string
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
const { waypoints } = parseTrackFile(day.gpx, day.filename)
const stats = computeTrackStats(waypoints)
const entryPayload: Record<string, unknown> = {
date: day.date,
dayOfTravel: day.dayOfTravel,
departure: day.departure,
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
signSkipper: '',
signCrew: '',
events: day.events
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
}
return {
entryId,
entryPayload,
trackData: {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
}
})
}
+3 -2
View File
@@ -24,8 +24,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — | | Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` | | Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — | | Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | | | Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | `mode`: `demo` (optional, bei Public-Demo) |
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) | | Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`), optional `mode`: `demo` |
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — | | Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — | | Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` | | PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |