Compare commits

...

19 Commits

Author SHA1 Message Date
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
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
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
23 changed files with 1114 additions and 342 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.29
+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"
} }
} }
+50
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;
@@ -1392,6 +1430,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle; vertical-align: middle;
} }
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover { .events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
+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>
)
}
@@ -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>
+301 -109
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -6,7 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx' import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx' import TrackMap from './TrackMap.tsx'
@@ -19,7 +19,7 @@ import {
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -34,6 +34,56 @@ import {
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: serializeSignature(normalizeSignature(decrypted.signSkipper as SignatureValue | '') || '') ?? '',
signCrew: serializeSignature(normalizeSignature(decrypted.signCrew as SignatureValue | '') || '') ?? ''
})
}
interface LogEntryEditorProps { interface LogEntryEditorProps {
entryId: string entryId: string
logbookId: string logbookId: string
@@ -45,24 +95,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any preloadedYacht?: any
} }
interface LogEvent { interface LogEvent extends LogEventPayload {}
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
export default function LogEntryEditor({ export default function LogEntryEditor({
entryId, entryId,
@@ -76,6 +109,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('')
@@ -137,6 +172,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false) const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload // Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null) const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -145,6 +181,9 @@ 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 skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints) const stats = computeTrackStats(waypoints)
@@ -167,7 +206,7 @@ export default function LogEntryEditor({
} }
} }
const buildPayloadForSigning = useCallback(() => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({ return buildLogEntryPayload({
date, date,
dayOfTravel, dayOfTravel,
@@ -188,7 +227,7 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events events: eventsOverride ?? events
}) })
}, [ }, [
date, dayOfTravel, departure, destination, date, dayOfTravel, departure, destination,
@@ -198,6 +237,61 @@ export default function LogEntryEditor({
events events
]) ])
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: serializeSignature(signSkipper) ?? '',
signCrew: serializeSignature(signCrew) ?? ''
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: serializeSignature(signSkipper) ?? '',
signCrew: serializeSignature(signCrew) ?? ''
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => { useEffect(() => {
const handleOnline = () => setIsOnline(true) const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false) const handleOffline = () => setIsOnline(false)
@@ -250,14 +344,21 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) { if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null lockedContentHashRef.current = null
setSignSkipper('') const hadSkipper = !!signSkipper
setSignCrew('') const hadCrew = !!signCrew
void showAlert( const skipperOnly = skipCrewSignClearRef.current
t('logs.sign_cleared_re_sign'), skipCrewSignClearRef.current = false
t('logs.sign_cleared_re_sign_title') if (hadSkipper) setSignSkipper('')
) if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : 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(
@@ -353,8 +454,10 @@ export default function LogEntryEditor({
async function loadEntry() { async function loadEntry() {
setLoading(true) setLoading(true)
setError(null) setError(null)
setSavedFingerprint(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 || '')
@@ -379,6 +482,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry) loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || []) setEvents(preloadedEntry.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return return
} }
@@ -411,6 +515,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted) loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || []) setEvents(decrypted.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
} }
} }
} catch (err: any) { } catch (err: any) {
@@ -426,7 +531,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 {
@@ -680,32 +790,26 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase()) return currentItems.includes(item.toLowerCase())
} }
const handleAddEvent = (e: React.FormEvent) => { const buildEventFromForm = (): LogEvent => ({
e.preventDefault() time: evTime,
if (readOnly || !evTime) return mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const newEvent: LogEvent = { const clearEventForm = () => {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
setEvTime('') setEvTime('')
setEvMgk('') setEvMgk('')
setEvRwk('') setEvRwk('')
@@ -723,11 +827,103 @@ export default function LogEntryEditor({
setEvGpsLng('') setEvGpsLng('')
setEvRemarks('') setEvRemarks('')
setEvLocationName('') setEvLocationName('')
setEditingEventIndex(null)
} }
const handleDeleteEvent = (index: number) => { const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index)) const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
} }
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
@@ -746,45 +942,13 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (readOnly) return if (readOnly || !isDirty) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
try { try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() await persistEntryToDb()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
setSuccess(true) setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -792,8 +956,6 @@ export default function LogEntryEditor({
setSuccess(false) setSuccess(false)
onBack() onBack()
}, 1500) }, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) { } catch (err: any) {
console.error('Failed to save entry details:', err) console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.') setError(err.message || 'Failed to save entry details.')
@@ -1067,8 +1229,22 @@ export default function LogEntryEditor({
</td> </td>
<td className="remarks-td">{ev.remarks}</td> <td className="remarks-td">{ev.remarks}</td>
{!readOnly && ( {!readOnly && (
<td> <td className="events-actions-td">
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}> <button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</td> </td>
@@ -1083,7 +1259,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */} {/* Add New Event Form Sub-Card */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass"> <div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4> <h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4"> <div className="form-grid mb-4">
<div className="input-group"> <div className="input-group">
@@ -1317,16 +1495,30 @@ export default function LogEntryEditor({
</div> </div>
</div> </div>
<button <div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
type="button" {editingEventIndex !== null && (
className="btn secondary" <button
onClick={handleAddEvent} type="button"
disabled={saving || !evTime} className="btn secondary"
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }} onClick={handleCancelEventEdit}
> disabled={saving}
<Plus size={16} /> style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
Add Event Entry >
</button> <X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1367,7 +1559,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')}</>
)} )}
@@ -1483,7 +1675,7 @@ export default function LogEntryEditor({
</div> </div>
)} )}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}> <button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} /> <Save size={18} />
{saving ? t('logs.saving') : t('logs.save')} {saving ? t('logs.saving') : t('logs.save')}
</button> </button>
+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`)
+20 -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,13 @@
"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",
"edit_event": "Ereignis bearbeiten",
"save_event_btn": "Änderung speichern",
"cancel_event_edit": "Abbrechen",
"delete_event": "Ereignis löschen",
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"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)",
@@ -141,6 +149,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 +391,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 +440,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."
+20 -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,13 @@
"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",
"edit_event": "Edit event",
"save_event_btn": "Save changes",
"cancel_event_edit": "Cancel",
"delete_event": "Delete event",
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date", "date": "Date",
"day_of_travel": "Day of Travel", "day_of_travel": "Day of Travel",
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
@@ -141,6 +149,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 +391,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 +440,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` |