fix: Onboarding-Tour bei gelöschtem Demo-Logbuch und GPS-Schritt stabilisieren
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 <cursoragent@cursor.com>
This commit is contained in:
+53
-28
@@ -46,7 +46,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
|||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
getStoredDemoFirstEntryId,
|
resolveTourLogbookContext,
|
||||||
seedDemoLogbookIfNeeded
|
seedDemoLogbookIfNeeded
|
||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
@@ -57,7 +57,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
|||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { confirmLeave } = useUnsavedChangesContext()
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||||
@@ -315,23 +315,39 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
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(() => {
|
useEffect(() => {
|
||||||
registerNavigation({
|
registerNavigation({
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
setSelectedEntryId: setTourSelectedEntryId,
|
||||||
setFeedbackOpen: setTourFeedbackOpen,
|
setFeedbackOpen: setTourFeedbackOpen,
|
||||||
setProfileOpen: setShowUserProfile,
|
setProfileOpen: setShowUserProfile,
|
||||||
|
ensureLogbookForTour: ensureTourLogbookOpen,
|
||||||
setLogbookActive: (active) => {
|
setLogbookActive: (active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
const saved = tourLogbookRef.current
|
void ensureTourLogbookOpen()
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,22 +362,19 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [registerNavigation])
|
}, [ensureTourLogbookOpen, registerNavigation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && activeLogbookId) {
|
if (!isAuthenticated || !activeLogbookId) return
|
||||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
void (async () => {
|
||||||
}
|
const ctx = await resolveTourLogbookContext()
|
||||||
}, [isAuthenticated, activeLogbookId])
|
if (!ctx || ctx.logbookId !== activeLogbookId) return
|
||||||
|
if (ctx.firstEntryId) {
|
||||||
const selectLogbook = (id: string, title: string) => {
|
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||||
setActiveLogbookId(id)
|
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||||
setActiveLogbookTitle(title)
|
}
|
||||||
setActiveTab('logs')
|
})()
|
||||||
setTourSelectedEntryId(null)
|
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
|
||||||
localStorage.setItem('active_logbook_id', id)
|
|
||||||
localStorage.setItem('active_logbook_title', title)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openLogbookById = useCallback(
|
const openLogbookById = useCallback(
|
||||||
async (logbookId: string) => {
|
async (logbookId: string) => {
|
||||||
@@ -377,7 +390,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||||
},
|
},
|
||||||
[]
|
[selectLogbook]
|
||||||
)
|
)
|
||||||
|
|
||||||
const consumePendingPushLogbook = useCallback(() => {
|
const consumePendingPushLogbook = useCallback(() => {
|
||||||
@@ -429,8 +442,20 @@ function App() {
|
|||||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||||
if (savedLogbookId && savedLogbookTitle) {
|
if (savedLogbookId && savedLogbookTitle) {
|
||||||
setActiveLogbookId(savedLogbookId)
|
try {
|
||||||
setActiveLogbookTitle(savedLogbookTitle)
|
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()
|
consumePendingPushLogbook()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ interface SpotlightRect {
|
|||||||
const TOOLTIP_EDGE_MARGIN = 16
|
const TOOLTIP_EDGE_MARGIN = 16
|
||||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||||
const TOOLTIP_WIDTH = 420
|
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 {
|
function computeTooltipLeft(spotlight: SpotlightRect): number {
|
||||||
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
|
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 viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||||
const below = spotlight.top + spotlight.height + 12
|
const below = spotlight.top + spotlight.height + 12
|
||||||
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||||
return below
|
return clampTooltipTop(below)
|
||||||
}
|
}
|
||||||
|
|
||||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||||
return above
|
return clampTooltipTop(above)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(
|
return clampTooltipTop(below)
|
||||||
TOOLTIP_EDGE_MARGIN,
|
}
|
||||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
|
||||||
|
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() {
|
export default function AppTourOverlay() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
@@ -89,25 +111,29 @@ export default function AppTourOverlay() {
|
|||||||
setSpotlight(null)
|
setSpotlight(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
if (rect.width <= 0 || rect.height <= 0) {
|
if (!isTargetVisibleInViewport(rect)) {
|
||||||
setSpotlight(null)
|
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
const next = measureSpotlight(el)
|
||||||
|
setSpotlight(next)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const padding = 8
|
|
||||||
setSpotlight({
|
setSpotlight(measureSpotlight(el))
|
||||||
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()
|
updateSpotlight()
|
||||||
window.addEventListener('resize', updateSpotlight)
|
window.addEventListener('resize', updateSpotlight)
|
||||||
window.addEventListener('scroll', updateSpotlight, true)
|
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))
|
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -365,6 +365,11 @@ export default function LogEntriesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tourFirstEntryId =
|
||||||
|
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
|
||||||
|
? highlightEntryId
|
||||||
|
: entries[0]?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
@@ -402,7 +407,7 @@ export default function LogEntriesList({
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
DEMO_EXCLUDED_STEPS,
|
DEMO_EXCLUDED_STEPS,
|
||||||
DEMO_STEP_ORDER,
|
DEMO_STEP_ORDER,
|
||||||
FULL_STEP_ORDER,
|
FULL_STEP_ORDER,
|
||||||
|
getTourScrollRetryDelays,
|
||||||
|
getTourTargetRetryDelay,
|
||||||
tourStepOpensEntry
|
tourStepOpensEntry
|
||||||
} from './AppTourContext.tsx'
|
} from './AppTourContext.tsx'
|
||||||
|
|
||||||
@@ -31,4 +33,9 @@ describe('AppTourContext step order', () => {
|
|||||||
expect(tourStepOpensEntry('entry_list')).toBe(false)
|
expect(tourStepOpensEntry('entry_list')).toBe(false)
|
||||||
expect(tourStepOpensEntry('entry_track')).toBe(true)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface TourNavigation {
|
|||||||
setFeedbackOpen: (open: boolean) => void
|
setFeedbackOpen: (open: boolean) => void
|
||||||
setLogbookActive: (active: boolean) => void
|
setLogbookActive: (active: boolean) => void
|
||||||
setProfileOpen: (open: boolean) => void
|
setProfileOpen: (open: boolean) => void
|
||||||
|
ensureLogbookForTour?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DemoTourContext {
|
interface DemoTourContext {
|
||||||
@@ -124,11 +125,19 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
if (stepId === 'nav_feedback') return 180
|
if (stepId === 'nav_feedback') return 180
|
||||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||||
return 0
|
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<AppTourContextValue | null>(null)
|
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||||
|
|
||||||
export function AppTourProvider({ children }: { children: ReactNode }) {
|
export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -203,13 +212,19 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
const selector = TARGET_BY_STEP[stepId]
|
const selector = TARGET_BY_STEP[stepId]
|
||||||
if (!selector) return
|
if (!selector) return
|
||||||
const delayMs = getTourTargetDelay(stepId)
|
|
||||||
window.setTimeout(() => {
|
for (const delayMs of getTourScrollRetryDelays(stepId)) {
|
||||||
window.requestAnimationFrame(() => {
|
window.setTimeout(() => {
|
||||||
const el = document.querySelector(selector)
|
window.requestAnimationFrame(() => {
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
const el = document.querySelector(selector)
|
||||||
})
|
el?.scrollIntoView({
|
||||||
}, delayMs)
|
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||||
@@ -276,10 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
applyStepSideEffects(stepId)
|
|
||||||
scrollToCurrentTarget(stepId)
|
let cancelled = false
|
||||||
const timer = window.setTimeout(() => setLayoutTick((tick) => tick + 1), 0)
|
const run = async () => {
|
||||||
return () => window.clearTimeout(timer)
|
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])
|
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||||
|
|
||||||
const restartTour = useCallback(() => {
|
const restartTour = useCallback(() => {
|
||||||
@@ -390,6 +420,7 @@ export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
if (stepId === 'profile_preferences') return 300
|
if (stepId === 'profile_preferences') return 300
|
||||||
if (stepId === 'nav_profile') return 200
|
if (stepId === 'nav_profile') return 200
|
||||||
return 120
|
return 120
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
|
|||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
return { logbookId: existingId, title, firstEntryId }
|
return { logbookId: existingId, title, firstEntryId }
|
||||||
}
|
}
|
||||||
|
clearDemoLogbookRefs(userId, existingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldSeed) return null
|
if (!shouldSeed) return null
|
||||||
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
|
|||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
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<boolean> {
|
||||||
|
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<TourLogbookContext | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
|
|||||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
|
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
|
||||||
|
|
||||||
const API_BASE = '/api/logbooks'
|
const API_BASE = '/api/logbooks'
|
||||||
|
|
||||||
@@ -320,6 +321,9 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
|
|
||||||
// Perform local cascading cleanup
|
// Perform local cascading cleanup
|
||||||
await deleteLocalLogbookCache(id)
|
await deleteLocalLogbookCache(id)
|
||||||
|
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
|
||||||
|
clearDemoLogbookRefs(userId, id)
|
||||||
|
}
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user