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) + } + } +}