Files
kapteins-daagbok/client/src/services/demoLogbook.ts
T
elpatron 3504ec97cc Add account-level crew pool with per-logbook and per-day selection.
Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:05:50 +02:00

250 lines
8.3 KiB
TypeScript

import { createLogbook } from './logbook.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
export function getDemoLogbookStorageKey(userId: string): string {
return `demo_logbook_id_${userId}`
}
export function getDemoFirstEntryStorageKey(userId: string): string {
return `demo_first_entry_id_${userId}`
}
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
): Promise<void> {
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === '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
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
logbookId: string
title: string
firstEntryId: string
}
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (existingId) {
const existing = await db.logbooks.get(existingId)
if (existing) {
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
clearDemoLogbookRefs(userId, existingId)
}
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 entryPayloads = buildDemoEntryPayloads()
let firstEntryId = ''
for (const { entryId, entryPayload, trackData } of entryPayloads) {
if (!firstEntryId) firstEntryId = entryId
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
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))
}
/** 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
}