Compare commits

...

6 Commits

Author SHA1 Message Date
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
14 changed files with 349 additions and 96 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.90
0.1.0.93
+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)
+3 -1
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 () => {
+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,
+48 -19
View File
@@ -27,17 +27,41 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
return Notification.permission
}
let cachedVapidKey: string | null = null
let cachedRegistration: ServiceWorkerRegistration | null = null
export async function preloadPushService(): Promise<void> {
if (!isPushSupported()) return
try {
if (!cachedVapidKey) {
await fetchVapidPublicKey()
}
if (!cachedRegistration) {
cachedRegistration = await navigator.serviceWorker.ready
}
} 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
}
@@ -98,27 +122,28 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device')
}
// Pre-resolve registration and VAPID key synchronously if preloaded.
// This keeps the user gesture active for iOS Safari.
const registration = cachedRegistration || await navigator.serviceWorker.ready
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) {
throw new Error('Push notifications are not configured on this server')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const publicKey = 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 keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe to renew/ensure subscription without reusing stale state
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
await saveSubscriptionToServer(subscription)
}
@@ -126,7 +151,7 @@ export async function subscribeToPush(): Promise<void> {
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
const registration = cachedRegistration || await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -164,3 +189,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)
}
}
}