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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 17:24:51 +02:00
parent 9ae24aa6fb
commit ac627a022f
4 changed files with 124 additions and 16 deletions
+8 -2
View File
@@ -9,7 +9,8 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.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 LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -123,6 +124,11 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') 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 local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
@@ -300,7 +306,7 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay( const entryCrew = await loadDefaultEntryCrewForNewDay(
+99 -14
View File
@@ -9,6 +9,7 @@ import {
normalizeLogEvent, normalizeLogEvent,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString,
type LogEventPayload type LogEventPayload
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
@@ -151,18 +152,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } 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> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId) const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray()) const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
let bestId: string | null = null
let bestScore = -1
let bestUpdatedAt = ''
for (const entry of local) { for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) { if (!decrypted || String(decrypted.date) !== todayStr) continue
return entry.payloadId
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<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> { export async function createTodayEntry(logbookId: string): Promise<string> {
@@ -185,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const initialPayload = { const initialPayload = {
date: todayStr, date: todayStr,
@@ -227,20 +296,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
return localId 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> { export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim() const id = logbookId.trim()
if (!id) throw new Error('Logbook id required') if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id) let inflight = findOrCreateTodayEntryInflight.get(id)
if (!inflight) {
const entryCount = await db.entries.where({ logbookId: id }).count() inflight = findOrCreateTodayEntryOnce(id)
if (entryCount === 0) { findOrCreateTodayEntryInflight.set(id, inflight)
return createTodayEntry(id) void inflight.finally(() => {
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
findOrCreateTodayEntryInflight.delete(id)
}
})
} }
return inflight
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
} }
export interface AppendQuickEventResult { export interface AppendQuickEventResult {
+9
View File
@@ -3,6 +3,7 @@ import {
buildLogEntryPayload, buildLogEntryPayload,
hasUnsavedEventDraft, hasUnsavedEventDraft,
isLogEventDraftEmpty, isLogEventDraftEmpty,
localDateString,
normalizeLogEvent, normalizeLogEvent,
type LogEventPayload type LogEventPayload
} from './logEntryPayload.js' } from './logEntryPayload.js'
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
const filledDraft = (): LogEventPayload => const filledDraft = (): LogEventPayload =>
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' }) 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', () => { describe('logEntryPayload event drafts', () => {
it('treats time-only draft as empty', () => { it('treats time-only draft as empty', () => {
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true) expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
+8
View File
@@ -24,6 +24,14 @@ export interface LogEventPayload {
remarks: string 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). */ /** Local time as HH:MM (24-hour). */
export function currentLocalTimeHHMM(date: Date = new Date()): string { export function currentLocalTimeHHMM(date: Date = new Date()): string {
const hours = String(date.getHours()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0')