From ac627a022f959427f9af2f701390dd6c1d286d28 Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 3 Jun 2026 17:24:51 +0200 Subject: [PATCH] fix: ensure only one travel day per calendar date Serialize Live-log day creation, prune empty duplicates, and use local dates for "today". Co-authored-by: Cursor --- client/src/components/LogEntriesList.tsx | 10 +- client/src/services/quickEventLog.ts | 113 ++++++++++++++++++++--- client/src/utils/logEntryPayload.test.ts | 9 ++ client/src/utils/logEntryPayload.ts | 8 ++ 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index a0cf317..a27c687 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -9,7 +9,8 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { getErrorMessage } from '../utils/errors.js' -import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js' +import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js' +import { localDateString } from '../utils/logEntryPayload.js' import LogEntryEditor from './LogEntryEditor.tsx' import LiveLogView from './LiveLogView.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' @@ -123,6 +124,11 @@ export default function LogEntriesList({ const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') + const todayEntryId = await findTodayEntryId(logbookId) + if (todayEntryId) { + await pruneEmptyTodayDuplicates(logbookId, todayEntryId) + } + const local = await db.entries.where({ logbookId }).toArray() const list: DecryptedEntryItem[] = [] @@ -300,7 +306,7 @@ export default function LogEntriesList({ const localId = window.crypto.randomUUID() const nowStr = new Date().toISOString() - const todayStr = nowStr.substring(0, 10) + const todayStr = localDateString() const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const entryCrew = await loadDefaultEntryCrewForNewDay( diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index 84bfc83..55b703d 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -9,6 +9,7 @@ import { normalizeLogEvent, sortLogEventsByTime, currentLocalTimeHHMM, + localDateString, type LogEventPayload } from '../utils/logEntryPayload.js' import { @@ -151,18 +152,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise): 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 { - const todayStr = new Date().toISOString().substring(0, 10) + 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) { - return entry.payloadId + 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 null + + return bestId +} + +async function entryHasAttachments(logbookId: string, entryId: string): Promise { + 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 +): Promise { + 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 { + 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 { @@ -185,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise { const localId = window.crypto.randomUUID() const nowStr = new Date().toISOString() - const todayStr = nowStr.substring(0, 10) + const todayStr = localDateString() const initialPayload = { date: todayStr, @@ -227,20 +296,36 @@ export async function createTodayEntry(logbookId: string): Promise { return localId } +const findOrCreateTodayEntryInflight = new Map>() + +async function findOrCreateTodayEntryOnce(logbookId: string): Promise { + 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 { const id = logbookId.trim() if (!id) throw new Error('Logbook id required') - await ensureLogbookKey(id) - - const entryCount = await db.entries.where({ logbookId: id }).count() - if (entryCount === 0) { - return createTodayEntry(id) + 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) + } + }) } - - const existing = await findTodayEntryId(id) - if (existing) return existing - return createTodayEntry(id) + return inflight } export interface AppendQuickEventResult { diff --git a/client/src/utils/logEntryPayload.test.ts b/client/src/utils/logEntryPayload.test.ts index 434372d..847b712 100644 --- a/client/src/utils/logEntryPayload.test.ts +++ b/client/src/utils/logEntryPayload.test.ts @@ -3,6 +3,7 @@ import { buildLogEntryPayload, hasUnsavedEventDraft, isLogEventDraftEmpty, + localDateString, normalizeLogEvent, type LogEventPayload } from './logEntryPayload.js' @@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload => const filledDraft = (): LogEventPayload => normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' }) +describe('localDateString', () => { + it('uses local calendar date, not UTC', () => { + const date = new Date(2026, 5, 4, 1, 30, 0) + expect(localDateString(date)).toBe('2026-06-04') + expect(date.toISOString().substring(0, 10)).toBe('2026-06-03') + }) +}) + describe('logEntryPayload event drafts', () => { it('treats time-only draft as empty', () => { expect(isLogEventDraftEmpty(emptyDraft())).toBe(true) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 5f7b6df..55c0c40 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -24,6 +24,14 @@ export interface LogEventPayload { remarks: string } +/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */ +export function localDateString(date: Date = new Date()): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + /** Local time as HH:MM (24-hour). */ export function currentLocalTimeHHMM(date: Date = new Date()): string { const hours = String(date.getHours()).padStart(2, '0')