From 05897c33840b7f7032444eab3f09c57c2357c5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:10:18 +0000 Subject: [PATCH] refactor(finder): name the searchable fields where they are matched Collecting the fields of an entry into a list and then walking that list kept the two apart, but it needed a type pairing a filter with a value, built an array for every entry on every keystroke, and left the order fields are reported in a property of a list built elsewhere. Naming cue, title and note where they are matched drops both the type and the collecting step, and reads as the preference order it is. Custom fields stay generic because the project names them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY --- .../src/views/editor/finder/useFinder.tsx | 70 +++++++++---------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index bd532866b..feb422240 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -28,7 +28,10 @@ type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone; type FinderFilter = { key: string; label: string }; -/** The fields common to every project, custom fields are appended at runtime */ +/** + * Offered to the user as filter badges. Index is a positional lookup rather than a + * text field, so it is handled separately from the fields a search runs over. + */ const staticFilters: FinderFilter[] = [ { key: indexFilter, label: 'Index' }, { key: 'cue', label: 'Cue' }, @@ -58,6 +61,7 @@ type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: numbe const noResults: SearchOutcome = { results: [], error: null, total: 0 }; +/** Groups are the only searchable entry with neither a cue nor a parent */ function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) { return { id: entry.id, @@ -71,34 +75,6 @@ function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber } satisfies FinderResult; } -type SearchableField = FinderFilter & { value: string }; - -/** - * The text fields of an entry, in the order we prefer to report a match. - * Image custom fields hold a URL, which nobody searches for. - */ -function getSearchableFields(entry: SearchableEntry, customFields: CustomFields): SearchableField[] { - const fields: SearchableField[] = []; - - if ('cue' in entry && entry.cue) { - fields.push({ key: 'cue', label: 'Cue', value: entry.cue }); - } - if (entry.title) { - fields.push({ key: 'title', label: 'Title', value: entry.title }); - } - if (entry.note) { - fields.push({ key: 'note', label: 'Note', value: entry.note }); - } - for (const [key, value] of Object.entries(entry.custom)) { - const definition = customFields[key]; - if (value && definition?.type === 'text') { - fields.push({ key, label: definition.label || key, value }); - } - } - - return fields; -} - /** Shows enough of a long value for the user to see why it matched */ function makeExcerpt(value: string, matchIndex: number, searchLength: number): string { const start = Math.max(0, matchIndex - excerptPadding); @@ -106,22 +82,44 @@ function makeExcerpt(value: string, matchIndex: number, searchLength: number): s return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`; } -/** The first field of an entry to contain the search string, if any */ +/** + * The first field of an entry to contain the search string, if any. + * Fields are tried in the order we prefer to report a match. + */ function findMatch( entry: SearchableEntry, customFields: CustomFields, filterKey: MaybeString, searchString: string, ): FinderMatch | null { - for (const field of getSearchableFields(entry, customFields)) { - if (filterKey !== null && field.key !== filterKey) { + function check(key: string, label: string, value: string): FinderMatch | null { + if (!value || (filterKey !== null && key !== filterKey)) { + return null; + } + const matchIndex = value.toLowerCase().indexOf(searchString); + if (matchIndex === -1) { + return null; + } + return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) }; + } + + // groups have no cue, the rest is common to every searchable entry + const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null; + const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note); + if (match !== null) { + return match; + } + + // custom fields are named by the project, so these can only be reached generically + for (const [key, value] of Object.entries(entry.custom)) { + const definition = customFields[key]; + if (definition?.type !== 'text') { continue; } - const matchIndex = field.value.toLowerCase().indexOf(searchString); - if (matchIndex !== -1) { - return { key: field.key, label: field.label, excerpt: makeExcerpt(field.value, matchIndex, searchString.length) }; - } + const custom = check(key, definition.label || key, value); + if (custom) return custom; } + return null; }