feat: Demo-Logbuch und Onboarding-Tour bei Registrierung

Neue Nutzer erhalten automatisch ein Demo-Logbuch mit drei Ostsee-Reisetagen
und eine interaktive App-Tour; die Tour kann in den Einstellungen erneut gestartet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 17:59:02 +02:00
parent 646d316a36
commit 0da855381d
20 changed files with 5549 additions and 23 deletions
+130
View File
@@ -2548,4 +2548,134 @@ html.theme-cupertino .events-scroll-container {
text-decoration: underline;
}
.demo-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
}
.app-tour-root {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
}
.app-tour-backdrop {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.72);
pointer-events: auto;
}
.app-tour-spotlight {
position: fixed;
border-radius: 12px;
box-shadow: 0 0 0 9999px rgba(2, 6, 23, 0.72);
border: 2px solid rgba(56, 189, 248, 0.85);
pointer-events: none;
z-index: 10001;
}
.app-tour-tooltip {
position: fixed;
z-index: 10002;
width: min(420px, calc(100vw - 32px));
padding: 20px 20px 16px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.96);
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
pointer-events: auto;
}
.app-tour-tooltip.centered {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.app-tour-close {
position: absolute;
top: 12px;
right: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.app-tour-close:hover {
background: rgba(148, 163, 184, 0.12);
color: #e2e8f0;
}
.app-tour-progress {
margin: 0 0 8px;
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.app-tour-title {
margin: 0 0 8px;
font-size: 20px;
color: #f8fafc;
}
.app-tour-body {
margin: 0 0 16px;
font-size: 14px;
line-height: 1.55;
color: #cbd5e1;
}
.app-tour-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.app-tour-link {
border: none;
background: transparent;
color: #94a3b8;
font-size: 13px;
cursor: pointer;
padding: 0;
}
.app-tour-link:hover {
color: #e2e8f0;
}
.app-tour-nav {
display: flex;
gap: 8px;
margin-left: auto;
}
.app-tour-nav-btn {
width: auto;
padding: 8px 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
+66 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -10,6 +10,8 @@ import CrewForm from './components/CrewForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
import SettingsForm from './components/SettingsForm.tsx'
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { getActiveMasterKey, logoutUser } from './services/auth.js'
import {
applyAppearanceToDocument,
@@ -26,13 +28,20 @@ import { db } from './services/db.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
function App() {
const { t } = useTranslation()
const { registerNavigation, requestStartAfterLogin } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs' | 'settings'>('logs')
const [activeTab, setActiveTab] = useState<AppTab>('logs')
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
const [online, setOnline] = useState(navigator.onLine)
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
@@ -119,8 +128,45 @@ function App() {
}
}, [])
const handleAuthenticated = () => {
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId
})
}, [registerNavigation])
useEffect(() => {
if (isAuthenticated && activeLogbookId) {
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
}
}, [isAuthenticated, activeLogbookId])
const handleSelectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
setActiveTab('logs')
setTourSelectedEntryId(null)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}, [])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
try {
const demo = await seedDemoLogbookIfNeeded()
if (demo) {
handleSelectLogbook(demo.logbookId, demo.title)
if (demo.firstEntryId) {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
return
}
} catch (err) {
console.error('Failed to seed demo logbook:', err)
}
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
@@ -134,20 +180,16 @@ function App() {
setIsAuthenticated(false)
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
setDemoHighlightEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
const handleSelectLogbook = (id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
const handleBackToDashboard = () => {
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
@@ -246,6 +288,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => setActiveTab('logs')}
data-tour="nav-logs"
>
<FileText size={18} />
{t('nav.logs')}
@@ -254,6 +297,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => setActiveTab('vessel')}
data-tour="nav-vessel"
>
<Ship size={18} />
{t('nav.vessel')}
@@ -262,6 +306,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -289,7 +334,12 @@ function App() {
{/* Tab Content Panels (Placeholder until Phase 3) */}
<main className="app-content">
{activeTab === 'logs' && (
<LogEntriesList logbookId={activeLogbookId} />
<LogEntriesList
logbookId={activeLogbookId}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
highlightEntryId={demoHighlightEntryId}
/>
)}
{activeTab === 'vessel' && (
@@ -319,8 +369,11 @@ function App() {
export default function AppWrapper() {
return (
<DialogProvider>
<PwaUpdatePrompt />
<App />
<AppTourProvider>
<PwaUpdatePrompt />
<App />
<AppTourOverlay />
</AppTourProvider>
<AppFooter />
</DialogProvider>
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
interface SpotlightRect {
top: number
left: number
width: number
height: number
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
isActive,
currentStepId,
currentStepIndex,
totalSteps,
nextStep,
prevStep,
skipTour
} = useAppTour()
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
useLayoutEffect(() => {
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
setSpotlight(null)
return
}
const updateSpotlight = () => {
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
return
}
const el = document.querySelector(selector)
if (!el) {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
return () => {
window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') skipTour()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isActive, skipTour])
if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t)
const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
return (
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
<div className="app-tour-backdrop" onClick={skipTour} />
{!centered && spotlight && (
<div
className="app-tour-spotlight"
style={{
top: spotlight.top,
left: spotlight.left,
width: spotlight.width,
height: spotlight.height
}}
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
<p className="app-tour-progress">
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
</p>
<h3 className="app-tour-title">{title}</h3>
<p className="app-tour-body">{body}</p>
<div className="app-tour-actions">
<button
type="button"
className="app-tour-link"
onClick={skipTour}
>
{t('tour.skip')}
</button>
<div className="app-tour-nav">
<button
type="button"
className="btn secondary app-tour-nav-btn"
onClick={prevStep}
disabled={currentStepIndex === 0}
>
<ChevronLeft size={16} />
{t('tour.back')}
</button>
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</div>
)
}
+26 -4
View File
@@ -27,6 +27,9 @@ interface LogEntriesListProps {
preloadedEntries?: any[]
preloadedPhotos?: any[]
preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void
highlightEntryId?: string | null
}
interface DecryptedEntryItem {
@@ -44,12 +47,26 @@ export default function LogEntriesList({
preloadedYacht,
preloadedEntries,
preloadedPhotos,
preloadedGpsTracks
preloadedGpsTracks,
controlledSelectedEntryId,
onSelectedEntryIdChange,
highlightEntryId
}: LogEntriesListProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
const selectedEntryId = isEntrySelectionControlled
? (controlledSelectedEntryId ?? null)
: internalSelectedEntryId
const setSelectedEntryId = (entryId: string | null) => {
if (isEntrySelectionControlled) {
onSelectedEntryIdChange?.(entryId)
} else {
setInternalSelectedEntryId(entryId)
}
}
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -356,9 +373,14 @@ export default function LogEntriesList({
{entries.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
) : (
<div className="logbooks-grid">
<div className="logbooks-grid" data-tour="entry-list">
{entries.map((item) => (
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<FileText size={24} />
</div>
+1 -1
View File
@@ -1326,7 +1326,7 @@ export default function LogEntryEditor({
</div>
{/* Track file upload */}
<div className="form-card">
<div className="form-card" data-tour="entry-track">
<div className="form-header">
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
@@ -209,6 +209,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
+22 -1
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
interface SettingsFormProps {
logbookId?: string | null
@@ -30,6 +31,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
@@ -365,6 +367,25 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
+239
View File
@@ -0,0 +1,239 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import {
clearTourCompleted,
isTourCompleted,
markTourCompleted
} from '../services/appTourStorage.js'
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
export type AppTab = 'vessel' | 'crew' | 'logs' | 'settings'
export type TourStepId =
| 'welcome'
| 'nav_logs'
| 'entry_list'
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
}
interface AppTourContextValue {
isActive: boolean
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
startTour: (options?: { force?: boolean }) => void
stopTour: () => void
restartTour: () => void
nextStep: () => void
prevStep: () => void
skipTour: () => void
registerNavigation: (navigation: TourNavigation) => void
requestStartAfterLogin: () => void
}
const STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'finish'
]
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_logs: '[data-tour="nav-logs"]',
entry_list: '[data-tour="entry-list"]',
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]'
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
export function AppTourProvider({ children }: { children: ReactNode }) {
const [isActive, setIsActive] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const navigationRef = useRef<TourNavigation | null>(null)
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
const nav = navigationRef.current
if (!nav) return
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
const firstEntryId = getStoredDemoFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
nav.setSelectedEntryId(null)
nav.setActiveTab('crew')
}
}, [])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
}, [])
const startTour = useCallback((options?: { force?: boolean }) => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
if (!options?.force && isTourCompleted(userId)) return
setStepIndex(0)
setIsActive(true)
applyStepSideEffects(STEP_ORDER[0])
scrollToCurrentTarget(STEP_ORDER[0])
}, [applyStepSideEffects, scrollToCurrentTarget])
const finishTour = useCallback(() => {
const userId = localStorage.getItem('active_userid')
if (userId) markTourCompleted(userId)
setIsActive(false)
setStepIndex(0)
}, [])
const stopTour = finishTour
const skipTour = finishTour
const nextStep = useCallback(() => {
const nextIndex = stepIndex + 1
if (nextIndex >= STEP_ORDER.length) {
finishTour()
return
}
const nextId = STEP_ORDER[nextIndex]
setStepIndex(nextIndex)
applyStepSideEffects(nextId)
scrollToCurrentTarget(nextId)
}, [applyStepSideEffects, finishTour, scrollToCurrentTarget, stepIndex])
const prevStep = useCallback(() => {
const prevIndex = Math.max(0, stepIndex - 1)
const prevId = STEP_ORDER[prevIndex]
setStepIndex(prevIndex)
applyStepSideEffects(prevId)
scrollToCurrentTarget(prevId)
}, [applyStepSideEffects, scrollToCurrentTarget, stepIndex])
const restartTour = useCallback(() => {
const userId = localStorage.getItem('active_userid')
if (!userId) return
clearTourCompleted(userId)
startTour({ force: true })
}, [startTour])
const registerNavigation = useCallback((navigation: TourNavigation) => {
navigationRef.current = navigation
}, [])
const requestStartAfterLogin = useCallback(() => {
setPendingAfterLogin(true)
}, [])
useEffect(() => {
if (!pendingAfterLogin) return
const userId = localStorage.getItem('active_userid')
if (!userId || isTourCompleted(userId)) {
setPendingAfterLogin(false)
return
}
const timer = window.setTimeout(() => {
startTour({ force: true })
setPendingAfterLogin(false)
}, 400)
return () => window.clearTimeout(timer)
}, [pendingAfterLogin, startTour])
const value = useMemo<AppTourContextValue>(
() => ({
isActive,
currentStepId,
currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length,
startTour,
stopTour,
restartTour,
nextStep,
prevStep,
skipTour,
registerNavigation,
requestStartAfterLogin
}),
[
currentStepId,
isActive,
nextStep,
prevStep,
registerNavigation,
requestStartAfterLogin,
restartTour,
skipTour,
startTour,
stepIndex,
stopTour
]
)
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
}
export function useAppTour(): AppTourContextValue {
const ctx = useContext(AppTourContext)
if (!ctx) {
throw new Error('useAppTour must be used within AppTourProvider')
}
return ctx
}
export function getTourStepCopy(
stepId: TourStepId,
t: (key: string) => string
): { title: string; body: string } {
return {
title: t(`tour.steps.${stepId}.title`),
body: t(`tour.steps.${stepId}.body`)
}
}
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
if (!stepId) return null
return TARGET_BY_STEP[stepId] ?? null
}
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
+49 -1
View File
@@ -308,7 +308,55 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"deleting_account": "Konto wird gelöscht…"
"deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten"
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo"
},
"tour": {
"skip": "Tour überspringen",
"back": "Zurück",
"next": "Weiter",
"finish": "Fertig",
"progress": "Schritt {{current}} von {{total}}",
"steps": {
"welcome": {
"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."
},
"nav_logs": {
"title": "Logbucheinträge",
"body": "Hier verwalten Sie Ihre Reisetage Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
},
"entry_list": {
"title": "Ihre Reisetage",
"body": "Jede Karte steht für einen Reisetag. Tippen Sie auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
},
"entry_open": {
"title": "Reisetag öffnen",
"body": "So sieht ein ausgefüllter Logbucheintrag aus mit Events, Tankständen und mehr."
},
"entry_track": {
"title": "GPS-Track",
"body": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
},
"finish": {
"title": "Alles klar!",
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
}
}
}
}
}
+49 -1
View File
@@ -308,7 +308,55 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.",
"deleting_account": "Deleting account…"
"deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour"
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo"
},
"tour": {
"skip": "Skip tour",
"back": "Back",
"next": "Next",
"finish": "Done",
"progress": "Step {{current}} of {{total}}",
"steps": {
"welcome": {
"title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features."
},
"nav_logs": {
"title": "Log entries",
"body": "Manage your travel days here departure, destination, weather, tank levels, and GPS tracks."
},
"entry_list": {
"title": "Your travel days",
"body": "Each card represents one travel day. Tap an entry to view or edit the details."
},
"entry_open": {
"title": "Open a travel day",
"body": "This is what a filled log entry looks like with events, tank levels, and more."
},
"entry_track": {
"title": "GPS track",
"body": "Upload GPX files or view saved routes on the map including distance and speed stats."
},
"nav_vessel": {
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
},
"finish": {
"title": "You're all set!",
"body": "You can restart the tour anytime in Settings. Fair winds!"
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
export function getTourCompletedKey(userId: string): string {
return `app_tour_completed_${userId}`
}
export function isTourCompleted(userId: string | null): boolean {
if (!userId) return true
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
}
export function markTourCompleted(userId: string): void {
localStorage.setItem(getTourCompletedKey(userId), '1')
}
export function clearTourCompleted(userId: string): void {
localStorage.removeItem(getTourCompletedKey(userId))
}
+1
View File
@@ -254,6 +254,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
localStorage.setItem('active_username', username)
localStorage.setItem('active_userid', result.userId)
rememberUsername(username)
sessionStorage.setItem('seed_demo_logbook', '1')
}
return {
+12
View File
@@ -6,6 +6,7 @@ export interface LocalLogbook {
updatedAt: string
isSynced: number // 1 = yes, 0 = pending local modifications
isShared?: number // 1 = collaborator copy, 0 or unset = owned
isDemo?: number // 1 = demo logbook seeded at registration
}
export interface LocalYacht {
@@ -132,6 +133,17 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(5).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
}
}
+331
View File
@@ -0,0 +1,331 @@
import { createLogbook } from './logbook.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { parseTrackFile } from './trackUpload.js'
import { syncLogbook } from './sync.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'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
export function getDemoLogbookStorageKey(userId: string): string {
return `demo_logbook_id_${userId}`
}
export function getDemoFirstEntryStorageKey(userId: string): string {
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(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
payloadId: string,
data: unknown,
now: string
): Promise<void> {
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'gpsTrack') {
await db.gpsTracks.put({
entryId: payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const isDe = i18n.language.startsWith('de')
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)
const crewId = crypto.randomUUID()
const crewData = {
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 {
logbookId: string
title: string
firstEntryId: string
}
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (existingId) {
const existing = await db.logbooks.get(existingId)
if (existing) {
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
}
if (!shouldSeed) return null
sessionStorage.removeItem(SEED_DEMO_FLAG)
const title = i18n.t('demo.logbook_title')
const logbook = await createLogbook(title)
const logbookId = logbook.id
await db.logbooks.update(logbookId, { isDemo: 1 })
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not available for demo seed')
const now = new Date().toISOString()
await seedYachtAndCrew(logbookId, key, now)
const days = buildDemoDays()
let firstEntryId = ''
for (const day of days) {
const entryId = crypto.randomUUID()
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)
const trackData = {
waypoints,
gpxContent: day.gpx,
filename: day.filename,
fileType: 'gpx'
}
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
}
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
return { logbookId, title, firstEntryId }
}
export function getStoredDemoLogbookId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoLogbookStorageKey(userId))
}
export function getStoredDemoFirstEntryId(): string | null {
const userId = localStorage.getItem('active_userid')
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
+6 -2
View File
@@ -11,6 +11,7 @@ export interface DecryptedLogbook {
updatedAt: string
isSynced: boolean
isShared: boolean
isDemo?: boolean
}
// Helper to decrypt a logbook's title using the active logbook key or master key
@@ -98,12 +99,14 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
}
// Update Dexie database cache
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
id: lb.id,
encryptedTitle: lb.encryptedTitle,
updatedAt: lb.updatedAt || new Date().toISOString(),
isSynced: 1,
isShared: lb.userId !== userId ? 1 : 0
isShared: lb.userId !== userId ? 1 : 0,
isDemo: localById.get(lb.id)?.isDemo
}))
// Clear existing cache for this user and insert new ones
@@ -126,7 +129,8 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
title,
updatedAt: lb.updatedAt,
isSynced: lb.isSynced === 1,
isShared: lb.isShared === 1
isShared: lb.isShared === 1,
isDemo: lb.isDemo === 1
})
}
+5
View File
@@ -2,3 +2,8 @@
/// <reference types="vite-plugin-pwa/react" />
declare const __APP_VERSION__: string
declare module '*?raw' {
const content: string
export default content
}
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Generates demo GPX tracks (LaboeDamp, DampSchleimünde) in Kapteins Daagbok format.
*/
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const outDir = join(__dirname, '../client/src/assets/demo')
const NM_IN_METERS = 1852
function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000
const toRad = (d) => (d * Math.PI) / 180
const dLat = toRad(lat2 - lat1)
const dLon = toRad(lon2 - lon1)
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function bearingDeg(lat1, lon1, lat2, lon2) {
const toRad = (d) => (d * Math.PI) / 180
const toDeg = (r) => (r * 180) / Math.PI
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δλ = toRad(lon2 - lon1)
const y = Math.sin(Δλ) * Math.cos(φ2)
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
return (toDeg(Math.atan2(y, x)) + 360) % 360
}
function generateTrack({ name, desc, start, end, distanceNm, startTime, avgSpeedKn = 4.5 }) {
const totalM = distanceNm * NM_IN_METERS
const numPoints = Math.max(40, Math.round(distanceNm * 25))
const course = bearingDeg(start.lat, start.lon, end.lat, end.lon)
const durationSec = (distanceNm / avgSpeedKn) * 3600
const startMs = new Date(startTime).getTime()
const points = []
for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1)
const lat = start.lat + (end.lat - start.lat) * t
const lon = start.lon + (end.lon - start.lon) * t
const ts = new Date(startMs + durationSec * t * 1000).toISOString()
const speedMs = (avgSpeedKn / 1.94384) * (0.85 + 0.3 * Math.sin(i * 0.4))
points.push({ lat, lon, ts, speedMs, course })
}
// Rescale last segment to hit target distance approximately
let acc = 0
for (let i = 1; i < points.length; i++) {
acc += haversineMeters(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
}
const scale = totalM / acc
const adjusted = [{ ...points[0] }]
for (let i = 1; i < points.length; i++) {
const prev = adjusted[i - 1]
const raw = points[i]
const seg = haversineMeters(prev.lat, prev.lon, raw.lat, raw.lon) * scale
const bearing = bearingDeg(prev.lat, prev.lon, raw.lat, raw.lon)
const R = 6371000
const br = (bearing * Math.PI) / 180
const lat1 = (prev.lat * Math.PI) / 180
const lon1 = (prev.lon * Math.PI) / 180
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(seg / R) + Math.cos(lat1) * Math.sin(seg / R) * Math.cos(br)
)
const lon2 =
lon1 +
Math.atan2(
Math.sin(br) * Math.sin(seg / R) * Math.cos(lat1),
Math.cos(seg / R) - Math.sin(lat1) * Math.sin(lat2)
)
adjusted.push({
lat: (lat2 * 180) / Math.PI,
lon: (lon2 * 180) / Math.PI,
ts: raw.ts,
speedMs: raw.speedMs,
course: raw.course
})
}
adjusted[adjusted.length - 1] = { ...adjusted.at(-1), lat: end.lat, lon: end.lon }
const trkpts = adjusted
.map(
(p) => ` <trkpt lat="${p.lat.toFixed(6)}" lon="${p.lon.toFixed(6)}">
<time>${p.ts}</time>
<ele>1.0</ele>
<speed>${p.speedMs.toFixed(3)}</speed>
<course>${p.course.toFixed(1)}</course>
</trkpt>`
)
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Kapteins Daagbok Demo" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${name}</name>
<desc>${desc}</desc>
<time>${startTime}</time>
</metadata>
<trk>
<name>${name}</name>
<type>sailing</type>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>
`
}
mkdirSync(outDir, { recursive: true })
const laboeDamp = generateTrack({
name: 'Laboe → Damp',
desc: 'Demo track Laboe to Damp, ~8 sm',
start: { lat: 54.397929, lon: 10.224316 },
end: { lat: 54.455, lon: 10.729 },
distanceNm: 8,
startTime: '2026-05-30T09:00:00Z',
avgSpeedKn: 4.2
})
const dampSchleimuende = generateTrack({
name: 'Damp → Schleimünde',
desc: 'Demo track Damp to Schleimünde, ~12 sm',
start: { lat: 54.455, lon: 10.729 },
end: { lat: 54.493, lon: 9.933 },
distanceNm: 12,
startTime: '2026-05-31T08:30:00Z',
avgSpeedKn: 4.8
})
writeFileSync(join(outDir, 'laboe-damp.gpx'), laboeDamp, 'utf8')
writeFileSync(join(outDir, 'damp-schleimuende.gpx'), dampSchleimuende, 'utf8')
console.log('Wrote laboe-damp.gpx and damp-schleimuende.gpx to', outDir)