Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d511e0f8c | |||
| 18a68367bc | |||
| 90518372d8 | |||
| 9d22cb61c7 | |||
| bb501ba644 | |||
| f51f088f1e | |||
| 3d2918e0fe |
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
|
|||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
|
import {
|
||||||
|
buildEntryListCache,
|
||||||
|
entryListItemFromLocal,
|
||||||
|
putEntryRecord
|
||||||
|
} from '../utils/entryListCache.js'
|
||||||
|
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
@@ -118,22 +124,32 @@ export default function LogEntriesList({
|
|||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
|
||||||
const list: DecryptedEntryItem[] = []
|
const list: DecryptedEntryItem[] = []
|
||||||
|
const needsDecrypt: typeof local = []
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const cached = entryListItemFromLocal(entry)
|
||||||
if (decrypted) {
|
if (cached) {
|
||||||
list.push({
|
list.push(cached)
|
||||||
id: entry.payloadId,
|
} else {
|
||||||
date: decrypted.date || '',
|
needsDecrypt.push(entry)
|
||||||
dayOfTravel: decrypted.dayOfTravel || '',
|
|
||||||
departure: decrypted.departure || '',
|
|
||||||
destination: decrypted.destination || '',
|
|
||||||
updatedAt: entry.updatedAt,
|
|
||||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||||
|
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||||
|
if (!decrypted) return
|
||||||
|
|
||||||
|
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||||
|
list.push({
|
||||||
|
id: entry.payloadId,
|
||||||
|
...listCache,
|
||||||
|
updatedAt: entry.updatedAt
|
||||||
|
})
|
||||||
|
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
|
||||||
|
console.warn('Failed to persist entry list cache:', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Sort chronological descending (by date, or dayOfTravel numerical)
|
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
@@ -309,14 +325,17 @@ export default function LogEntriesList({
|
|||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
// Save locally
|
// Save locally
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId: localId,
|
{
|
||||||
logbookId,
|
payloadId: localId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: nowStr
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: nowStr
|
||||||
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
// Queue for background sync
|
// Queue for background sync
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
|||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryListCache {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalEntry {
|
export interface LocalEntry {
|
||||||
payloadId: string
|
payloadId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
|||||||
iv: string
|
iv: string
|
||||||
tag: string
|
tag: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||||
|
listCache?: EntryListCache
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalPhoto {
|
export interface LocalPhoto {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import { syncPersonPool } from './personPoolSync.js'
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
import type { PersonData } from '../types/person.js'
|
import type { PersonData } from '../types/person.js'
|
||||||
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
|
|||||||
const encrypted = await encryptJson(data, key)
|
const encrypted = await encryptJson(data, key)
|
||||||
|
|
||||||
if (type === 'entry') {
|
if (type === 'entry') {
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId,
|
{
|
||||||
logbookId,
|
payloadId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: now
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: now
|
||||||
|
},
|
||||||
|
data as Record<string, unknown>
|
||||||
|
)
|
||||||
} else if (type === 'yacht') {
|
} else if (type === 'yacht') {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
|
||||||
|
import type { LocalEntry } from '../services/db.js'
|
||||||
|
|
||||||
|
describe('entryListCache', () => {
|
||||||
|
it('builds cache fields from decrypted entry', async () => {
|
||||||
|
const cache = await buildEntryListCache({
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '3',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
signSkipper: 'Max'
|
||||||
|
})
|
||||||
|
expect(cache).toEqual({
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '3',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
skipperSignStatus: 'valid'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps cached local entry to list item', () => {
|
||||||
|
const entry: LocalEntry = {
|
||||||
|
payloadId: 'e1',
|
||||||
|
logbookId: 'lb1',
|
||||||
|
encryptedData: 'x',
|
||||||
|
iv: 'i',
|
||||||
|
tag: 't',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||||
|
listCache: {
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
skipperSignStatus: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(entryListItemFromLocal(entry)).toEqual({
|
||||||
|
id: 'e1',
|
||||||
|
date: '2026-06-02',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||||
|
skipperSignStatus: 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when cache is missing', () => {
|
||||||
|
const entry: LocalEntry = {
|
||||||
|
payloadId: 'e1',
|
||||||
|
logbookId: 'lb1',
|
||||||
|
encryptedData: 'x',
|
||||||
|
iv: 'i',
|
||||||
|
tag: 't',
|
||||||
|
updatedAt: '2026-06-02T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
expect(entryListItemFromLocal(entry)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
|
||||||
|
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
|
||||||
|
|
||||||
|
export type { EntryListCache }
|
||||||
|
|
||||||
|
export interface EntryListItem {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
updatedAt: string
|
||||||
|
skipperSignStatus: SkipperSignStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
|
||||||
|
return {
|
||||||
|
date: String(decrypted.date || ''),
|
||||||
|
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||||
|
departure: String(decrypted.departure || ''),
|
||||||
|
destination: String(decrypted.destination || ''),
|
||||||
|
skipperSignStatus: await getSkipperSignStatus(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
|
||||||
|
if (!entry.listCache) return null
|
||||||
|
return {
|
||||||
|
id: entry.payloadId,
|
||||||
|
date: entry.listCache.date,
|
||||||
|
dayOfTravel: entry.listCache.dayOfTravel,
|
||||||
|
departure: entry.listCache.departure,
|
||||||
|
destination: entry.listCache.destination,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
skipperSignStatus: entry.listCache.skipperSignStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
|
||||||
|
|
||||||
|
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
|
||||||
|
export async function putEntryRecord(
|
||||||
|
record: LocalEntryPut,
|
||||||
|
decryptedForCache?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const listCache =
|
||||||
|
record.listCache ??
|
||||||
|
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
|
||||||
|
|
||||||
|
await db.entries.put({
|
||||||
|
...record,
|
||||||
|
...(listCache ? { listCache } : {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
|
||||||
|
export function persistEntryListCache(
|
||||||
|
payloadId: string,
|
||||||
|
decrypted: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
void buildEntryListCache(decrypted)
|
||||||
|
.then((listCache) => db.entries.update(payloadId, { listCache }))
|
||||||
|
.catch((err) => console.warn('Failed to persist entry list cache:', err))
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Yield so long tasks can interleave with paint and input handling. */
|
||||||
|
export function yieldToMain(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an async handler over items in batches, yielding between batches. */
|
||||||
|
export async function forEachInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
batchSize: number,
|
||||||
|
handler: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return
|
||||||
|
const size = Math.max(1, batchSize)
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
if (i > 0) await yieldToMain()
|
||||||
|
const batch = items.slice(i, i + size)
|
||||||
|
for (const item of batch) {
|
||||||
|
await handler(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user