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>
This commit is contained in:
2026-06-01 19:05:50 +02:00
parent 4c6c2779f2
commit 3504ec97cc
33 changed files with 1946 additions and 73 deletions
+121
View File
@@ -0,0 +1,121 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): 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 poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperId: string | null; crewIds: string[] } = {
skipperId: null,
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') legacyIds.skipperId = poolId
else legacyIds.crewIds.push(poolId)
}
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (legacyIds.skipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
legacyIds.skipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+45 -1
View File
@@ -80,16 +80,41 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
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'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
@@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie {
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
@@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie {
logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).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',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+47 -16
View File
@@ -4,9 +4,12 @@ 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 {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
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' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
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)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, 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 {
+39 -2
View File
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
title,
yacht,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -0,0 +1,75 @@
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 { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.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 loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
+110
View File
@@ -0,0 +1,110 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { PersonData } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { syncPersonPool } from './personPoolSync.js'
export interface DecryptedPerson {
payloadId: string
data: PersonData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
const masterKey = requireMasterKey()
const records = await db.personPool.toArray()
const result: DecryptedPerson[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| PersonData
| null
if (data) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) => {
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
})
return result
}
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
const people = await loadPersonPool()
return new Map(people.map((p) => [p.payloadId, p.data]))
}
export async function savePerson(
payloadId: string,
data: PersonData,
isNew: boolean
): Promise<void> {
if (data.role === 'crew' && isNew) {
const crewCount = await db.personPool
.toArray()
.then(async (rows) => {
let count = 0
const masterKey = requireMasterKey()
for (const row of rows) {
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
if (dec?.role === 'crew') count++
}
return count
})
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
throw new Error('MAX_CREW')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'person',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export async function deletePerson(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.personPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'person',
payloadId,
data: '',
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'skipper')
}
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'crew')
}
+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/person-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncPersonPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushPersonPool()
await pullPersonPool()
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
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('Person 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('Person pool push failed:', err)
}
}
async function pullPersonPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { persons } = await response.json()
if (!Array.isArray(persons)) return
const serverMap = new Map<string, (typeof persons)[0]>()
for (const p of persons) {
serverMap.set(p.payloadId, p)
const local = await db.personPool.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
await db.personPool.put({
payloadId: p.payloadId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})
}
}
const localAll = await db.personPool.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.personPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Person pool pull failed:', err)
}
}
+41 -4
View File
@@ -7,6 +7,7 @@ import {
reportSyncConflict,
type SyncConflict
} from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.photos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
default:
return false
}
@@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.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)
@@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const key =
item.type === 'yacht'
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
crews,
entries,
photos,
gpsTracks
}
// 1. Sync Yacht Payload
if (yacht) {
@@ -314,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 3. Sync Crew List Payloads
// 2b. Sync Logbook Crew Selection
if (logbookCrewSelection) {
const local = await db.logbookCrewSelections.get(logbookId)
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrewSelection.encryptedData,
iv: logbookCrewSelection.iv,
tag: logbookCrewSelection.tag,
updatedAt: logbookCrewSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
for (const c of crews) {
@@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
syncAllInFlight++
recomputeSyncingState()
try {
await syncPersonPool()
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()