Compare commits

...

14 Commits

Author SHA1 Message Date
elpatron b1500f8361 chore: release v0.1.0.96 2026-06-02 20:26:41 +02:00
elpatron bc7512003e fix: retrieve Service Worker registration directly via getRegistration() to avoid ready promise hangs 2026-06-02 20:26:04 +02:00
elpatron eaf126b584 chore: release v0.1.0.95 2026-06-02 20:19:51 +02:00
elpatron a9c712be45 fix: add timeouts to SW ready and push subscribe promises to prevent silent hangs during push activation 2026-06-02 20:19:32 +02:00
elpatron b0195601de chore: release v0.1.0.94 2026-06-02 20:08:22 +02:00
elpatron c2b58baa6e fix: implement callback-based Notification.requestPermission compatibility and manual key extraction fallback to fix mobile push subscription 2026-06-02 20:07:44 +02:00
elpatron a85d6e42fc chore: release v0.1.0.93 2026-06-02 19:41:54 +02:00
elpatron 53da4a14a0 fix: delay PWA update checks on visibilitychange/online events to allow network stack stabilization 2026-06-02 19:39:48 +02:00
elpatron 2453134c51 chore: release v0.1.0.92 2026-06-02 19:28:24 +02:00
elpatron 671cb2dd9a fix: resolve push notification issues on iPad and Android by preloading VAPID keys and ready service worker to preserve user gesture context and by forcing clean re-subscription 2026-06-02 19:28:03 +02:00
elpatron 1d511e0f8c chore: release v0.1.0.91 2026-06-02 19:18:28 +02:00
elpatron 18a68367bc fix: resolve PWA freeze caused by infinite microtask loop in sync.ts and hung fetches without timeout 2026-06-02 19:17:36 +02:00
elpatron 90518372d8 chore: release v0.1.0.90 2026-06-02 15:48:31 +02:00
elpatron 9d22cb61c7 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>
2026-06-02 15:47:18 +02:00
15 changed files with 441 additions and 101 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.90
0.1.0.97
+40 -21
View File
@@ -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<string, unknown>)
})
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<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)
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({
+12 -8
View File
@@ -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',
@@ -6,7 +6,8 @@ import {
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
setLoading(false)
return
}
void preloadPushService()
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error')
console.error('Failed to toggle push notifications:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
showAlert(message)
void loadPrefs()
} finally {
+5 -2
View File
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators()
loadShareLink()
}
void preloadPushService()
}, [logbookId])
const loadShareLink = async () => {
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
}
}
+4 -2
View File
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
// Delay check on wake-up to allow the mobile network stack to stabilize
setTimeout(checkForUpdate, 2000)
}
}
const onOnline = () => {
checkForUpdate()
// Small delay to ensure connection is fully established
setTimeout(checkForUpdate, 500)
}
document.addEventListener('visibilitychange', onVisibilityChange)
+29 -8
View File
@@ -10,22 +10,43 @@ export class ApiError extends Error {
export async function apiFetch(
input: string,
init: RequestInit = {}
init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
if (init.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', () => controller.abort())
}
}
try {
return await fetch(input, {
...init,
headers,
credentials: 'include',
signal: controller.signal
})
} finally {
clearTimeout(timeoutId)
}
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
export async function apiJson<T>(
input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
+10
View File
@@ -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 {
+12 -8
View File
@@ -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<string, unknown>
)
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
+132 -20
View File
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
return Notification.permission
}
let cachedVapidKey: string | null = null
let cachedRegistration: ServiceWorkerRegistration | null = null
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker is not supported by your browser')
}
try {
const reg = await navigator.serviceWorker.getRegistration()
if (reg) return reg
} catch (e) {
console.warn('Failed to get service worker registration directly:', e)
}
// Fallback to waiting for ready state with a timeout
const readyPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
)
return Promise.race([readyPromise, timeoutPromise])
}
export async function preloadPushService(): Promise<void> {
if (!isPushSupported()) return
try {
if (!cachedVapidKey) {
await fetchVapidPublicKey()
}
if (!cachedRegistration) {
cachedRegistration = await getRegistrationCompat()
}
} catch (err) {
console.warn('Failed to preload push service:', err)
}
}
async function fetchVapidPublicKey(): Promise<string | null> {
if (cachedVapidKey) return cachedVapidKey
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim()
cachedVapidKey = envKey.trim()
return cachedVapidKey
}
try {
const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null
const data = await res.json()
return typeof data.publicKey === 'string' ? data.publicKey : null
if (typeof data.publicKey === 'string') {
cachedVapidKey = data.publicKey.trim()
return cachedVapidKey
}
return null
} catch {
return null
}
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
})
}
async function requestNotificationPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied'
// Try promise-based signature first
try {
const result = Notification.requestPermission()
if (result !== undefined) {
return await result
}
} catch {
// Ignore and fall back to callback
}
// Callback-based fallback
return new Promise<NotificationPermission>((resolve) => {
Notification.requestPermission((permission) => {
resolve(permission)
})
})
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const endpoint = subscription.endpoint
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
let p256dh = json.keys?.p256dh
let auth = json.keys?.auth
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
if (!p256dh && typeof subscription.getKey === 'function') {
try {
const rawKey = subscription.getKey('p256dh')
if (rawKey) {
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract p256dh key manually:', e)
}
}
if (!auth && typeof subscription.getKey === 'function') {
try {
const rawAuth = subscription.getKey('auth')
if (rawAuth) {
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract auth key manually:', e)
}
}
if (!endpoint || !p256dh || !auth) {
throw new Error('Invalid push subscription')
}
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
await apiJson(`${API_BASE}/subscription`, {
method: 'PUT',
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
endpoint,
keys: { p256dh, auth },
locale,
userAgent: navigator.userAgent
})
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
let registration = cachedRegistration
if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const publicKey = await fetchVapidPublicKey()
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) {
throw new Error('Push notifications are not configured on this server')
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const permission = await requestNotificationPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe with timeout to prevent silent hangs on push network errors
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
await saveSubscriptionToServer(subscription)
}
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
let registration = cachedRegistration
if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false)
await unsubscribeFromPush()
}
if (isPushSupported()) {
void preloadPushService()
}
+23 -16
View File
@@ -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<string> {
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',
+19 -13
View File
@@ -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<string>()
@@ -130,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
}
function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
}
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
@@ -305,6 +301,10 @@ async function pullChanges(logbookId: string): Promise<boolean> {
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 +375,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
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 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt
})
}
}
})
}
// Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -408,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>()
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 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt
})
}
}
})
}
// Deletions for Entries
@@ -440,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos
const serverPhotoMap = new Map<string, any>()
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 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt
})
}
}
})
}
// Deletions for Photos
@@ -474,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>()
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 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt
})
}
}
})
}
// Deletions for GPS Tracks
@@ -535,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} finally {
syncingLogbooks.delete(logbookId)
recomputeSyncingState()
if (pendingResync.has(logbookId)) {
pendingResync.delete(logbookId)
setTimeout(() => {
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
}, 1000)
}
}
}
+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)
}
}
}