Compare commits

..

11 Commits

Author SHA1 Message Date
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
elpatron 181459c7e8 chore: release v0.1.0.20 2026-05-30 09:17:32 +02:00
elpatron ebeb05e865 feat: Skipper-Signatur-Badge auf Reisetag-Kacheln
Zeigt in der Journal-Liste an, ob ein Eintrag vom Skipper freigegeben ist
und ob eine Passkey-Signatur nach Inhaltsänderung ungültig geworden ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 09:14:17 +02:00
elpatron 64c0d8cd47 docs: Update copyright information in README to reflect new ownership 2026-05-29 21:46:59 +02:00
22 changed files with 741 additions and 217 deletions
+1 -1
View File
@@ -177,4 +177,4 @@ Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-B
--- ---
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu) © 2026 KnorrLabs/Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
+1 -1
View File
@@ -1 +1 @@
0.1.0.20 0.1.0.25
+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"
} }
} }
+38
View File
@@ -931,6 +931,44 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-subtle); color: var(--app-text-subtle);
} }
.entry-sign-badge {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.entry-sign-badge--skipper.valid {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
padding: 3px 7px;
}
.entry-sign-badge--skipper.invalid {
color: #fde68a;
background: rgba(251, 191, 36, 0.12);
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;
+22
View File
@@ -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(false)
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]
@@ -138,6 +142,11 @@ function App() {
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))
if (window.location.pathname === '/demo') {
setIsDemoMode(true)
return
}
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) { if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
setShareToken(params.get('token') || '') setShareToken(params.get('token') || '')
setShareKey(hashParams.get('key') || '') setShareKey(hashParams.get('key') || '')
@@ -234,6 +243,19 @@ function App() {
i18n.changeLanguage(nextLang) i18n.changeLanguage(nextLang)
} }
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
setIsDemoMode(false)
}
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' }}>
+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
+10
View File
@@ -523,6 +523,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={() => { window.location.pathname = '/demo' }}
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>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import CaptainCap from './icons/CaptainCap.tsx'
import type { SkipperSignStatus } from '../utils/signatures.js'
interface EntrySkipperSignBadgeProps {
status: SkipperSignStatus
}
export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeProps) {
const { t } = useTranslation()
if (status === 'none') return null
const isValid = status === 'valid'
const label = isValid
? t('logs.sign_badge_skipper_title_valid')
: t('logs.sign_badge_skipper_title_invalid')
return (
<span
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
title={label}
>
{isValid ? <CaptainCap size={14} aria-hidden /> : <AlertTriangle size={12} aria-hidden />}
<span className={isValid ? 'entry-sign-badge__sr-label' : undefined}>
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
</span>
</span>
)
}
+18 -9
View File
@@ -9,7 +9,9 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -41,6 +43,7 @@ interface DecryptedEntryItem {
departure: string departure: string
destination: string destination: string
updatedAt: string updatedAt: string
skipperSignStatus: SkipperSignStatus
} }
export default function LogEntriesList({ export default function LogEntriesList({
@@ -79,14 +82,18 @@ export default function LogEntriesList({
setError(null) setError(null)
try { try {
if (readOnly && preloadedEntries) { if (readOnly && preloadedEntries) {
const list = preloadedEntries.map((entry: any) => ({ const list: DecryptedEntryItem[] = []
id: entry.payloadId || entry.id, for (const entry of preloadedEntries) {
date: entry.date || '', list.push({
dayOfTravel: entry.dayOfTravel || '', id: entry.payloadId || entry.id,
departure: entry.departure || '', date: entry.date || '',
destination: entry.destination || '', dayOfTravel: entry.dayOfTravel || '',
updatedAt: entry.updatedAt || new Date().toISOString() departure: entry.departure || '',
})) destination: entry.destination || '',
updatedAt: entry.updatedAt || new Date().toISOString(),
skipperSignStatus: await getSkipperSignStatus(entry)
})
}
list.sort((a, b) => { list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -114,7 +121,8 @@ export default function LogEntriesList({
dayOfTravel: decrypted.dayOfTravel || '', dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '', departure: decrypted.departure || '',
destination: decrypted.destination || '', destination: decrypted.destination || '',
updatedAt: entry.updatedAt updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
}) })
} }
} }
@@ -411,6 +419,7 @@ export default function LogEntriesList({
<span className="sync-badge synced"> <span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel} {t('logs.day_of_travel')} {item.dayOfTravel}
</span> </span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge"> <span className="date-badge">
{new Date(item.date).toLocaleDateString()} {new Date(item.date).toLocaleDateString()}
</span> </span>
+7 -2
View File
@@ -426,7 +426,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 {
@@ -1367,7 +1372,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')}</>
)} )}
@@ -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`)
+13 -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",
@@ -141,6 +142,10 @@
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen", "sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen", "sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert", "sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Ungültig",
"sign_badge_skipper_title_valid": "Skipper hat freigegeben",
"sign_badge_skipper_title_invalid": "Skipper-Signatur ungültig — Inhalt wurde geändert",
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben", "sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben", "sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich", "sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
@@ -379,7 +384,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",
@@ -425,6 +433,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."
+13 -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",
@@ -141,6 +142,10 @@
"sign_passkey_failed": "Passkey signing failed", "sign_passkey_failed": "Passkey signing failed",
"sign_passkey_cancelled": "Passkey signing cancelled", "sign_passkey_cancelled": "Passkey signing cancelled",
"sign_invalid": "Signature invalid — entry content changed", "sign_invalid": "Signature invalid — entry content changed",
"sign_badge_skipper": "Skipper",
"sign_badge_skipper_invalid": "Invalid",
"sign_badge_skipper_title_valid": "Signed by skipper",
"sign_badge_skipper_title_invalid": "Skipper signature invalid — entry content changed",
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above", "sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey", "sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline", "sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
@@ -379,7 +384,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",
@@ -425,6 +433,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'
}
}
})
}
+13
View File
@@ -1,5 +1,8 @@
import { hashEntryForSigning } from './entryCanonicalHash.js'
import type { PasskeySignature, SignatureValue } from '../types/signatures.js' import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
export function isSignatureImage(value: string | undefined | null): boolean { export function isSignatureImage(value: string | undefined | null): boolean {
return typeof value === 'string' && value.startsWith('data:image/') return typeof value === 'string' && value.startsWith('data:image/')
} }
@@ -31,6 +34,16 @@ export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: strin
return sig.entryHash === entryHash return sig.entryHash === entryHash
} }
export async function getSkipperSignStatus(
entry: Record<string, unknown>
): Promise<SkipperSignStatus> {
const signSkipper = normalizeSignature(entry.signSkipper)
if (!signSkipper) return 'none'
if (!isPasskeySignature(signSkipper)) return 'valid'
const hash = await hashEntryForSigning(entry)
return isSignatureValidForEntry(signSkipper, hash) ? 'valid' : 'invalid'
}
export interface SignatureExportLabels { export interface SignatureExportLabels {
imagePlaceholder: string imagePlaceholder: string
passkeyLabel: (username: string, signedAt: string) => string passkeyLabel: (username: string, signedAt: string) => string
+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` |