Compare commits

...

15 Commits

Author SHA1 Message Date
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
elpatron bb501ba644 chore: release v0.1.0.89 2026-06-01 22:44:01 +02:00
elpatron f51f088f1e chore: release v0.1.0.88 2026-06-01 22:32:53 +02:00
elpatron 3d2918e0fe feat: logbook filter by crew/vessel and save-on-leave dialog
Extend dashboard search with ship name and crew name parts from local data.
When leaving a dirty travel day, offer save, discard, or stay instead of only leave/cancel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:30:41 +02:00
26 changed files with 851 additions and 180 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.88
0.1.0.96
+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({
+41 -16
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'
@@ -412,9 +413,15 @@ export default function LogEntryEditor({
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const saveBeforeLeaveRef = useRef<(() => Promise<void>) | null>(null)
const invokeSaveBeforeLeave = useCallback(async () => {
if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current()
}, [])
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
!readOnly && !loading && isDirty,
invokeSaveBeforeLeave
)
const handleBack = async () => {
@@ -448,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',
@@ -1207,8 +1217,7 @@ export default function LogEntryEditor({
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const saveEntryChanges = useCallback(async () => {
if (readOnly) return
let eventsToSave = events
@@ -1236,7 +1245,6 @@ export default function LogEntryEditor({
setSaving(true)
setError(null)
setSuccess(false)
try {
await persistEntryToDb({
@@ -1245,9 +1253,28 @@ export default function LogEntryEditor({
})
await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
} finally {
setSaving(false)
}
}, [
readOnly, events, hasPendingEventForm, editingEventIndex, isDirty,
resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm,
clearEventForm, persistEntryToDb, logbookId, entryId, t
])
useEffect(() => {
saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges
}, [readOnly, saveEntryChanges])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
setSuccess(false)
try {
await saveEntryChanges()
setSuccess(true)
setTimeout(() => {
setSuccess(false)
onBack()
@@ -1255,8 +1282,6 @@ export default function LogEntryEditor({
} catch (err: unknown) {
console.error('Failed to save entry details:', err)
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setSaving(false)
}
}
+32 -24
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -20,26 +22,6 @@ interface LogbookDashboardProps {
onOpenProfile: () => void
}
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
return false
}
type LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc'
@@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
@@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
loadLogbooks()
}, [])
useEffect(() => {
const ids = logbooks.map((lb) => lb.id)
if (ids.length === 0) {
setSearchFieldsByLogbookId(new Map())
return
}
let cancelled = false
void loadLogbookSearchFieldsBatch(ids).then((index) => {
if (!cancelled) setSearchFieldsByLogbookId(index)
})
return () => {
cancelled = true
}
}, [logbooks])
const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
@@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo(
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[ownedLogbooks, filterQuery, i18n.language]
() =>
ownedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const filteredSharedLogbooks = useMemo(
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[sharedLogbooks, filterQuery, i18n.language]
() =>
sharedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
+106 -24
View File
@@ -10,9 +10,19 @@ import React, {
} from 'react'
import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
showConfirmLeave: (
message: string,
title?: string,
stayLabel?: string,
saveLabel?: string,
discardLabel?: string,
options?: { showSave?: boolean }
) => Promise<ConfirmLeaveChoice>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [type, setType] = useState<'alert' | 'confirm'>('alert')
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const [saveLabel, setSaveLabel] = useState('')
const [discardLabel, setDiscardLabel] = useState('')
const [showSaveOption, setShowSaveOption] = useState(false)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
})
}, [t])
const showConfirmLeave = useCallback((
msg: string,
headerTitle?: string,
btnStay?: string,
btnSave?: string,
btnDiscard?: string,
options?: { showSave?: boolean }
): Promise<ConfirmLeaveChoice> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm-leave')
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
setShowSaveOption(options?.showSave !== false)
setIsOpen(true)
return new Promise<ConfirmLeaveChoice>((resolve) => {
confirmLeaveResolveRef.current = resolve
})
}, [t])
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
setIsOpen(false)
if (confirmLeaveResolveRef.current) {
confirmLeaveResolveRef.current(choice)
confirmLeaveResolveRef.current = null
}
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (type === 'confirm' && confirmResolveRef.current) {
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [type])
const handleCancel = useCallback(() => {
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
setIsOpen(false)
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [])
}, [type, closeConfirmLeave])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm') handleCancel()
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm()
}
}
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
() => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm, showConfirmLeave]
)
return (
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{isOpen && (
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm}
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{type === 'confirm-leave' ? (
<>
<button
ref={confirmRef}
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{showSaveOption && (
<button
type="button"
className="btn primary"
onClick={() => closeConfirmLeave('save')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{saveLabel}
</button>
)}
<button
type="button"
className="btn danger"
onClick={() => closeConfirmLeave('discard')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{discardLabel}
</button>
</>
) : (
<>
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</div>
</div>
</div>
@@ -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)
}
}
+53 -8
View File
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean>
}
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
if (handler) saveHandlers.current.set(source, handler)
else saveHandlers.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
const choice = await showConfirmLeave(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
t('common.unsaved_changes_stay'),
t('common.unsaved_changes_save_leave'),
t('common.unsaved_changes_discard'),
{ showSave: canSave }
)
}, [showConfirm, t])
if (choice === 'stay') return false
if (choice === 'discard') return true
const handlers = [...dirtySources.current]
.map((source) => saveHandlers.current.get(source))
.filter((handler): handler is () => Promise<void> => handler != null)
try {
for (const handler of handlers) {
await handler()
}
return true
} catch (err) {
console.error('Failed to save before leaving:', err)
await showAlert(t('errors.save_failed'))
return false
}
}, [showConfirmLeave, showAlert, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return (
<UnsavedChangesContext.Provider value={value}>
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
export function useRegisterUnsavedChanges(
source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave }
}
+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)
+5 -3
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
"unsaved_changes_leave": "Forladelse",
"unsaved_changes_stay": "Bliv her"
"unsaved_changes_stay": "Bliv her",
"unsaved_changes_save_leave": "Gem og forlad",
"unsaved_changes_discard": "Kassér",
"unsaved_changes_leave": "Forladelse"
},
"nav": {
"dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbog omdøbt med succes",
"edit_btn": "Omdøb",
"filter_label": "Filtrer logbøger",
"filter_placeholder": "Navn, årstal eller dato ...",
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
"filter_clear": "Nulstil filter",
"filter_results": "{{count}} Hits",
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
+5 -3
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
},
"nav": {
"dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr oder Datum …",
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
+5 -3
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
},
"nav": {
"dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …",
"filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
+5 -3
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
"unsaved_changes_leave": "Oppgivelse",
"unsaved_changes_stay": "Bli"
"unsaved_changes_stay": "Bli",
"unsaved_changes_save_leave": "Lagre og forlat",
"unsaved_changes_discard": "Forkast",
"unsaved_changes_leave": "Oppgivelse"
},
"nav": {
"dashboard": "Dashbord",
@@ -484,7 +486,7 @@
"edit_success": "Loggboken har fått nytt navn",
"edit_btn": "Gi nytt navn",
"filter_label": "Filtrer loggbøker",
"filter_placeholder": "Navn, årstall eller dato ...",
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
"filter_clear": "Tilbakestill filter",
"filter_results": "{{count}} Treff",
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
+5 -3
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
"unsaved_changes_leave": "Övergivande",
"unsaved_changes_stay": "Stanna kvar"
"unsaved_changes_stay": "Stanna kvar",
"unsaved_changes_save_leave": "Spara och lämna",
"unsaved_changes_discard": "Kasta",
"unsaved_changes_leave": "Övergivande"
},
"nav": {
"dashboard": "Instrumentpanel",
@@ -484,7 +486,7 @@
"edit_success": "Loggboken har framgångsrikt bytt namn",
"edit_btn": "Byt namn på",
"filter_label": "Filtrera loggböcker",
"filter_placeholder": "Namn, årtal eller datum ...",
"filter_placeholder": "Namn, årtal, datum, crew eller fartyg …",
"filter_clear": "Återställ filter",
"filter_results": "{{count}} Träffar",
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
+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,
+81
View File
@@ -0,0 +1,81 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { loadLogbookCrewSelection } from './logbookCrewSelection.js'
import { loadPersonPoolMap } from './personPool.js'
import { resolveVesselForLogbook } from './resolveVessel.js'
import type { LogbookSearchFields } from '../utils/logbookFilter.js'
async function loadLegacyCrewNames(logbookId: string): Promise<string[]> {
const records = await db.crews.where({ logbookId }).toArray()
if (records.length === 0) return []
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) return []
const names: string[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as PersonData | null
const name = data?.name?.trim()
if (name) names.push(name)
}
return names
}
function collectCrewNamesFromSelection(
selection: Awaited<ReturnType<typeof loadLogbookCrewSelection>>,
pool: Map<string, PersonData>
): string[] {
const names = new Set<string>()
for (const snapshot of Object.values(selection.snapshotsById)) {
const name = snapshot.name?.trim()
if (name) names.add(name)
}
const ids = [
...(selection.activeSkipperId ? [selection.activeSkipperId] : []),
...selection.activeCrewIds
]
for (const id of ids) {
const fromSnapshot = selection.snapshotsById[id]?.name?.trim()
if (fromSnapshot) {
names.add(fromSnapshot)
continue
}
const fromPool = pool.get(id)?.name?.trim()
if (fromPool) names.add(fromPool)
}
return [...names]
}
export async function loadLogbookSearchFields(logbookId: string): Promise<LogbookSearchFields> {
const [vessel, crewSelection, pool] = await Promise.all([
resolveVesselForLogbook(logbookId),
loadLogbookCrewSelection(logbookId),
loadPersonPoolMap()
])
let crewNames = collectCrewNamesFromSelection(crewSelection, pool)
if (crewNames.length === 0) {
crewNames = await loadLegacyCrewNames(logbookId)
}
return {
vesselName: vessel?.name?.trim() ?? '',
crewNames
}
}
export async function loadLogbookSearchFieldsBatch(
logbookIds: string[]
): Promise<Map<string, LogbookSearchFields>> {
const uniqueIds = [...new Set(logbookIds)]
const entries = await Promise.all(
uniqueIds.map(async (id) => [id, await loadLogbookSearchFields(id)] as const)
)
return new Map(entries)
}
+119 -20
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
}
@@ -72,11 +96,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 +159,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 +172,56 @@ 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 with timeout to prevent silent hangs
let registration = cachedRegistration
if (!registration) {
const readyPromise = navigator.serviceWorker.ready
const readyTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), 8000)
)
registration = await Promise.race([readyPromise, readyTimeout])
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) {
const readyPromise = navigator.serviceWorker.ready
const readyTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), 8000)
)
registration = await Promise.race([readyPromise, readyTimeout])
cachedRegistration = registration
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -164,3 +259,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))
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { logbookMatchesFilter, nameMatchesQuery } from './logbookFilter.js'
describe('nameMatchesQuery', () => {
it('matches full name', () => {
expect(nameMatchesQuery('Anna Müller', 'müller')).toBe(true)
})
it('matches first name part only', () => {
expect(nameMatchesQuery('Anna Müller', 'anna')).toBe(true)
})
it('matches last name part only', () => {
expect(nameMatchesQuery('Anna Müller', 'mül')).toBe(true)
})
it('returns false for unrelated query', () => {
expect(nameMatchesQuery('Anna Müller', 'peter')).toBe(false)
})
})
describe('logbookMatchesFilter', () => {
const lb = { title: 'Sommer 2024', updatedAt: '2024-06-15T12:00:00.000Z' }
it('matches logbook title', () => {
expect(logbookMatchesFilter(lb, 'sommer', 'de')).toBe(true)
})
it('matches vessel name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'wind', 'de', { vesselName: 'Windrose', crewNames: [] })
).toBe(true)
})
it('matches crew first name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'klaus', 'de', {
vesselName: '',
crewNames: ['Klaus Hansen']
})
).toBe(true)
})
it('matches crew last name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'hansen', 'de', {
vesselName: '',
crewNames: ['Klaus Hansen']
})
).toBe(true)
})
})
+45
View File
@@ -0,0 +1,45 @@
export interface LogbookSearchFields {
vesselName: string
crewNames: string[]
}
/** Match full name or any whitespace-separated part (e.g. first or last name). */
export function nameMatchesQuery(name: string, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const normalized = name.trim().toLowerCase()
if (!normalized) return false
if (normalized.includes(q)) return true
return normalized.split(/\s+/).some((part) => part.includes(q))
}
export function logbookMatchesFilter(
lb: { title: string; updatedAt: string },
query: string,
locale: string,
fields?: LogbookSearchFields
): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
if (fields?.vesselName && nameMatchesQuery(fields.vesselName, q)) return true
if (fields?.crewNames?.some((name) => nameMatchesQuery(name, q))) return true
return false
}
+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)
}
}
}