From 0d16782001c2a3ebb7a6db103243405b5ac97c71 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 13:15:53 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Onboarding-Tour=20bei=20gel=C3=B6schtem?= =?UTF-8?q?=20Demo-Logbuch=20und=20GPS-Schritt=20stabilisieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bereinigt veraltete Demo-Referenzen, löst gültiges Logbuch und ersten Eintrag zur Laufzeit auf und scrollt den GPS-Track-Schritt automatisch ins Viewport. Co-authored-by: Cursor --- client/src/App.tsx | 81 +++++++++++++++-------- client/src/components/AppTourOverlay.tsx | 56 +++++++++++----- client/src/components/LogEntriesList.tsx | 7 +- client/src/context/AppTourContext.test.ts | 7 ++ client/src/context/AppTourContext.tsx | 53 ++++++++++++--- client/src/services/demoLogbook.test.ts | 36 ++++++++++ client/src/services/demoLogbook.ts | 64 ++++++++++++++++++ client/src/services/logbook.ts | 4 ++ 8 files changed, 253 insertions(+), 55 deletions(-) create mode 100644 client/src/services/demoLogbook.test.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index f82e3c6..a4e4761 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -46,7 +46,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import { useTranslation } from 'react-i18next' import { - getStoredDemoFirstEntryId, + resolveTourLogbookContext, seedDemoLogbookIfNeeded } from './services/demoLogbook.js' import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js' @@ -57,7 +57,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id' function App() { const { t, i18n } = useTranslation() const { confirmLeave } = useUnsavedChangesContext() - const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour() + const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour() const [isAuthenticated, setIsAuthenticated] = useState(false) const [activeLogbookId, setActiveLogbookId] = useState(null) const [activeLogbookTitle, setActiveLogbookTitle] = useState(null) @@ -315,23 +315,39 @@ function App() { setIsAcceptingInvite(false) }, []) + const selectLogbook = 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 ensureTourLogbookOpen = useCallback(async () => { + const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id) + if (!ctx) return + + if (activeLogbookRef.current.id !== ctx.logbookId) { + selectLogbook(ctx.logbookId, ctx.title) + } + + if (ctx.firstEntryId) { + setDemoHighlightEntryId(ctx.firstEntryId) + registerDemoTourContext({ firstEntryId: ctx.firstEntryId }) + } + }, [registerDemoTourContext, selectLogbook]) + useEffect(() => { registerNavigation({ setActiveTab, setSelectedEntryId: setTourSelectedEntryId, setFeedbackOpen: setTourFeedbackOpen, setProfileOpen: setShowUserProfile, + ensureLogbookForTour: ensureTourLogbookOpen, 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) - } + void ensureTourLogbookOpen() return } @@ -346,22 +362,19 @@ function App() { localStorage.removeItem('active_logbook_title') } }) - }, [registerNavigation]) + }, [ensureTourLogbookOpen, registerNavigation]) useEffect(() => { - if (isAuthenticated && activeLogbookId) { - setDemoHighlightEntryId(getStoredDemoFirstEntryId()) - } - }, [isAuthenticated, activeLogbookId]) - - const selectLogbook = (id: string, title: string) => { - setActiveLogbookId(id) - setActiveLogbookTitle(title) - setActiveTab('logs') - setTourSelectedEntryId(null) - localStorage.setItem('active_logbook_id', id) - localStorage.setItem('active_logbook_title', title) - } + if (!isAuthenticated || !activeLogbookId) return + void (async () => { + const ctx = await resolveTourLogbookContext() + if (!ctx || ctx.logbookId !== activeLogbookId) return + if (ctx.firstEntryId) { + setDemoHighlightEntryId(ctx.firstEntryId) + registerDemoTourContext({ firstEntryId: ctx.firstEntryId }) + } + })() + }, [isAuthenticated, activeLogbookId, registerDemoTourContext]) const openLogbookById = useCallback( async (logbookId: string) => { @@ -377,7 +390,7 @@ function App() { } selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`) }, - [] + [selectLogbook] ) const consumePendingPushLogbook = useCallback(() => { @@ -429,8 +442,20 @@ function App() { const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookTitle = localStorage.getItem('active_logbook_title') if (savedLogbookId && savedLogbookTitle) { - setActiveLogbookId(savedLogbookId) - setActiveLogbookTitle(savedLogbookTitle) + try { + const books = await fetchLogbooks() + const match = books.find((b) => b.id === savedLogbookId) + if (match) { + setActiveLogbookId(match.id) + setActiveLogbookTitle(match.title) + } else { + localStorage.removeItem('active_logbook_id') + localStorage.removeItem('active_logbook_title') + } + } catch { + setActiveLogbookId(savedLogbookId) + setActiveLogbookTitle(savedLogbookTitle) + } } consumePendingPushLogbook() } diff --git a/client/src/components/AppTourOverlay.tsx b/client/src/components/AppTourOverlay.tsx index ce07736..2fe8555 100644 --- a/client/src/components/AppTourOverlay.tsx +++ b/client/src/components/AppTourOverlay.tsx @@ -19,6 +19,12 @@ interface SpotlightRect { const TOOLTIP_EDGE_MARGIN = 16 const TOOLTIP_ESTIMATED_HEIGHT = 240 const TOOLTIP_WIDTH = 420 +const TARGET_VIEWPORT_MARGIN = 24 + +function clampTooltipTop(preferred: number): number { + const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT + return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop)) +} function computeTooltipLeft(spotlight: SpotlightRect): number { const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2) @@ -37,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number { const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN const below = spotlight.top + spotlight.height + 12 if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) { - return below + return clampTooltipTop(below) } const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT if (above >= TOOLTIP_EDGE_MARGIN) { - return above + return clampTooltipTop(above) } - return Math.max( - TOOLTIP_EDGE_MARGIN, - Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT) + return clampTooltipTop(below) +} + +function isTargetVisibleInViewport(rect: DOMRect): boolean { + return ( + rect.top >= TARGET_VIEWPORT_MARGIN && + rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN ) } +function measureSpotlight(el: Element): SpotlightRect | null { + const rect = el.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return null + const padding = 8 + return { + top: Math.max(8, rect.top - padding), + left: Math.max(8, rect.left - padding), + width: rect.width + padding * 2, + height: rect.height + padding * 2 + } +} + export default function AppTourOverlay() { const { t } = useTranslation() const { @@ -89,25 +111,29 @@ export default function AppTourOverlay() { setSpotlight(null) return } + const rect = el.getBoundingClientRect() - if (rect.width <= 0 || rect.height <= 0) { - setSpotlight(null) + if (!isTargetVisibleInViewport(rect)) { + el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }) + window.requestAnimationFrame(() => { + if (cancelled) return + const next = measureSpotlight(el) + setSpotlight(next) + }) return } - 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 - }) + + setSpotlight(measureSpotlight(el)) } updateSpotlight() window.addEventListener('resize', updateSpotlight) window.addEventListener('scroll', updateSpotlight, true) - const retryDelays = [getTourTargetRetryDelay(currentStepId), 120, 280, 480] + const retryDelays = + currentStepId === 'entry_track' + ? [400, 700, 1100, 1600] + : [getTourTargetRetryDelay(currentStepId), 120, 280, 480] const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay)) return () => { diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index b52faae..a58df73 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -365,6 +365,11 @@ export default function LogEntriesList({ ) } + const tourFirstEntryId = + highlightEntryId && entries.some((e) => e.id === highlightEntryId) + ? highlightEntryId + : entries[0]?.id ?? null + return (
@@ -402,7 +407,7 @@ export default function LogEntriesList({
setSelectedEntryId(item.id)} >
diff --git a/client/src/context/AppTourContext.test.ts b/client/src/context/AppTourContext.test.ts index 28fe342..f448541 100644 --- a/client/src/context/AppTourContext.test.ts +++ b/client/src/context/AppTourContext.test.ts @@ -3,6 +3,8 @@ import { DEMO_EXCLUDED_STEPS, DEMO_STEP_ORDER, FULL_STEP_ORDER, + getTourScrollRetryDelays, + getTourTargetRetryDelay, tourStepOpensEntry } from './AppTourContext.tsx' @@ -31,4 +33,9 @@ describe('AppTourContext step order', () => { expect(tourStepOpensEntry('entry_list')).toBe(false) expect(tourStepOpensEntry('entry_track')).toBe(true) }) + + it('retries scroll for entry_track while editor mounts', () => { + expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400) + expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1) + }) }) diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx index 9dc8fe9..f86da7e 100644 --- a/client/src/context/AppTourContext.tsx +++ b/client/src/context/AppTourContext.tsx @@ -39,6 +39,7 @@ interface TourNavigation { setFeedbackOpen: (open: boolean) => void setLogbookActive: (active: boolean) => void setProfileOpen: (open: boolean) => void + ensureLogbookForTour?: () => Promise } interface DemoTourContext { @@ -124,11 +125,19 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean { } export function getTourTargetDelay(stepId: TourStepId): number { + if (stepId === 'entry_track') return 400 if (stepId === 'nav_feedback') return 180 if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250 return 0 } +/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */ +export function getTourScrollRetryDelays(stepId: TourStepId): number[] { + if (stepId === 'entry_track') return [400, 700, 1100, 1600] + const initial = getTourTargetDelay(stepId) + return initial > 0 ? [initial] : [0] +} + const AppTourContext = createContext(null) export function AppTourProvider({ children }: { children: ReactNode }) { @@ -203,13 +212,19 @@ export function AppTourProvider({ children }: { children: ReactNode }) { if (!stepId) return const selector = TARGET_BY_STEP[stepId] if (!selector) return - const delayMs = getTourTargetDelay(stepId) - window.setTimeout(() => { - window.requestAnimationFrame(() => { - const el = document.querySelector(selector) - el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) - }) - }, delayMs) + + for (const delayMs of getTourScrollRetryDelays(stepId)) { + window.setTimeout(() => { + window.requestAnimationFrame(() => { + const el = document.querySelector(selector) + el?.scrollIntoView({ + behavior: stepId === 'entry_track' ? 'instant' : 'smooth', + block: 'center', + inline: 'nearest' + }) + }) + }, delayMs) + } }, []) const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => { @@ -276,10 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) { if (!isActive) return const stepId = getStepOrder(isDemoTour)[stepIndex] if (!stepId) return - applyStepSideEffects(stepId) - scrollToCurrentTarget(stepId) - const timer = window.setTimeout(() => setLayoutTick((tick) => tick + 1), 0) - return () => window.clearTimeout(timer) + + let cancelled = false + const run = async () => { + if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) { + await navigationRef.current?.ensureLogbookForTour?.() + } + if (cancelled) return + applyStepSideEffects(stepId) + scrollToCurrentTarget(stepId) + setLayoutTick((tick) => tick + 1) + window.setTimeout(() => { + if (!cancelled) setLayoutTick((tick) => tick + 1) + }, 150) + } + void run() + + return () => { + cancelled = true + } }, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget]) const restartTour = useCallback(() => { @@ -390,6 +420,7 @@ export function isCenteredTourStep(stepId: TourStepId | null): boolean { } export function getTourTargetRetryDelay(stepId: TourStepId | null): number { + if (stepId === 'entry_track') return 400 if (stepId === 'profile_preferences') return 300 if (stepId === 'nav_profile') return 200 return 120 diff --git a/client/src/services/demoLogbook.test.ts b/client/src/services/demoLogbook.test.ts new file mode 100644 index 0000000..a61fd42 --- /dev/null +++ b/client/src/services/demoLogbook.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearDemoLogbookRefs, + getDemoFirstEntryStorageKey, + getDemoLogbookStorageKey +} from './demoLogbook.js' + +describe('clearDemoLogbookRefs', () => { + const userId = 'user-1' + + beforeEach(() => { + localStorage.clear() + localStorage.setItem('active_userid', userId) + }) + + it('removes demo logbook and first-entry keys for the user', () => { + const logbookId = 'lb-demo' + localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId) + localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1') + + clearDemoLogbookRefs(userId, logbookId) + + expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull() + expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull() + }) + + it('does not clear refs when logbookId does not match stored demo id', () => { + localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook') + localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1') + + clearDemoLogbookRefs(userId, 'deleted-logbook') + + expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook') + expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1') + }) +}) diff --git a/client/src/services/demoLogbook.ts b/client/src/services/demoLogbook.ts index b80e67d..ca20ea4 100644 --- a/client/src/services/demoLogbook.ts +++ b/client/src/services/demoLogbook.ts @@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise const title = i18n.t('demo.logbook_title') return { logbookId: existingId, title, firstEntryId } } + clearDemoLogbookRefs(userId, existingId) } if (!shouldSeed) return null @@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null { if (!userId) return null return localStorage.getItem(getDemoFirstEntryStorageKey(userId)) } + +/** Remove persisted demo logbook pointers when the logbook no longer exists. */ +export function clearDemoLogbookRefs(userId: string, logbookId?: string): void { + const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId)) + if (logbookId && storedId && storedId !== logbookId) return + localStorage.removeItem(getDemoLogbookStorageKey(userId)) + localStorage.removeItem(getDemoFirstEntryStorageKey(userId)) +} + +export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise { + const entry = await db.entries.get(entryId) + return entry?.logbookId === logbookId +} + +export interface TourLogbookContext { + logbookId: string + title: string + firstEntryId: string | null +} + +/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */ +export async function resolveTourLogbookContext( + preferLogbookId?: string | null +): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId || !getActiveMasterKey()) return null + + const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId)) + if (demoId && !(await db.logbooks.get(demoId))) { + clearDemoLogbookRefs(userId, demoId) + } + + const { fetchLogbooks } = await import('./logbook.js') + const books = await fetchLogbooks() + if (books.length === 0) return null + + const activeId = localStorage.getItem('active_logbook_id') + const pick = + (preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ?? + (activeId ? books.find((b) => b.id === activeId) : undefined) ?? + (demoId ? books.find((b) => b.id === demoId) : undefined) ?? + books[0] + + const firstEntryId = await resolveTourFirstEntryId(pick.id, userId) + return { logbookId: pick.id, title: pick.title, firstEntryId } +} + +async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise { + const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) + if (stored && (await entryExistsInLogbook(logbookId, stored))) { + return stored + } + + if (stored) { + localStorage.removeItem(getDemoFirstEntryStorageKey(userId)) + } + + const localEntries = await db.entries.where({ logbookId }).toArray() + if (localEntries.length === 0) return null + + localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + return localEntries[0]?.payloadId ?? null +} diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts index a5aa78d..6f76c7a 100644 --- a/client/src/services/logbook.ts +++ b/client/src/services/logbook.ts @@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { apiFetch } from './api.js' +import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js' const API_BASE = '/api/logbooks' @@ -320,6 +321,9 @@ export async function deleteLogbook(id: string): Promise { // Perform local cascading cleanup await deleteLocalLogbookCache(id) + if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) { + clearDemoLogbookRefs(userId, id) + } trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED) }