35ee705510
Stellt sicher, dass signSkipper und signCrew gemeinsam für den Punktewert bei der Auswahl des Tageseintrags ausgewertet werden. Co-authored-by: Cursor <cursoragent@cursor.com>
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import { db } from './db.js'
|
|
import { getActiveMasterKey } from './auth.js'
|
|
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
|
import { decryptJson, encryptJson } from './crypto.js'
|
|
import { syncLogbook } from './sync.js'
|
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
|
import {
|
|
buildLogEntryPayload,
|
|
normalizeLogEvent,
|
|
sortLogEventsByTime,
|
|
currentLocalTimeHHMM,
|
|
localDateString,
|
|
type LogEventPayload
|
|
} from '../utils/logEntryPayload.js'
|
|
import {
|
|
carryOverFromPreviousDay,
|
|
compareTravelDaysChronological,
|
|
getNextTravelDayNumber,
|
|
type LogEntryTankSource,
|
|
type TravelDaySortable
|
|
} from '../utils/logEntryTankLevels.js'
|
|
|
|
export interface LoadedEntry {
|
|
payloadId: string
|
|
updatedAt: string
|
|
data: Record<string, unknown>
|
|
}
|
|
|
|
type EncryptedRecord = {
|
|
encryptedData: string
|
|
iv: string
|
|
tag: string
|
|
}
|
|
|
|
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
|
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
|
return masterKey
|
|
}
|
|
|
|
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
|
|
export async function tryDecryptEntryPayload(
|
|
record: EncryptedRecord,
|
|
key: ArrayBuffer
|
|
): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
|
|
return [...entries].sort(
|
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
)
|
|
}
|
|
|
|
function tankLevelsFromData(data: Record<string, unknown>) {
|
|
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
|
|
morning: 0, refilled: 0, evening: 0, consumption: 0
|
|
}
|
|
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
|
|
morning: 0, refilled: 0, evening: 0, consumption: 0
|
|
}
|
|
const gw = data.greywater as { level?: number } | undefined
|
|
return { fw, fuel, gw }
|
|
}
|
|
|
|
function buildEncryptedPayload(
|
|
data: Record<string, unknown>,
|
|
options: {
|
|
events: LogEventPayload[]
|
|
departure?: string
|
|
destination?: string
|
|
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
|
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
|
clearSignatures?: boolean
|
|
}
|
|
): Record<string, unknown> {
|
|
const { fw, fuel, gw } = tankLevelsFromData(data)
|
|
const trackDistance = data.trackDistanceNm
|
|
const trackSpeedMax = data.trackSpeedMaxKn
|
|
const trackSpeedAvg = data.trackSpeedAvgKn
|
|
const motorHoursRaw = data.motorHours
|
|
|
|
const freshwater = options.freshwater ?? {
|
|
morning: fw.morning || 0,
|
|
refilled: fw.refilled || 0,
|
|
evening: fw.evening || 0,
|
|
consumption: fw.consumption ?? 0
|
|
}
|
|
const fuelLevels = options.fuel ?? {
|
|
morning: fuel.morning || 0,
|
|
refilled: fuel.refilled || 0,
|
|
evening: fuel.evening || 0,
|
|
consumption: fuel.consumption ?? 0
|
|
}
|
|
|
|
const payload = buildLogEntryPayload({
|
|
date: String(data.date || ''),
|
|
dayOfTravel: String(data.dayOfTravel || ''),
|
|
departure: options.departure ?? String(data.departure || ''),
|
|
destination: options.destination ?? String(data.destination || ''),
|
|
freshwater,
|
|
fuel: fuelLevels,
|
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
|
trackDistanceNm:
|
|
trackDistance != null && trackDistance !== ''
|
|
? parseFloat(String(trackDistance))
|
|
: undefined,
|
|
trackSpeedMaxKn:
|
|
trackSpeedMax != null && trackSpeedMax !== ''
|
|
? parseFloat(String(trackSpeedMax))
|
|
: undefined,
|
|
trackSpeedAvgKn:
|
|
trackSpeedAvg != null && trackSpeedAvg !== ''
|
|
? parseFloat(String(trackSpeedAvg))
|
|
: undefined,
|
|
motorHours:
|
|
motorHoursRaw != null && motorHoursRaw !== ''
|
|
? parseFloat(String(motorHoursRaw))
|
|
: undefined,
|
|
events: options.events
|
|
})
|
|
|
|
const clear = options.clearSignatures
|
|
const entryData: Record<string, unknown> = {
|
|
...payload,
|
|
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
|
signCrew: clear ? '' : (data.signCrew ?? '')
|
|
}
|
|
|
|
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
|
|
if (summary) {
|
|
entryData.aiSummary = summary
|
|
entryData.aiSummaryGeneratedAt =
|
|
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
|
|
? data.aiSummaryGeneratedAt
|
|
: new Date().toISOString()
|
|
}
|
|
|
|
return entryData
|
|
}
|
|
|
|
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
|
const masterKey = await getMasterKey(logbookId)
|
|
const record = await db.entries.get(entryId)
|
|
if (!record) return null
|
|
const data = await tryDecryptEntryPayload(record, masterKey)
|
|
if (!data) return null
|
|
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
|
}
|
|
|
|
function scoreTodayEntry(data: Record<string, unknown>): number {
|
|
const events = (data.events as unknown[] | undefined)?.length ?? 0
|
|
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
|
|
const destination = String(data.destination || '').trim() ? 1 : 0
|
|
return events * 10 + signed + destination
|
|
}
|
|
|
|
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
|
const todayStr = localDateString()
|
|
const masterKey = await getMasterKey(logbookId)
|
|
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
|
|
|
let bestId: string | null = null
|
|
let bestScore = -1
|
|
let bestUpdatedAt = ''
|
|
|
|
for (const entry of local) {
|
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
|
|
|
const score = scoreTodayEntry(decrypted)
|
|
if (
|
|
score > bestScore
|
|
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
|
|
) {
|
|
bestId = entry.payloadId
|
|
bestScore = score
|
|
bestUpdatedAt = entry.updatedAt
|
|
}
|
|
}
|
|
|
|
return bestId
|
|
}
|
|
|
|
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
|
|
const [photos, voices, track] = await Promise.all([
|
|
db.photos.where({ logbookId, entryId }).count(),
|
|
db.voiceMemos.where({ logbookId, entryId }).count(),
|
|
db.gpsTracks.get(entryId)
|
|
])
|
|
return photos > 0 || voices > 0 || track != null
|
|
}
|
|
|
|
async function isEmptyTodayEntry(
|
|
logbookId: string,
|
|
entryId: string,
|
|
data: Record<string, unknown>
|
|
): Promise<boolean> {
|
|
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
|
|
if (data.signSkipper || data.signCrew) return false
|
|
if (String(data.destination || '').trim()) return false
|
|
return !(await entryHasAttachments(logbookId, entryId))
|
|
}
|
|
|
|
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
|
|
export async function pruneEmptyTodayDuplicates(
|
|
logbookId: string,
|
|
keepEntryId: string
|
|
): Promise<void> {
|
|
const todayStr = localDateString()
|
|
const masterKey = await getMasterKey(logbookId)
|
|
const local = await db.entries.where({ logbookId }).toArray()
|
|
const now = new Date().toISOString()
|
|
|
|
for (const entry of local) {
|
|
if (entry.payloadId === keepEntryId) continue
|
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
|
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
|
|
|
|
await db.entries.delete(entry.payloadId)
|
|
await db.syncQueue.put({
|
|
action: 'delete',
|
|
type: 'entry',
|
|
payloadId: entry.payloadId,
|
|
logbookId,
|
|
data: '',
|
|
updatedAt: now
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function createTodayEntry(logbookId: string): Promise<string> {
|
|
const masterKey = await getMasterKey(logbookId)
|
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
|
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
|
|
|
if (localEntries.length > 0) {
|
|
for (const entry of localEntries) {
|
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
|
if (decrypted) {
|
|
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
|
}
|
|
}
|
|
}
|
|
|
|
decryptedEntries.sort(compareTravelDaysChronological)
|
|
const previousEntry = decryptedEntries.at(-1) ?? null
|
|
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
|
|
|
const localId = window.crypto.randomUUID()
|
|
const nowStr = new Date().toISOString()
|
|
const todayStr = localDateString()
|
|
|
|
const initialPayload = {
|
|
date: todayStr,
|
|
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
|
departure,
|
|
destination: '',
|
|
freshwater,
|
|
fuel,
|
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
|
signSkipper: '',
|
|
signCrew: '',
|
|
events: []
|
|
}
|
|
|
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
|
|
|
await putEntryRecord(
|
|
{
|
|
payloadId: localId,
|
|
logbookId,
|
|
encryptedData: encrypted.ciphertext,
|
|
iv: encrypted.iv,
|
|
tag: encrypted.tag,
|
|
updatedAt: nowStr
|
|
},
|
|
initialPayload
|
|
)
|
|
|
|
await db.syncQueue.put({
|
|
action: 'create',
|
|
type: 'entry',
|
|
payloadId: localId,
|
|
logbookId,
|
|
data: JSON.stringify(encrypted),
|
|
updatedAt: nowStr
|
|
})
|
|
|
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
return localId
|
|
}
|
|
|
|
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
|
|
|
|
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
|
|
await ensureLogbookKey(logbookId)
|
|
|
|
let entryId = await findTodayEntryId(logbookId)
|
|
if (!entryId) {
|
|
entryId = await createTodayEntry(logbookId)
|
|
}
|
|
|
|
await pruneEmptyTodayDuplicates(logbookId, entryId)
|
|
return entryId
|
|
}
|
|
|
|
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
|
|
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
|
const id = logbookId.trim()
|
|
if (!id) throw new Error('Logbook id required')
|
|
|
|
let inflight = findOrCreateTodayEntryInflight.get(id)
|
|
if (!inflight) {
|
|
inflight = findOrCreateTodayEntryOnce(id)
|
|
findOrCreateTodayEntryInflight.set(id, inflight)
|
|
void inflight.finally(() => {
|
|
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
|
findOrCreateTodayEntryInflight.delete(id)
|
|
}
|
|
})
|
|
}
|
|
return inflight
|
|
}
|
|
|
|
export interface AppendQuickEventResult {
|
|
events: LogEventPayload[]
|
|
hadSignature: boolean
|
|
}
|
|
|
|
export async function appendQuickEvent(
|
|
logbookId: string,
|
|
entryId: string,
|
|
partialEvent: Partial<LogEventPayload>,
|
|
headerPatch?: { departure?: string; destination?: string }
|
|
): Promise<AppendQuickEventResult> {
|
|
const loaded = await loadEntry(logbookId, entryId)
|
|
if (!loaded) throw new Error('Entry not found')
|
|
|
|
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
|
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
|
const newEvent = normalizeLogEvent({
|
|
time: currentLocalTimeHHMM(),
|
|
...partialEvent
|
|
})
|
|
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
|
|
|
await persistEntry(logbookId, entryId, loaded.data, {
|
|
events: nextEvents,
|
|
departure: headerPatch?.departure,
|
|
destination: headerPatch?.destination,
|
|
clearSignatures: hadSignature
|
|
})
|
|
|
|
return { events: nextEvents, hadSignature }
|
|
}
|
|
|
|
/** Append multiple events in one load/encrypt/persist cycle (avoids UI freezes). */
|
|
export async function appendQuickEvents(
|
|
logbookId: string,
|
|
entryId: string,
|
|
partialEvents: Partial<LogEventPayload>[]
|
|
): Promise<AppendQuickEventResult> {
|
|
const loaded = await loadEntry(logbookId, entryId)
|
|
if (!loaded) throw new Error('Entry not found')
|
|
|
|
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
|
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
|
if (partialEvents.length === 0) {
|
|
return { events: currentEvents, hadSignature }
|
|
}
|
|
|
|
const time = currentLocalTimeHHMM()
|
|
const newEvents = partialEvents.map((partial) =>
|
|
normalizeLogEvent({ time, ...partial })
|
|
)
|
|
const nextEvents = sortLogEventsByTime([...currentEvents, ...newEvents])
|
|
|
|
await persistEntry(logbookId, entryId, loaded.data, {
|
|
events: nextEvents,
|
|
clearSignatures: hadSignature
|
|
})
|
|
|
|
return { events: nextEvents, hadSignature }
|
|
}
|
|
|
|
async function persistEntry(
|
|
logbookId: string,
|
|
entryId: string,
|
|
data: Record<string, unknown>,
|
|
options: Parameters<typeof buildEncryptedPayload>[1]
|
|
): Promise<void> {
|
|
const hadSignature = !!(data.signSkipper || data.signCrew)
|
|
const entryData = buildEncryptedPayload(data, {
|
|
...options,
|
|
clearSignatures: options.clearSignatures ?? hadSignature
|
|
})
|
|
|
|
const masterKey = await getMasterKey(logbookId)
|
|
const encrypted = await encryptJson(entryData, masterKey)
|
|
const now = new Date().toISOString()
|
|
|
|
await putEntryRecord(
|
|
{
|
|
payloadId: entryId,
|
|
logbookId,
|
|
encryptedData: encrypted.ciphertext,
|
|
iv: encrypted.iv,
|
|
tag: encrypted.tag,
|
|
updatedAt: now
|
|
},
|
|
entryData
|
|
)
|
|
|
|
await db.syncQueue.put({
|
|
action: 'update',
|
|
type: 'entry',
|
|
payloadId: entryId,
|
|
logbookId,
|
|
data: JSON.stringify(encrypted),
|
|
updatedAt: now
|
|
})
|
|
|
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
}
|
|
|
|
export async function removeLastEvent(
|
|
logbookId: string,
|
|
entryId: string
|
|
): Promise<LogEventPayload[]> {
|
|
const loaded = await loadEntry(logbookId, entryId)
|
|
if (!loaded) throw new Error('Entry not found')
|
|
|
|
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
|
if (currentEvents.length === 0) return []
|
|
|
|
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
|
|
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
|
|
return nextEvents
|
|
}
|
|
|
|
export async function appendTankRefill(
|
|
logbookId: string,
|
|
entryId: string,
|
|
tank: 'fuel' | 'freshwater',
|
|
addLiters: number,
|
|
event: Partial<LogEventPayload>
|
|
): Promise<AppendQuickEventResult> {
|
|
const loaded = await loadEntry(logbookId, entryId)
|
|
if (!loaded) throw new Error('Entry not found')
|
|
|
|
const { fw, fuel } = tankLevelsFromData(loaded.data)
|
|
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
|
const newEvent = normalizeLogEvent({
|
|
time: currentLocalTimeHHMM(),
|
|
...event
|
|
})
|
|
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
|
|
|
const tankPatch = tank === 'fuel'
|
|
? {
|
|
fuel: {
|
|
morning: fuel.morning || 0,
|
|
refilled: (fuel.refilled || 0) + addLiters,
|
|
evening: fuel.evening || 0,
|
|
consumption: fuel.consumption ?? 0
|
|
}
|
|
}
|
|
: {
|
|
freshwater: {
|
|
morning: fw.morning || 0,
|
|
refilled: (fw.refilled || 0) + addLiters,
|
|
evening: fw.evening || 0,
|
|
consumption: fw.consumption ?? 0
|
|
}
|
|
}
|
|
|
|
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
|
await persistEntry(logbookId, entryId, loaded.data, {
|
|
events: nextEvents,
|
|
...tankPatch,
|
|
clearSignatures: hadSignature
|
|
})
|
|
|
|
return { events: nextEvents, hadSignature }
|
|
}
|