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:
@@ -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(
|
||||
|
||||
@@ -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<Loa
|
||||
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 = 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<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> {
|
||||
@@ -185,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
|
||||
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<string> {
|
||||
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')
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user