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 { 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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user