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:
@@ -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,
|
||||||
@@ -118,22 +124,32 @@ export default function LogEntriesList({
|
|||||||
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({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user