diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index dccaa17..531d662 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -412,9 +412,15 @@ export default function LogEntryEditor({ currentFingerprint !== savedFingerprint || hasPendingEventForm ) + const saveBeforeLeaveRef = useRef<(() => Promise) | null>(null) + const invokeSaveBeforeLeave = useCallback(async () => { + if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current() + }, []) + const { confirmLeave } = useRegisterUnsavedChanges( `log-entry-${entryId}`, - !readOnly && !loading && isDirty + !readOnly && !loading && isDirty, + invokeSaveBeforeLeave ) const handleBack = async () => { @@ -1207,8 +1213,7 @@ export default function LogEntryEditor({ } } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const saveEntryChanges = useCallback(async () => { if (readOnly) return let eventsToSave = events @@ -1236,7 +1241,6 @@ export default function LogEntryEditor({ setSaving(true) setError(null) - setSuccess(false) try { await persistEntryToDb({ @@ -1245,9 +1249,28 @@ export default function LogEntryEditor({ }) await clearEntryDraft(logbookId, entryId) - - setSuccess(true) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) + } finally { + setSaving(false) + } + }, [ + readOnly, events, hasPendingEventForm, editingEventIndex, isDirty, + resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm, + clearEventForm, persistEntryToDb, logbookId, entryId, t + ]) + + useEffect(() => { + saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges + }, [readOnly, saveEntryChanges]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (readOnly) return + + setSuccess(false) + try { + await saveEntryChanges() + setSuccess(true) setTimeout(() => { setSuccess(false) onBack() @@ -1255,8 +1278,6 @@ export default function LogEntryEditor({ } catch (err: unknown) { console.error('Failed to save entry details:', err) setError(getErrorMessage(err, t('errors.save_failed'))) - } finally { - setSaving(false) } } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 43aa19b..88ab68b 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { cycleAppLanguage } from '../utils/i18nLanguages.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' +import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js' +import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js' import LogbookRoleBadge from './LogbookRoleBadge.tsx' import BetaBadge from './BetaBadge.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' @@ -20,26 +22,6 @@ interface LogbookDashboardProps { onOpenProfile: () => void } -function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean { - const q = query.trim().toLowerCase() - if (!q) return true - - if (lb.title.toLowerCase().includes(q)) return true - - const updated = new Date(lb.updatedAt) - const year = updated.getFullYear().toString() - if (year.includes(q)) return true - - const dateLabel = updated.toLocaleDateString(locale, { - year: 'numeric', - month: 'short', - day: 'numeric' - }).toLowerCase() - if (dateLabel.includes(q)) return true - - return false -} - type LogbookSortKey = 'name' | 'date' type LogbookSortDirection = 'asc' | 'desc' @@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [filterQuery, setFilterQuery] = useState('') + const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState>( + () => new Map() + ) const [sortBy, setSortBy] = useState('date') const [sortDirection, setSortDirection] = useState('desc') const filterInputRef = useRef(null) @@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf loadLogbooks() }, []) + useEffect(() => { + const ids = logbooks.map((lb) => lb.id) + if (ids.length === 0) { + setSearchFieldsByLogbookId(new Map()) + return + } + + let cancelled = false + void loadLogbookSearchFieldsBatch(ids).then((index) => { + if (!cancelled) setSearchFieldsByLogbookId(index) + }) + + return () => { + cancelled = true + } + }, [logbooks]) + const loadLogbooks = async (isRefresh = false) => { if (isRefresh) setRefreshing(true) else setLoading(true) @@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf const filterActive = filterQuery.trim().length > 0 const filteredOwnedLogbooks = useMemo( - () => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)), - [ownedLogbooks, filterQuery, i18n.language] + () => + ownedLogbooks.filter((lb) => + logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id)) + ), + [ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId] ) const filteredSharedLogbooks = useMemo( - () => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)), - [sharedLogbooks, filterQuery, i18n.language] + () => + sharedLogbooks.filter((lb) => + logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id)) + ), + [sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId] ) const sortedOwnedLogbooks = useMemo( () => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language), diff --git a/client/src/components/ModalDialog.tsx b/client/src/components/ModalDialog.tsx index 3e9e2f1..88843c5 100644 --- a/client/src/components/ModalDialog.tsx +++ b/client/src/components/ModalDialog.tsx @@ -10,9 +10,19 @@ import React, { } from 'react' import { useTranslation } from 'react-i18next' +export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard' + interface DialogContextType { showAlert: (message: string, title?: string, confirmText?: string) => Promise showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise + showConfirmLeave: ( + message: string, + title?: string, + stayLabel?: string, + saveLabel?: string, + discardLabel?: string, + options?: { showSave?: boolean } + ) => Promise } const DialogContext = createContext(undefined) @@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false) const [title, setTitle] = useState('') const [message, setMessage] = useState('') - const [type, setType] = useState<'alert' | 'confirm'>('alert') + const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert') const [confirmLabel, setConfirmLabel] = useState('OK') const [cancelLabel, setCancelLabel] = useState('Cancel') + const [saveLabel, setSaveLabel] = useState('') + const [discardLabel, setDiscardLabel] = useState('') + const [showSaveOption, setShowSaveOption] = useState(false) const alertResolveRef = useRef<(() => void) | null>(null) const confirmResolveRef = useRef<((val: boolean) => void) | null>(null) + const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null) const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise => { setMessage(msg) @@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { }) }, [t]) + const showConfirmLeave = useCallback(( + msg: string, + headerTitle?: string, + btnStay?: string, + btnSave?: string, + btnDiscard?: string, + options?: { showSave?: boolean } + ): Promise => { + 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((resolve) => { + confirmLeaveResolveRef.current = resolve + }) + }, [t]) + + const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => { + setIsOpen(false) + if (confirmLeaveResolveRef.current) { + confirmLeaveResolveRef.current(choice) + confirmLeaveResolveRef.current = null + } + }, []) + const handleConfirm = useCallback(() => { setIsOpen(false) if (type === 'confirm' && confirmResolveRef.current) { @@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { }, [type]) const handleCancel = useCallback(() => { + if (type === 'confirm-leave') { + closeConfirmLeave('stay') + return + } setIsOpen(false) if (confirmResolveRef.current) { confirmResolveRef.current(false) confirmResolveRef.current = null } - }, []) + }, [type, closeConfirmLeave]) useEffect(() => { if (!isOpen) return confirmRef.current?.focus() const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - if (type === 'confirm') handleCancel() + if (type === 'confirm' || type === 'confirm-leave') handleCancel() else handleConfirm() } } @@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { }, [isOpen, type, handleCancel, handleConfirm]) const contextValue = useMemo( - () => ({ showAlert, showConfirm }), - [showAlert, showConfirm] + () => ({ showAlert, showConfirm, showConfirmLeave }), + [showAlert, showConfirm, showConfirmLeave] ) return ( @@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { {isOpen && (
- {type === 'confirm' && ( - + {type === 'confirm-leave' ? ( + <> + + {showSaveOption && ( + + )} + + + ) : ( + <> + {type === 'confirm' && ( + + )} + + )} -
diff --git a/client/src/context/UnsavedChangesContext.tsx b/client/src/context/UnsavedChangesContext.tsx index 4ef3a26..8c9d4b1 100644 --- a/client/src/context/UnsavedChangesContext.tsx +++ b/client/src/context/UnsavedChangesContext.tsx @@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx' interface UnsavedChangesContextValue { setDirty: (source: string, dirty: boolean) => void + registerSaveHandler: (source: string, handler: (() => Promise) | null) => void confirmLeave: () => Promise } @@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext(n export function UnsavedChangesProvider({ children }: { children: ReactNode }) { const { t } = useTranslation() - const { showConfirm } = useDialog() + const { showConfirmLeave, showAlert } = useDialog() const dirtySources = useRef(new Set()) + const saveHandlers = useRef(new Map Promise>()) const setDirty = useCallback((source: string, dirty: boolean) => { if (dirty) dirtySources.current.add(source) else dirtySources.current.delete(source) }, []) + const registerSaveHandler = useCallback((source: string, handler: (() => Promise) | null) => { + if (handler) saveHandlers.current.set(source, handler) + else saveHandlers.current.delete(source) + }, []) + const confirmLeave = useCallback(async (): Promise => { if (dirtySources.current.size === 0) return true - return showConfirm( + + const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source)) + const choice = await showConfirmLeave( t('common.unsaved_changes_message'), t('common.unsaved_changes_title'), - t('common.unsaved_changes_leave'), - t('common.unsaved_changes_stay') + t('common.unsaved_changes_stay'), + t('common.unsaved_changes_save_leave'), + t('common.unsaved_changes_discard'), + { showSave: canSave } ) - }, [showConfirm, t]) + + if (choice === 'stay') return false + if (choice === 'discard') return true + + const handlers = [...dirtySources.current] + .map((source) => saveHandlers.current.get(source)) + .filter((handler): handler is () => Promise => handler != null) + + try { + for (const handler of handlers) { + await handler() + } + return true + } catch (err) { + console.error('Failed to save before leaving:', err) + await showAlert(t('errors.save_failed')) + return false + } + }, [showConfirmLeave, showAlert, t]) useEffect(() => { const handler = (e: BeforeUnloadEvent) => { @@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('beforeunload', handler) }, []) - const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave]) + const value = useMemo( + () => ({ setDirty, registerSaveHandler, confirmLeave }), + [setDirty, registerSaveHandler, confirmLeave] + ) return ( @@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue { } /** Register a form/view as having unsaved changes (cleared automatically on unmount). */ -export function useRegisterUnsavedChanges(source: string, isDirty: boolean) { - const { setDirty, confirmLeave } = useUnsavedChangesContext() +export function useRegisterUnsavedChanges( + source: string, + isDirty: boolean, + onSave?: () => Promise +) { + const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext() useEffect(() => { setDirty(source, isDirty) return () => setDirty(source, false) }, [source, isDirty, setDirty]) + useEffect(() => { + if (!onSave) { + registerSaveHandler(source, null) + return + } + registerSaveHandler(source, onSave) + return () => registerSaveHandler(source, null) + }, [source, onSave, registerSaveHandler]) + return { confirmLeave } } diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index fb50c5c..b048f61 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -27,8 +27,10 @@ "common": { "unsaved_changes_title": "Ikke gemte ændringer", "unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.", - "unsaved_changes_leave": "Forladelse", - "unsaved_changes_stay": "Bliv her" + "unsaved_changes_stay": "Bliv her", + "unsaved_changes_save_leave": "Gem og forlad", + "unsaved_changes_discard": "Kassér", + "unsaved_changes_leave": "Forladelse" }, "nav": { "dashboard": "Dashboard", @@ -484,7 +486,7 @@ "edit_success": "Logbog omdøbt med succes", "edit_btn": "Omdøb", "filter_label": "Filtrer logbøger", - "filter_placeholder": "Navn, årstal eller dato ...", + "filter_placeholder": "Navn, årstal, dato, crew eller skib …", "filter_clear": "Nulstil filter", "filter_results": "{{count}} Hits", "filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index ad51858..b2a49f3 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -27,8 +27,10 @@ "common": { "unsaved_changes_title": "Ungespeicherte Änderungen", "unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.", - "unsaved_changes_leave": "Verlassen", - "unsaved_changes_stay": "Bleiben" + "unsaved_changes_stay": "Bleiben", + "unsaved_changes_save_leave": "Speichern & verlassen", + "unsaved_changes_discard": "Verwerfen", + "unsaved_changes_leave": "Verlassen" }, "nav": { "dashboard": "Dashboard", @@ -484,7 +486,7 @@ "edit_success": "Logbuch erfolgreich umbenannt", "edit_btn": "Umbenennen", "filter_label": "Logbücher filtern", - "filter_placeholder": "Name, Jahr oder Datum …", + "filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …", "filter_clear": "Filter zurücksetzen", "filter_results": "{{count}} Treffer", "filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index cfdee67..697264a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -27,8 +27,10 @@ "common": { "unsaved_changes_title": "Unsaved changes", "unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.", - "unsaved_changes_leave": "Leave", - "unsaved_changes_stay": "Stay" + "unsaved_changes_stay": "Stay", + "unsaved_changes_save_leave": "Save & leave", + "unsaved_changes_discard": "Discard", + "unsaved_changes_leave": "Leave" }, "nav": { "dashboard": "Dashboard", @@ -484,7 +486,7 @@ "edit_success": "Logbook renamed successfully", "edit_btn": "Rename", "filter_label": "Filter logbooks", - "filter_placeholder": "Name, year or date …", + "filter_placeholder": "Name, year, date, crew or vessel …", "filter_clear": "Clear filter", "filter_results": "{{count}} matches", "filter_no_results": "No logbooks match your search. Try a different name or year.", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 083bfd6..4d4a448 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -27,8 +27,10 @@ "common": { "unsaved_changes_title": "Ikke-lagrede endringer", "unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.", - "unsaved_changes_leave": "Oppgivelse", - "unsaved_changes_stay": "Bli" + "unsaved_changes_stay": "Bli", + "unsaved_changes_save_leave": "Lagre og forlat", + "unsaved_changes_discard": "Forkast", + "unsaved_changes_leave": "Oppgivelse" }, "nav": { "dashboard": "Dashbord", @@ -484,7 +486,7 @@ "edit_success": "Loggboken har fått nytt navn", "edit_btn": "Gi nytt navn", "filter_label": "Filtrer loggbøker", - "filter_placeholder": "Navn, årstall eller dato ...", + "filter_placeholder": "Navn, årstall, dato, crew eller skip …", "filter_clear": "Tilbakestill filter", "filter_results": "{{count}} Treff", "filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 5ac1905..91e5029 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -27,8 +27,10 @@ "common": { "unsaved_changes_title": "Osparade ändringar", "unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.", - "unsaved_changes_leave": "Övergivande", - "unsaved_changes_stay": "Stanna kvar" + "unsaved_changes_stay": "Stanna kvar", + "unsaved_changes_save_leave": "Spara och lämna", + "unsaved_changes_discard": "Kasta", + "unsaved_changes_leave": "Övergivande" }, "nav": { "dashboard": "Instrumentpanel", @@ -484,7 +486,7 @@ "edit_success": "Loggboken har framgångsrikt bytt namn", "edit_btn": "Byt namn på", "filter_label": "Filtrera loggböcker", - "filter_placeholder": "Namn, årtal eller datum ...", + "filter_placeholder": "Namn, årtal, datum, crew eller fartyg …", "filter_clear": "Återställ filter", "filter_results": "{{count}} Träffar", "filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.", diff --git a/client/src/services/logbookSearchIndex.ts b/client/src/services/logbookSearchIndex.ts new file mode 100644 index 0000000..f3065df --- /dev/null +++ b/client/src/services/logbookSearchIndex.ts @@ -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 { + 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>, + pool: Map +): string[] { + const names = new Set() + + 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 { + 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> { + const uniqueIds = [...new Set(logbookIds)] + const entries = await Promise.all( + uniqueIds.map(async (id) => [id, await loadLogbookSearchFields(id)] as const) + ) + return new Map(entries) +} diff --git a/client/src/utils/logbookFilter.test.ts b/client/src/utils/logbookFilter.test.ts new file mode 100644 index 0000000..c4f7412 --- /dev/null +++ b/client/src/utils/logbookFilter.test.ts @@ -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) + }) +}) diff --git a/client/src/utils/logbookFilter.ts b/client/src/utils/logbookFilter.ts new file mode 100644 index 0000000..a84c94c --- /dev/null +++ b/client/src/utils/logbookFilter.ts @@ -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 +}