diff --git a/client/src/App.css b/client/src/App.css index 4eebde7..9e34192 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index cc2e64a..42e6553 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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(null) const [activeLogbookTitle, setActiveLogbookTitle] = useState(null) - const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs' | 'settings'>('logs') + const [activeTab, setActiveTab] = useState('logs') + const [tourSelectedEntryId, setTourSelectedEntryId] = useState(null) + const [demoHighlightEntryId, setDemoHighlightEntryId] = useState(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() { + +

+ {t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })} +

+

{title}

+

{body}

+ +
+ + +
+ + +
+
+ + + ) +} diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index c4b4ce3..4b06c1e 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -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([]) - const [selectedEntryId, setSelectedEntryId] = useState(null) + const [internalSelectedEntryId, setInternalSelectedEntryId] = useState(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(null) @@ -356,9 +373,14 @@ export default function LogEntriesList({ {entries.length === 0 ? (
{t('logs.no_entries')}
) : ( -
+
{entries.map((item) => ( -
setSelectedEntryId(item.id)}> +
setSelectedEntryId(item.id)} + >
diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 329e993..e94deea 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -1326,7 +1326,7 @@ export default function LogEntryEditor({
{/* Track file upload */} -
+

{t('logs.track_upload_title')}

diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index b4f720f..7bb0105 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -209,6 +209,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD {lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')} + {lb.isDemo && ( + {t('demo.badge')} + )} {new Date(lb.updatedAt).toLocaleDateString(i18n.language, { year: 'numeric', diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index aec40a1..28789e3 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -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) {
+
+
+ +

+ {t('settings.tour_title')} +

+
+

+ {t('settings.tour_desc')} +

+ +
+
{success && (
diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx new file mode 100644 index 0000000..7c6f9e9 --- /dev/null +++ b/client/src/context/AppTourContext.tsx @@ -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> = { + 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(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(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( + () => ({ + 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 {children} +} + +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' +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 1a7e59c..8d932b7 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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!" + } + } } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 055d36f..896bd3f 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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!" + } + } } } } diff --git a/client/src/services/appTourStorage.ts b/client/src/services/appTourStorage.ts new file mode 100644 index 0000000..03c3c06 --- /dev/null +++ b/client/src/services/appTourStorage.ts @@ -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)) +} diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 7f4876d..c6587b2 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -254,6 +254,7 @@ export async function registerUser(username: string): Promise> +} + +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 { + 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 { + 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 { + 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 = { + 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)) +} diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts index 85dc456..3c0d229 100644 --- a/client/src/services/logbook.ts +++ b/client/src/services/logbook.ts @@ -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 { } // 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 { title, updatedAt: lb.updatedAt, isSynced: lb.isSynced === 1, - isShared: lb.isShared === 1 + isShared: lb.isShared === 1, + isDemo: lb.isDemo === 1 }) } diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index c98a80e..961b092 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -2,3 +2,8 @@ /// declare const __APP_VERSION__: string + +declare module '*?raw' { + const content: string + export default content +} diff --git a/scripts/generate-demo-tracks.mjs b/scripts/generate-demo-tracks.mjs new file mode 100644 index 0000000..6200b8b --- /dev/null +++ b/scripts/generate-demo-tracks.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Generates demo GPX tracks (Laboe→Damp, Damp→Schleimü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) => ` + + 1.0 + ${p.speedMs.toFixed(3)} + ${p.course.toFixed(1)} + ` + ) + .join('\n') + + return ` + + + ${name} + ${desc} + + + + ${name} + sailing + +${trkpts} + + + +` +} + +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)