From 9d22cb61c7e10d0eb646239e3682fddeffdc2ffd Mon Sep 17 00:00:00 2001 From: elpatron Date: Tue, 2 Jun 2026 15:47:18 +0200 Subject: [PATCH] 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 --- client/src/components/LogEntriesList.tsx | 61 ++++++++++++++-------- client/src/components/LogEntryEditor.tsx | 20 +++++--- client/src/services/db.ts | 10 ++++ client/src/services/demoLogbook.ts | 20 +++++--- client/src/services/quickEventLog.ts | 39 +++++++++------ client/src/services/sync.ts | 21 +++++--- client/src/utils/entryListCache.test.ts | 61 ++++++++++++++++++++++ client/src/utils/entryListCache.ts | 64 ++++++++++++++++++++++++ client/src/utils/yieldToMain.ts | 24 +++++++++ 9 files changed, 259 insertions(+), 61 deletions(-) create mode 100644 client/src/utils/entryListCache.test.ts create mode 100644 client/src/utils/entryListCache.ts create mode 100644 client/src/utils/yieldToMain.ts diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index e68ce18..2610e2f 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import { useDialog } from './ModalDialog.tsx' 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 { carryOverFromPreviousDay, @@ -116,24 +122,34 @@ export default function LogEntriesList({ if (!masterKey) throw new Error('Encryption key not found. Please log in.') const local = await db.entries.where({ logbookId }).toArray() - + const list: DecryptedEntryItem[] = [] - + const needsDecrypt: typeof local = [] + for (const entry of local) { - const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) - if (decrypted) { - list.push({ - id: entry.payloadId, - date: decrypted.date || '', - dayOfTravel: decrypted.dayOfTravel || '', - departure: decrypted.departure || '', - destination: decrypted.destination || '', - updatedAt: entry.updatedAt, - skipperSignStatus: await getSkipperSignStatus(decrypted as Record) - }) + const cached = entryListItemFromLocal(entry) + if (cached) { + list.push(cached) + } else { + needsDecrypt.push(entry) } } + 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) + 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) list.sort((a, b) => { 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) // Save locally - await db.entries.put({ - payloadId: localId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: nowStr - }) + await putEntryRecord( + { + payloadId: localId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: nowStr + }, + initialPayload + ) // Queue for background sync await db.syncQueue.put({ diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 531d662..5120561 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { signLogEntry } from '../services/entrySigning.js' +import { putEntryRecord } from '../utils/entryListCache.js' import { getLogbookAccess } from '../services/logbookAccess.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' @@ -454,14 +455,17 @@ export default function LogEntryEditor({ const encrypted = await encryptJson(entryData, masterKey) const now = new Date().toISOString() - await db.entries.put({ - payloadId: entryId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: now - }) + await putEntryRecord( + { + payloadId: entryId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }, + entryData + ) await db.syncQueue.put({ action: 'update', diff --git a/client/src/services/db.ts b/client/src/services/db.ts index 6ad8be8..32299f4 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -35,6 +35,14 @@ export interface LocalDeviation { updatedAt: string } +export interface EntryListCache { + date: string + dayOfTravel: string + departure: string + destination: string + skipperSignStatus: 'none' | 'valid' | 'invalid' +} + export interface LocalEntry { payloadId: string logbookId: string @@ -42,6 +50,8 @@ export interface LocalEntry { iv: string tag: string updatedAt: string + /** Plaintext list fields — avoids full decrypt when opening the journal list. */ + listCache?: EntryListCache } export interface LocalPhoto { diff --git a/client/src/services/demoLogbook.ts b/client/src/services/demoLogbook.ts index d441a4b..ae9c8b4 100644 --- a/client/src/services/demoLogbook.ts +++ b/client/src/services/demoLogbook.ts @@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { encryptJson } from './crypto.js' import { syncLogbook } from './sync.js' +import { putEntryRecord } from '../utils/entryListCache.js' import { syncPersonPool } from './personPoolSync.js' import i18n from '../i18n/index.js' import type { PersonData } from '../types/person.js' @@ -35,14 +36,17 @@ async function putEncryptedRecord( const encrypted = await encryptJson(data, key) if (type === 'entry') { - await db.entries.put({ - payloadId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: now - }) + await putEntryRecord( + { + payloadId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }, + data as Record + ) } else if (type === 'yacht') { await db.yachts.put({ logbookId, diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index 060bd11..1e27e7a 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js' import { decryptJson, encryptJson } from './crypto.js' import { syncLogbook } from './sync.js' +import { putEntryRecord } from '../utils/entryListCache.js' import { buildLogEntryPayload, normalizeLogEvent, @@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise { const encrypted = await encryptJson(initialPayload, masterKey) - await db.entries.put({ - payloadId: localId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: nowStr - }) + await putEntryRecord( + { + payloadId: localId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: nowStr + }, + initialPayload + ) await db.syncQueue.put({ action: 'create', @@ -305,14 +309,17 @@ async function persistEntry( const encrypted = await encryptJson(entryData, masterKey) const now = new Date().toISOString() - await db.entries.put({ - payloadId: entryId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: now - }) + await putEntryRecord( + { + payloadId: entryId, + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }, + entryData + ) await db.syncQueue.put({ action: 'update', diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index 3c8a7f9..1389325 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -8,6 +8,7 @@ import { type SyncConflict } from './syncConflicts.js' import { syncPersonPool } from './personPoolSync.js' +import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js' const API_BASE = '/api/sync' const syncingLogbooks = new Set() @@ -305,6 +306,10 @@ async function pullChanges(logbookId: string): Promise { const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } = await response.json() + + // Large pull payloads block on JSON.parse — yield before applying to IndexedDB. + await yieldToMain() + const serverSnapshot: PulledServerPayload = { yacht, deviation, @@ -375,7 +380,7 @@ async function pullChanges(logbookId: string): Promise { // 3. Sync Crew List Payloads (legacy) const serverCrewMap = new Map() if (crews && Array.isArray(crews)) { - for (const c of crews) { + await forEachInBatches(crews, 20, async (c) => { serverCrewMap.set(c.payloadId, c) const local = await db.crews.get(c.payloadId) if (!local || isNewer(c.updatedAt, local.updatedAt)) { @@ -388,7 +393,7 @@ async function pullChanges(logbookId: string): Promise { updatedAt: c.updatedAt }) } - } + }) } // 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 { // 4. Sync Journal Entry Payloads const serverEntryMap = new Map() if (entries && Array.isArray(entries)) { - for (const e of entries) { + await forEachInBatches(entries, 15, async (e) => { serverEntryMap.set(e.payloadId, e) const local = await db.entries.get(e.payloadId) if (!local || isNewer(e.updatedAt, local.updatedAt)) { @@ -421,7 +426,7 @@ async function pullChanges(logbookId: string): Promise { updatedAt: e.updatedAt }) } - } + }) } // Deletions for Entries @@ -440,7 +445,7 @@ async function pullChanges(logbookId: string): Promise { // 5. Sync Photos const serverPhotoMap = new Map() if (photos && Array.isArray(photos)) { - for (const p of photos) { + await forEachInBatches(photos, 20, async (p) => { serverPhotoMap.set(p.payloadId, p) const local = await db.photos.get(p.payloadId) if (!local || isNewer(p.updatedAt, local.updatedAt)) { @@ -455,7 +460,7 @@ async function pullChanges(logbookId: string): Promise { updatedAt: p.updatedAt }) } - } + }) } // Deletions for Photos @@ -474,7 +479,7 @@ async function pullChanges(logbookId: string): Promise { // 6. Sync GPS Tracks const serverGpsTrackMap = new Map() if (gpsTracks && Array.isArray(gpsTracks)) { - for (const gt of gpsTracks) { + await forEachInBatches(gpsTracks, 10, async (gt) => { serverGpsTrackMap.set(gt.entryId, gt) const local = await db.gpsTracks.get(gt.entryId) if (!local || isNewer(gt.updatedAt, local.updatedAt)) { @@ -487,7 +492,7 @@ async function pullChanges(logbookId: string): Promise { updatedAt: gt.updatedAt }) } - } + }) } // Deletions for GPS Tracks diff --git a/client/src/utils/entryListCache.test.ts b/client/src/utils/entryListCache.test.ts new file mode 100644 index 0000000..3ccf8e3 --- /dev/null +++ b/client/src/utils/entryListCache.test.ts @@ -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() + }) +}) diff --git a/client/src/utils/entryListCache.ts b/client/src/utils/entryListCache.ts new file mode 100644 index 0000000..5f22184 --- /dev/null +++ b/client/src/utils/entryListCache.ts @@ -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): Promise { + 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 & { listCache?: EntryListCache } + +/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */ +export async function putEntryRecord( + record: LocalEntryPut, + decryptedForCache?: Record +): Promise { + 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 +): void { + void buildEntryListCache(decrypted) + .then((listCache) => db.entries.update(payloadId, { listCache })) + .catch((err) => console.warn('Failed to persist entry list cache:', err)) +} diff --git a/client/src/utils/yieldToMain.ts b/client/src/utils/yieldToMain.ts new file mode 100644 index 0000000..f25ddd7 --- /dev/null +++ b/client/src/utils/yieldToMain.ts @@ -0,0 +1,24 @@ +/** Yield so long tasks can interleave with paint and input handling. */ +export function yieldToMain(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0) + }) +} + +/** Run an async handler over items in batches, yielding between batches. */ +export async function forEachInBatches( + items: T[], + batchSize: number, + handler: (item: T) => Promise +): Promise { + 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) + } + } +}