3504ec97cc
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>
250 lines
8.3 KiB
TypeScript
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
|
|
}
|