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>
This commit is contained in:
2026-06-01 22:30:41 +02:00
co-authored by Cursor
parent c5a9b39057
commit 3d2918e0fe
12 changed files with 423 additions and 79 deletions
+29 -8
View File
@@ -412,9 +412,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 () => {
@@ -1207,8 +1213,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 +1241,6 @@ export default function LogEntryEditor({
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false)
try { try {
await persistEntryToDb({ await persistEntryToDb({
@@ -1245,9 +1249,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 +1278,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.",
+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)
}
+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
}