fix: prevent UI freeze after saving signed log entries

Cache plaintext list metadata on entry save so the journal list avoids
full decrypt per row, and batch sync pull writes with main-thread yields.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 15:47:18 +02:00
co-authored by Cursor
parent bb501ba644
commit 9d22cb61c7
9 changed files with 259 additions and 61 deletions
+40 -21
View File
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js' import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -116,24 +122,34 @@ export default function LogEntriesList({
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 local = await db.entries.where({ logbookId }).toArray() const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) { for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const cached = entryListItemFromLocal(entry)
if (decrypted) { if (cached) {
list.push({ list.push(cached)
id: entry.payloadId, } else {
date: decrypted.date || '', needsDecrypt.push(entry)
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
} }
} }
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
list.push({
id: entry.payloadId,
...listCache,
updatedAt: entry.updatedAt
})
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
console.warn('Failed to persist entry list cache:', err)
})
})
// Sort chronological descending (by date, or dayOfTravel numerical) // Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => { list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -309,14 +325,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally // Save locally
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
// Queue for background sync // Queue for background sync
await db.syncQueue.put({ await db.syncQueue.put({
+12 -8
View File
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
@@ -454,14 +455,17 @@ export default function LogEntryEditor({
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', action: 'update',
+10
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string updatedAt: string
} }
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry { export interface LocalEntry {
payloadId: string payloadId: string
logbookId: string logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string iv: string
tag: string tag: string
updatedAt: string updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
} }
export interface LocalPhoto { export interface LocalPhoto {
+12 -8
View File
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key) const encrypted = await encryptJson(data, key)
if (type === 'entry') { if (type === 'entry') {
await db.entries.put({ await putEntryRecord(
payloadId, {
logbookId, payloadId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') { } else if (type === 'yacht') {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
+23 -16
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
@@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'create', action: 'create',
@@ -305,14 +309,17 @@ async function persistEntry(
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', action: 'update',
+13 -8
View File
@@ -8,6 +8,7 @@ import {
type SyncConflict type SyncConflict
} from './syncConflicts.js' } from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -305,6 +306,10 @@ async function pullChanges(logbookId: string): Promise<boolean> {
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } = const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
await response.json() await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
await yieldToMain()
const serverSnapshot: PulledServerPayload = { const serverSnapshot: PulledServerPayload = {
yacht, yacht,
deviation, deviation,
@@ -375,7 +380,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 3. Sync Crew List Payloads (legacy) // 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>() const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) { if (crews && Array.isArray(crews)) {
for (const c of crews) { await forEachInBatches(crews, 20, async (c) => {
serverCrewMap.set(c.payloadId, c) serverCrewMap.set(c.payloadId, c)
const local = await db.crews.get(c.payloadId) const local = await db.crews.get(c.payloadId)
if (!local || isNewer(c.updatedAt, local.updatedAt)) { if (!local || isNewer(c.updatedAt, local.updatedAt)) {
@@ -388,7 +393,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt updatedAt: c.updatedAt
}) })
} }
} })
} }
// Deletions for Crew: If present locally but not on server, and not pending creation locally // Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -408,7 +413,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads // 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>() const serverEntryMap = new Map<string, any>()
if (entries && Array.isArray(entries)) { if (entries && Array.isArray(entries)) {
for (const e of entries) { await forEachInBatches(entries, 15, async (e) => {
serverEntryMap.set(e.payloadId, e) serverEntryMap.set(e.payloadId, e)
const local = await db.entries.get(e.payloadId) const local = await db.entries.get(e.payloadId)
if (!local || isNewer(e.updatedAt, local.updatedAt)) { if (!local || isNewer(e.updatedAt, local.updatedAt)) {
@@ -421,7 +426,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt updatedAt: e.updatedAt
}) })
} }
} })
} }
// Deletions for Entries // Deletions for Entries
@@ -440,7 +445,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos // 5. Sync Photos
const serverPhotoMap = new Map<string, any>() const serverPhotoMap = new Map<string, any>()
if (photos && Array.isArray(photos)) { if (photos && Array.isArray(photos)) {
for (const p of photos) { await forEachInBatches(photos, 20, async (p) => {
serverPhotoMap.set(p.payloadId, p) serverPhotoMap.set(p.payloadId, p)
const local = await db.photos.get(p.payloadId) const local = await db.photos.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) { if (!local || isNewer(p.updatedAt, local.updatedAt)) {
@@ -455,7 +460,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt updatedAt: p.updatedAt
}) })
} }
} })
} }
// Deletions for Photos // Deletions for Photos
@@ -474,7 +479,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 6. Sync GPS Tracks // 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>() const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) { if (gpsTracks && Array.isArray(gpsTracks)) {
for (const gt of gpsTracks) { await forEachInBatches(gpsTracks, 10, async (gt) => {
serverGpsTrackMap.set(gt.entryId, gt) serverGpsTrackMap.set(gt.entryId, gt)
const local = await db.gpsTracks.get(gt.entryId) const local = await db.gpsTracks.get(gt.entryId)
if (!local || isNewer(gt.updatedAt, local.updatedAt)) { if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
@@ -487,7 +492,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt updatedAt: gt.updatedAt
}) })
} }
} })
} }
// Deletions for GPS Tracks // Deletions for GPS Tracks
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
import type { LocalEntry } from '../services/db.js'
describe('entryListCache', () => {
it('builds cache fields from decrypted entry', async () => {
const cache = await buildEntryListCache({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
signSkipper: 'Max'
})
expect(cache).toEqual({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
skipperSignStatus: 'valid'
})
})
it('maps cached local entry to list item', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z',
listCache: {
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
skipperSignStatus: 'none'
}
}
expect(entryListItemFromLocal(entry)).toEqual({
id: 'e1',
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
updatedAt: '2026-06-02T12:00:00.000Z',
skipperSignStatus: 'none'
})
})
it('returns null when cache is missing', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z'
}
expect(entryListItemFromLocal(entry)).toBeNull()
})
})
+64
View File
@@ -0,0 +1,64 @@
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
export type { EntryListCache }
export interface EntryListItem {
id: string
date: string
dayOfTravel: string
departure: string
destination: string
updatedAt: string
skipperSignStatus: SkipperSignStatus
}
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
return {
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
skipperSignStatus: await getSkipperSignStatus(decrypted)
}
}
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
if (!entry.listCache) return null
return {
id: entry.payloadId,
date: entry.listCache.date,
dayOfTravel: entry.listCache.dayOfTravel,
departure: entry.listCache.departure,
destination: entry.listCache.destination,
updatedAt: entry.updatedAt,
skipperSignStatus: entry.listCache.skipperSignStatus
}
}
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
export async function putEntryRecord(
record: LocalEntryPut,
decryptedForCache?: Record<string, unknown>
): Promise<void> {
const listCache =
record.listCache ??
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
await db.entries.put({
...record,
...(listCache ? { listCache } : {})
})
}
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
export function persistEntryListCache(
payloadId: string,
decrypted: Record<string, unknown>
): void {
void buildEntryListCache(decrypted)
.then((listCache) => db.entries.update(payloadId, { listCache }))
.catch((err) => console.warn('Failed to persist entry list cache:', err))
}
+24
View File
@@ -0,0 +1,24 @@
/** Yield so long tasks can interleave with paint and input handling. */
export function yieldToMain(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
/** Run an async handler over items in batches, yielding between batches. */
export async function forEachInBatches<T>(
items: T[],
batchSize: number,
handler: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return
const size = Math.max(1, batchSize)
for (let i = 0; i < items.length; i += size) {
if (i > 0) await yieldToMain()
const batch = items.slice(i, i + size)
for (const item of batch) {
await handler(item)
}
}
}