Compare commits

...

7 Commits

Author SHA1 Message Date
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
22 changed files with 718 additions and 154 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.88 0.1.0.92
+38 -19
View File
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js' import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -118,22 +124,32 @@ export default function LogEntriesList({
const local = await db.entries.where({ logbookId }).toArray() const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) { for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const cached = entryListItemFromLocal(entry)
if (decrypted) { if (cached) {
list.push({ list.push(cached)
id: entry.payloadId, } else {
date: decrypted.date || '', needsDecrypt.push(entry)
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
} }
} }
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
list.push({
id: entry.payloadId,
...listCache,
updatedAt: entry.updatedAt
})
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
console.warn('Failed to persist entry list cache:', err)
})
})
// Sort chronological descending (by date, or dayOfTravel numerical) // Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => { list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -309,14 +325,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally // Save locally
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
// Queue for background sync // Queue for background sync
await db.syncQueue.put({ await db.syncQueue.put({
+41 -16
View File
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
@@ -412,9 +413,15 @@ export default function LogEntryEditor({
currentFingerprint !== savedFingerprint || hasPendingEventForm currentFingerprint !== savedFingerprint || hasPendingEventForm
) )
const saveBeforeLeaveRef = useRef<(() => Promise<void>) | null>(null)
const invokeSaveBeforeLeave = useCallback(async () => {
if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current()
}, [])
const { confirmLeave } = useRegisterUnsavedChanges( const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`, `log-entry-${entryId}`,
!readOnly && !loading && isDirty !readOnly && !loading && isDirty,
invokeSaveBeforeLeave
) )
const handleBack = async () => { const handleBack = async () => {
@@ -448,14 +455,17 @@ export default function LogEntryEditor({
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', action: 'update',
@@ -1207,8 +1217,7 @@ export default function LogEntryEditor({
} }
} }
const handleSubmit = async (e: React.FormEvent) => { const saveEntryChanges = useCallback(async () => {
e.preventDefault()
if (readOnly) return if (readOnly) return
let eventsToSave = events let eventsToSave = events
@@ -1236,7 +1245,6 @@ export default function LogEntryEditor({
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false)
try { try {
await persistEntryToDb({ await persistEntryToDb({
@@ -1245,9 +1253,28 @@ export default function LogEntryEditor({
}) })
await clearEntryDraft(logbookId, entryId) await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) 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(() => { setTimeout(() => {
setSuccess(false) setSuccess(false)
onBack() onBack()
@@ -1255,8 +1282,6 @@ export default function LogEntryEditor({
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to save entry details:', err) console.error('Failed to save entry details:', err)
setError(getErrorMessage(err, t('errors.save_failed'))) 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 { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.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 LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -20,26 +22,6 @@ interface LogbookDashboardProps {
onOpenProfile: () => void 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 LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc' type LogbookSortDirection = 'asc' | 'desc'
@@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('') const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date') const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc') const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null) const filterInputRef = useRef<HTMLInputElement>(null)
@@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
loadLogbooks() 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) => { const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true) if (isRefresh) setRefreshing(true)
else setLoading(true) else setLoading(true)
@@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const filterActive = filterQuery.trim().length > 0 const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo( 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( 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( const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language), () => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
+106 -24
View File
@@ -10,9 +10,19 @@ import React, {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType { interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void> showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean> 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) const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [message, setMessage] = 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 [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel') 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 alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => 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> => { const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg) setMessage(msg)
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}) })
}, [t]) }, [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(() => { const handleConfirm = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (type === 'confirm' && confirmResolveRef.current) { if (type === 'confirm' && confirmResolveRef.current) {
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [type]) }, [type])
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
setIsOpen(false) setIsOpen(false)
if (confirmResolveRef.current) { if (confirmResolveRef.current) {
confirmResolveRef.current(false) confirmResolveRef.current(false)
confirmResolveRef.current = null confirmResolveRef.current = null
} }
}, []) }, [type, closeConfirmLeave])
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
confirmRef.current?.focus() confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (type === 'confirm') handleCancel() if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm() else handleConfirm()
} }
} }
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [isOpen, type, handleCancel, handleConfirm]) }, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo( const contextValue = useMemo(
() => ({ showAlert, showConfirm }), () => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm] [showAlert, showConfirm, showConfirmLeave]
) )
return ( return (
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{isOpen && ( {isOpen && (
<div <div
className="custom-dialog-overlay" className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm} onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
> >
<div <div
className="custom-dialog-card glass scale-in" className="custom-dialog-card glass scale-in"
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{message} {message}
</p> </p>
<div className="custom-dialog-actions"> <div className="custom-dialog-actions">
{type === 'confirm' && ( {type === 'confirm-leave' ? (
<button <>
type="button" <button
className="btn secondary" ref={confirmRef}
onClick={handleCancel} type="button"
style={{ width: 'auto', padding: '8px 20px', margin: 0 }} className="btn secondary"
> onClick={handleCancel}
{cancelLabel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
</button> >
{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> </div>
</div> </div>
+53 -8
View File
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue { interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean> confirmLeave: () => Promise<boolean>
} }
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
export function UnsavedChangesProvider({ children }: { children: ReactNode }) { export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>()) const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => { const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source) if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(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> => { const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true 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_message'),
t('common.unsaved_changes_title'), 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(() => { useEffect(() => {
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
}, []) }, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave]) const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return ( return (
<UnsavedChangesContext.Provider value={value}> <UnsavedChangesContext.Provider value={value}>
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
} }
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */ /** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) { export function useRegisterUnsavedChanges(
const { setDirty, confirmLeave } = useUnsavedChangesContext() source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => { useEffect(() => {
setDirty(source, isDirty) setDirty(source, isDirty)
return () => setDirty(source, false) return () => setDirty(source, false)
}, [source, isDirty, setDirty]) }, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave } return { confirmLeave }
} }
+5 -3
View File
@@ -27,8 +27,10 @@
"common": { "common": {
"unsaved_changes_title": "Ikke gemte ændringer", "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_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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbog omdøbt med succes", "edit_success": "Logbog omdøbt med succes",
"edit_btn": "Omdøb", "edit_btn": "Omdøb",
"filter_label": "Filtrer logbøger", "filter_label": "Filtrer logbøger",
"filter_placeholder": "Navn, årstal eller dato ...", "filter_placeholder": "Navn, årstal, dato, crew eller skib …",
"filter_clear": "Nulstil filter", "filter_clear": "Nulstil filter",
"filter_results": "{{count}} Hits", "filter_results": "{{count}} Hits",
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.", "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": { "common": {
"unsaved_changes_title": "Ungespeicherte Änderungen", "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_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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbuch erfolgreich umbenannt", "edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen", "edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern", "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_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer", "filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.", "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": { "common": {
"unsaved_changes_title": "Unsaved changes", "unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.", "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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -484,7 +486,7 @@
"edit_success": "Logbook renamed successfully", "edit_success": "Logbook renamed successfully",
"edit_btn": "Rename", "edit_btn": "Rename",
"filter_label": "Filter logbooks", "filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …", "filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter", "filter_clear": "Clear filter",
"filter_results": "{{count}} matches", "filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.", "filter_no_results": "No logbooks match your search. Try a different name or year.",
+5 -3
View File
@@ -27,8 +27,10 @@
"common": { "common": {
"unsaved_changes_title": "Ikke-lagrede endringer", "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_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": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
@@ -484,7 +486,7 @@
"edit_success": "Loggboken har fått nytt navn", "edit_success": "Loggboken har fått nytt navn",
"edit_btn": "Gi nytt navn", "edit_btn": "Gi nytt navn",
"filter_label": "Filtrer loggbøker", "filter_label": "Filtrer loggbøker",
"filter_placeholder": "Navn, årstall eller dato ...", "filter_placeholder": "Navn, årstall, dato, crew eller skip …",
"filter_clear": "Tilbakestill filter", "filter_clear": "Tilbakestill filter",
"filter_results": "{{count}} Treff", "filter_results": "{{count}} Treff",
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.", "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": { "common": {
"unsaved_changes_title": "Osparade ändringar", "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_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": { "nav": {
"dashboard": "Instrumentpanel", "dashboard": "Instrumentpanel",
@@ -484,7 +486,7 @@
"edit_success": "Loggboken har framgångsrikt bytt namn", "edit_success": "Loggboken har framgångsrikt bytt namn",
"edit_btn": "Byt namn på", "edit_btn": "Byt namn på",
"filter_label": "Filtrera loggböcker", "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_clear": "Återställ filter",
"filter_results": "{{count}} Träffar", "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.", "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( export async function apiFetch(
input: string, input: string,
init: RequestInit = {} init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> { ): Promise<Response> {
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) { if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
return fetch(input, { const controller = new AbortController()
...init, const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
headers,
credentials: 'include' 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> { export async function apiJson<T>(
const res = await apiFetch(input, init) input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
if (!res.ok) { if (!res.ok) {
const message = const message =
+10
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string updatedAt: string
} }
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry { export interface LocalEntry {
payloadId: string payloadId: string
logbookId: string logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string iv: string
tag: string tag: string
updatedAt: string updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
} }
export interface LocalPhoto { export interface LocalPhoto {
+12 -8
View File
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key) const encrypted = await encryptJson(data, key)
if (type === 'entry') { if (type === 'entry') {
await db.entries.put({ await putEntryRecord(
payloadId, {
logbookId, payloadId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') { } else if (type === 'yacht') {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
+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)
}
+23 -16
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
@@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'create', action: 'create',
@@ -305,14 +309,17 @@ async function persistEntry(
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', action: 'update',
+19 -13
View File
@@ -8,6 +8,7 @@ import {
type SyncConflict type SyncConflict
} from './syncConflicts.js' } from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -130,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
} }
function scheduleResync(logbookId: string) { function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId) pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
} }
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN' 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 } = const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
await response.json() await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
await yieldToMain()
const serverSnapshot: PulledServerPayload = { const serverSnapshot: PulledServerPayload = {
yacht, yacht,
deviation, deviation,
@@ -375,7 +375,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 3. Sync Crew List Payloads (legacy) // 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>() const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) { if (crews && Array.isArray(crews)) {
for (const c of crews) { await forEachInBatches(crews, 20, async (c) => {
serverCrewMap.set(c.payloadId, c) serverCrewMap.set(c.payloadId, c)
const local = await db.crews.get(c.payloadId) const local = await db.crews.get(c.payloadId)
if (!local || isNewer(c.updatedAt, local.updatedAt)) { if (!local || isNewer(c.updatedAt, local.updatedAt)) {
@@ -388,7 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt updatedAt: c.updatedAt
}) })
} }
} })
} }
// Deletions for Crew: If present locally but not on server, and not pending creation locally // Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -408,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads // 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>() const serverEntryMap = new Map<string, any>()
if (entries && Array.isArray(entries)) { if (entries && Array.isArray(entries)) {
for (const e of entries) { await forEachInBatches(entries, 15, async (e) => {
serverEntryMap.set(e.payloadId, e) serverEntryMap.set(e.payloadId, e)
const local = await db.entries.get(e.payloadId) const local = await db.entries.get(e.payloadId)
if (!local || isNewer(e.updatedAt, local.updatedAt)) { if (!local || isNewer(e.updatedAt, local.updatedAt)) {
@@ -421,7 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt updatedAt: e.updatedAt
}) })
} }
} })
} }
// Deletions for Entries // Deletions for Entries
@@ -440,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos // 5. Sync Photos
const serverPhotoMap = new Map<string, any>() const serverPhotoMap = new Map<string, any>()
if (photos && Array.isArray(photos)) { if (photos && Array.isArray(photos)) {
for (const p of photos) { await forEachInBatches(photos, 20, async (p) => {
serverPhotoMap.set(p.payloadId, p) serverPhotoMap.set(p.payloadId, p)
const local = await db.photos.get(p.payloadId) const local = await db.photos.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) { if (!local || isNewer(p.updatedAt, local.updatedAt)) {
@@ -455,7 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt updatedAt: p.updatedAt
}) })
} }
} })
} }
// Deletions for Photos // Deletions for Photos
@@ -474,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 6. Sync GPS Tracks // 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>() const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) { if (gpsTracks && Array.isArray(gpsTracks)) {
for (const gt of gpsTracks) { await forEachInBatches(gpsTracks, 10, async (gt) => {
serverGpsTrackMap.set(gt.entryId, gt) serverGpsTrackMap.set(gt.entryId, gt)
const local = await db.gpsTracks.get(gt.entryId) const local = await db.gpsTracks.get(gt.entryId)
if (!local || isNewer(gt.updatedAt, local.updatedAt)) { if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
@@ -487,7 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt updatedAt: gt.updatedAt
}) })
} }
} })
} }
// Deletions for GPS Tracks // Deletions for GPS Tracks
@@ -535,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
recomputeSyncingState() 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)
}
}
}