feat(vessel): Schiffsflotte im Profil und Logbuch-Auswahl

Benutzerweiter Vessel-Pool (E2E, Sync, Migration von Legacy-Yachts) mit
LogbookVesselSelection und LogbookVesselPicker. Profil mit Accordion
(Flotte & Crew); Demo und Onboarding-Tour inkl. profile_vessel_pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 21:25:08 +02:00
parent 182ea497d8
commit ec11dd8d2b
39 changed files with 2107 additions and 113 deletions
+6 -1
View File
@@ -558,7 +558,12 @@ export async function deleteAccount(): Promise<boolean> {
db.photos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear()
db.logbookKeys.clear(),
db.personPool.clear(),
db.vesselPool.clear(),
db.logbookCrewSelections.clear(),
db.logbookVesselSelections.clear(),
db.userSyncQueue.clear()
])
// Wipe localStorage and session variables
+11 -16
View File
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
throw new Error('Encryption key not found. User must log in.')
}
// 1. Fetch Yacht details
const yachtRecord = await db.yachts.get(logbookId);
if (yachtRecord) {
try {
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
yachtName = yacht.name || '';
homePort = yacht.port || '';
owner = yacht.owner || '';
charter = yacht.charter || '';
registration = yacht.registration || '';
callsign = yacht.callsign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for CSV:', e);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
owner = yacht.owner || ''
charter = yacht.charterCompany || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch logbook entries
+47 -2
View File
@@ -88,6 +88,14 @@ export interface LocalPerson {
updatedAt: string
}
export interface LocalVessel {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
@@ -96,10 +104,27 @@ export interface LocalLogbookCrewSelection {
updatedAt: string
}
export interface LocalLogbookVesselSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
type:
| 'yacht'
| 'crew'
| 'deviation'
| 'entry'
| 'logbook'
| 'photo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
@@ -109,7 +134,7 @@ export interface SyncQueueItem {
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
type: 'person' | 'vessel'
payloadId: string
data: string
updatedAt: string
@@ -135,7 +160,9 @@ class DaagboxDatabase extends Dexie {
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
vesselPool!: Table<LocalVessel>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
@@ -234,6 +261,24 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(9).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+33
View File
@@ -17,6 +17,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -50,9 +51,19 @@ export interface DemoCrewRecord {
}
}
export interface DemoVesselRecord {
payloadId: string
data: Record<string, unknown> & { name: string }
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
vesselPool: DemoVesselRecord[]
logbookVesselSelection: {
activeVesselId: string | null
vesselSnapshot: Record<string, unknown> | null
}
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
@@ -238,6 +249,24 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
return [
{
payloadId: PUBLIC_DEMO_VESSEL_ID,
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
}
]
}
function buildDemoLogbookVesselSelection(
yacht: Record<string, unknown>
): PublicDemoFixture['logbookVesselSelection'] {
return {
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
}
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
@@ -255,6 +284,8 @@ function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const vesselPool = buildDemoVesselPool(yacht)
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
@@ -310,6 +341,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
return {
title,
yacht,
vesselPool,
logbookVesselSelection,
crews,
personPool,
logbookCrewSelection,
@@ -0,0 +1,73 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
import type { VesselData } from '../types/vessel.js'
import { loadVesselPoolMap } from './vesselPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookVesselSelection(
logbookId: string
): Promise<LogbookVesselSelectionData> {
const record = await db.logbookVesselSelections.get(logbookId)
if (!record) return emptyLogbookVesselSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookVesselSelectionData
| null
if (!data) return emptyLogbookVesselSelection()
return {
activeVesselId: data.activeVesselId ?? null,
vesselSnapshot: data.vesselSnapshot ?? null
}
}
export async function saveLogbookVesselSelection(
logbookId: string,
selection: LogbookVesselSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookVesselSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookVesselSelectionFromId(
logbookId: string,
activeVesselId: string | null,
poolOverride?: Map<string, VesselData>
): Promise<LogbookVesselSelectionData> {
const pool = poolOverride ?? (await loadVesselPoolMap())
const selection = buildLogbookVesselSelection(activeVesselId, pool)
await saveLogbookVesselSelection(logbookId, selection)
return selection
}
+9 -14
View File
@@ -31,20 +31,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
throw new Error('Encryption key not found. Please log in.')
}
// 1. Fetch Yacht details
const yachtRecord = await db.yachts.get(logbookId);
if (yachtRecord) {
try {
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
yachtName = yacht.name || '';
homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for PDF:', e);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch active Entry
+1 -1
View File
@@ -16,7 +16,7 @@ export async function syncPersonPool(): Promise<void> {
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'person')
if (pending.length === 0) return
try {
+45
View File
@@ -0,0 +1,45 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import type { VesselData } from '../types/vessel.js'
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
import { loadLogbookVesselSelection } from './logbookVesselSelection.js'
import { loadVesselPoolMap } from './vesselPool.js'
/** Resolved vessel for a logbook: selection snapshot, pool, or legacy per-logbook yacht. */
export async function resolveVesselForLogbook(
logbookId: string,
options?: {
preloadedYacht?: VesselData | Record<string, unknown> | null
preloadedSelection?: import('../types/vessel.js').LogbookVesselSelectionData
}
): Promise<VesselData | null> {
if (options?.preloadedYacht) {
return options.preloadedYacht as VesselData
}
const selection =
options?.preloadedSelection ?? (logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
if (selection?.vesselSnapshot) {
return vesselDataFromSnapshot(selection.vesselSnapshot)
}
if (selection?.activeVesselId && logbookId !== 'demo') {
const pool = await loadVesselPoolMap()
const fromPool = pool.get(selection.activeVesselId)
if (fromPool) return fromPool
}
const legacy = await db.yachts.get(logbookId)
if (!legacy) return null
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) return null
const decrypted = (await decryptJson(legacy.encryptedData, legacy.iv, legacy.tag, key)) as
| VesselData
| null
return decrypted
}
+25 -2
View File
@@ -64,6 +64,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
case 'logbookVessel':
return !!(await db.logbookVesselSelections.get(item.logbookId))
default:
return false
}
@@ -228,6 +230,7 @@ type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
logbookVesselSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -248,6 +251,9 @@ async function pruneAcknowledgedQueueItems(
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
}
if (server.logbookVesselSelection) {
serverTimes.set('logbookVessel:' + logbookId, server.logbookVesselSelection.updatedAt)
}
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
@@ -269,7 +275,9 @@ async function pruneAcknowledgedQueueItems(
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
: item.type === 'logbookVessel'
? 'logbookVessel:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -295,12 +303,13 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
logbookVesselSelection,
crews,
entries,
photos,
@@ -349,6 +358,20 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 2c. Sync Logbook Vessel Selection
if (logbookVesselSelection) {
const local = await db.logbookVesselSelections.get(logbookId)
if (!local || isNewer(logbookVesselSelection.updatedAt, local.updatedAt)) {
await db.logbookVesselSelections.put({
logbookId,
encryptedData: logbookVesselSelection.encryptedData,
iv: logbookVesselSelection.iv,
tag: logbookVesselSelection.tag,
updatedAt: logbookVesselSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
+80
View File
@@ -0,0 +1,80 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { VesselData } from '../types/vessel.js'
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
import { saveLogbookVesselSelection } from './logbookVesselSelection.js'
const MIGRATION_FLAG = 'vessel_pool_migration_v1_done'
function dedupeKey(data: VesselData): string {
const reg = (data.registrationNumber || '').trim().toLowerCase()
const name = (data.name || '').trim().toLowerCase()
return `${reg}|${name}`
}
export async function migrateLegacyYachtsToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByKey = new Map<string, string>()
const poolData = new Map<string, VesselData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyYacht = await db.yachts.get(logbook.id)
if (!legacyYacht) continue
const data = (await decryptJson(
legacyYacht.encryptedData,
legacyYacht.iv,
legacyYacht.tag,
logbookKey
)) as VesselData | null
if (!data?.name?.trim()) continue
const key = dedupeKey(data)
let poolId = poolByKey.get(key)
if (!poolId) {
poolId = crypto.randomUUID()
const existing = await db.vesselPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.vesselPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'vessel',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByKey.set(key, poolId)
poolData.set(poolId, data)
}
const existingSelection = await db.logbookVesselSelections.get(logbook.id)
if (!existingSelection) {
const selection = buildLogbookVesselSelection(poolId, poolData)
await saveLogbookVesselSelection(logbook.id, selection)
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Vessel pool migration failed:', err)
}
}
+90
View File
@@ -0,0 +1,90 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { VesselData } from '../types/vessel.js'
import { MAX_POOL_VESSELS } from '../types/vessel.js'
import { syncVesselPool } from './vesselPoolSync.js'
export interface DecryptedVessel {
payloadId: string
data: VesselData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadVesselPool(): Promise<DecryptedVessel[]> {
const masterKey = requireMasterKey()
const records = await db.vesselPool.toArray()
const result: DecryptedVessel[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| VesselData
| null
if (data?.name) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) =>
a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
)
return result
}
export async function loadVesselPoolMap(): Promise<Map<string, VesselData>> {
const vessels = await loadVesselPool()
return new Map(vessels.map((v) => [v.payloadId, v.data]))
}
export async function saveVessel(
payloadId: string,
data: VesselData,
isNew: boolean
): Promise<void> {
if (isNew) {
const count = await db.vesselPool.count()
if (count >= MAX_POOL_VESSELS) {
throw new Error('MAX_VESSELS')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.vesselPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'vessel',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
}
export async function deleteVessel(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.vesselPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'vessel',
payloadId,
data: '',
updatedAt: now
})
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
}
+83
View File
@@ -0,0 +1,83 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/auth/vessel-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncVesselPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushVesselPool()
await pullVesselPool()
}
async function pushVesselPool(): Promise<void> {
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'vessel')
if (pending.length === 0) return
try {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
body: JSON.stringify({ items: pending })
})
if (!response.ok) {
console.warn('Vessel pool push rejected')
return
}
const { results } = await response.json()
for (let i = 0; i < results.length; i++) {
const res = results[i]
const item = pending[i]
if (!item) continue
if (res.status === 'success' && item.id !== undefined) {
await db.userSyncQueue.delete(item.id)
}
}
} catch (err) {
console.warn('Vessel pool push failed:', err)
}
}
async function pullVesselPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { vessels } = await response.json()
if (!Array.isArray(vessels)) return
const serverMap = new Map<string, (typeof vessels)[0]>()
for (const v of vessels) {
serverMap.set(v.payloadId, v)
const local = await db.vesselPool.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.vesselPool.put({
payloadId: v.payloadId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
}
const localAll = await db.vesselPool.toArray()
for (const local of localAll) {
if (!serverMap.has(local.payloadId)) {
const pendingCreate = await db.userSyncQueue
.where({ payloadId: local.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.vesselPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Vessel pool pull failed:', err)
}
}