diff --git a/client/src/App.tsx b/client/src/App.tsx
index 667b077..f82e3c6 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -69,6 +69,12 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
+ const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
+ const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
+ id: activeLogbookId,
+ title: activeLogbookTitle
+ })
+ activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
// Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -313,7 +319,32 @@ function App() {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
- setFeedbackOpen: setTourFeedbackOpen
+ setFeedbackOpen: setTourFeedbackOpen,
+ setProfileOpen: setShowUserProfile,
+ setLogbookActive: (active) => {
+ if (active) {
+ const saved = tourLogbookRef.current
+ const id = saved?.id ?? localStorage.getItem('active_logbook_id')
+ const title = saved?.title ?? localStorage.getItem('active_logbook_title')
+ if (id && title) {
+ setActiveLogbookId(id)
+ setActiveLogbookTitle(title)
+ localStorage.setItem('active_logbook_id', id)
+ localStorage.setItem('active_logbook_title', title)
+ }
+ return
+ }
+
+ const { id, title } = activeLogbookRef.current
+ if (id && title) {
+ tourLogbookRef.current = { id, title }
+ }
+ setActiveLogbookId(null)
+ setActiveLogbookTitle(null)
+ setTourSelectedEntryId(null)
+ localStorage.removeItem('active_logbook_id')
+ localStorage.removeItem('active_logbook_title')
+ }
})
}, [registerNavigation])
diff --git a/client/src/components/AppTourOverlay.tsx b/client/src/components/AppTourOverlay.tsx
index 0f45b38..8e06b04 100644
--- a/client/src/components/AppTourOverlay.tsx
+++ b/client/src/components/AppTourOverlay.tsx
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
+ getTourTargetRetryDelay,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
@@ -89,10 +90,15 @@ export default function AppTourOverlay() {
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
- const timer = window.setTimeout(updateSpotlight, 120)
+ const retryDelay = getTourTargetRetryDelay(currentStepId)
+ const timer = window.setTimeout(updateSpotlight, retryDelay)
+ const retryTimer = retryDelay > 120
+ ? window.setTimeout(updateSpotlight, retryDelay + 180)
+ : undefined
return () => {
window.clearTimeout(timer)
+ if (retryTimer !== undefined) window.clearTimeout(retryTimer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx
index 7bc0fe5..1a3eb25 100644
--- a/client/src/components/LogbookDashboard.tsx
+++ b/client/src/components/LogbookDashboard.tsx
@@ -314,6 +314,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
+ data-tour="nav-profile"
>
{username}
diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx
index 3ee81d6..f55c2c4 100644
--- a/client/src/components/UserProfilePage.tsx
+++ b/client/src/components/UserProfilePage.tsx
@@ -443,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
) : profile ? (
<>
+
@@ -484,6 +485,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
+
diff --git a/client/src/context/AppTourContext.test.ts b/client/src/context/AppTourContext.test.ts
new file mode 100644
index 0000000..28fe342
--- /dev/null
+++ b/client/src/context/AppTourContext.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import {
+ DEMO_EXCLUDED_STEPS,
+ DEMO_STEP_ORDER,
+ FULL_STEP_ORDER,
+ tourStepOpensEntry
+} from './AppTourContext.tsx'
+
+describe('AppTourContext step order', () => {
+ it('includes profile steps before finish in full tour', () => {
+ const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
+ const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
+ const finishIndex = FULL_STEP_ORDER.indexOf('finish')
+
+ expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
+ expect(prefsIndex).toBe(profileIndex + 1)
+ expect(finishIndex).toBe(prefsIndex + 1)
+ expect(FULL_STEP_ORDER).toHaveLength(12)
+ })
+
+ it('excludes profile, stats and feedback from demo tour', () => {
+ for (const step of DEMO_EXCLUDED_STEPS) {
+ expect(DEMO_STEP_ORDER).not.toContain(step)
+ }
+ expect(DEMO_STEP_ORDER).toContain('finish')
+ expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
+ })
+
+ it('only opens entry editor on entry_track step', () => {
+ expect(tourStepOpensEntry('entry_open')).toBe(false)
+ expect(tourStepOpensEntry('entry_list')).toBe(false)
+ expect(tourStepOpensEntry('entry_track')).toBe(true)
+ })
+})
diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx
index e7a44b6..414a278 100644
--- a/client/src/context/AppTourContext.tsx
+++ b/client/src/context/AppTourContext.tsx
@@ -29,12 +29,16 @@ export type TourStepId =
| 'nav_crew'
| 'nav_stats'
| 'nav_feedback'
+ | 'nav_profile'
+ | 'profile_preferences'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
+ setLogbookActive: (active: boolean) => void
+ setProfileOpen: (open: boolean) => void
}
interface DemoTourContext {
@@ -58,7 +62,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
-const FULL_STEP_ORDER: TourStepId[] = [
+export const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
@@ -68,12 +72,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
'nav_crew',
'nav_stats',
'nav_feedback',
+ 'nav_profile',
+ 'profile_preferences',
'finish'
]
-/** Public demo has no stats/feedback UI — skip those steps. */
-const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
-const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
+/** Public demo has no stats/feedback/profile UI — skip those steps. */
+export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
+ 'nav_stats',
+ 'nav_feedback',
+ 'nav_profile',
+ 'profile_preferences'
+]
+
+export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
+ (id) => !DEMO_EXCLUDED_STEPS.includes(id)
+)
+
+const LOGBOOK_TOUR_STEPS = new Set
([
+ 'nav_logs',
+ 'entry_list',
+ 'entry_open',
+ 'entry_track',
+ 'nav_vessel',
+ 'nav_crew',
+ 'nav_stats',
+ 'nav_feedback'
+])
function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
@@ -87,7 +112,20 @@ const TARGET_BY_STEP: Partial> = {
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
- nav_feedback: '[data-tour="feedback-form"]'
+ nav_feedback: '[data-tour="feedback-form"]',
+ nav_profile: '[data-tour="nav-profile"]',
+ profile_preferences: '[data-tour="profile-preferences"]'
+}
+
+/** Whether a tour step opens the first log entry editor (not the list card). */
+export function tourStepOpensEntry(stepId: TourStepId): boolean {
+ return stepId === 'entry_track'
+}
+
+export function getTourTargetDelay(stepId: TourStepId): number {
+ if (stepId === 'nav_feedback') return 180
+ if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
+ return 0
}
const AppTourContext = createContext(null)
@@ -112,13 +150,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current
if (!nav) return
+ if (LOGBOOK_TOUR_STEPS.has(stepId)) {
+ nav.setProfileOpen(false)
+ nav.setLogbookActive(true)
+ }
+
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
- if (stepId === 'entry_open' || stepId === 'entry_track') {
+
+ if (stepId === 'entry_list' || stepId === 'entry_open') {
+ nav.setSelectedEntryId(null)
+ } else if (tourStepOpensEntry(stepId)) {
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
+ } else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
+ nav.setSelectedEntryId(null)
}
+
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
@@ -137,13 +186,22 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
} else {
nav.setFeedbackOpen(false)
}
+
+ if (stepId === 'nav_profile') {
+ nav.setProfileOpen(false)
+ nav.setLogbookActive(false)
+ }
+ if (stepId === 'profile_preferences') {
+ nav.setLogbookActive(false)
+ nav.setProfileOpen(true)
+ }
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
- const delayMs = stepId === 'nav_feedback' ? 180 : 0
+ const delayMs = getTourTargetDelay(stepId)
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
@@ -173,6 +231,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
+ nav.setProfileOpen(false)
+ nav.setLogbookActive(true)
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
@@ -183,6 +243,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
+ navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -321,3 +382,9 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
+
+export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
+ if (stepId === 'profile_preferences') return 300
+ if (stepId === 'nav_profile') return 200
+ return 120
+}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json
index 9e51909..9671ba0 100644
--- a/client/src/i18n/locales/de.json
+++ b/client/src/i18n/locales/de.json
@@ -672,9 +672,17 @@
"title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
},
+ "nav_profile": {
+ "title": "Dein Benutzerprofil",
+ "body": "Über den Skipper-Button oben erreichst du dein persönliches Profil – unabhängig vom aktuellen Logbuch."
+ },
+ "profile_preferences": {
+ "title": "Konto & Darstellung",
+ "body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
+ },
"finish": {
"title": "Alles klar!",
- "body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
+ "body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
}
}
},
diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json
index 4aa2648..322c8f3 100644
--- a/client/src/i18n/locales/en.json
+++ b/client/src/i18n/locales/en.json
@@ -672,9 +672,17 @@
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
+ "nav_profile": {
+ "title": "Your user profile",
+ "body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
+ },
+ "profile_preferences": {
+ "title": "Account & appearance",
+ "body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
+ },
"finish": {
"title": "You're all set!",
- "body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
+ "body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
}
}
},