diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index 19d3f404370..6cd4d647d36 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -27,6 +27,7 @@ import type { } from 'loot-core/types/models'; import { Autocomplete, defaultFilterSuggestion } from './Autocomplete'; +import { rankAutocompleteMatch } from './autocompleteRanking'; import { ItemHeader } from './ItemHeader'; import { useEnvelopeSheetValue } from '@desktop-client/components/budget/envelope/EnvelopeBudgetComponents'; @@ -190,18 +191,19 @@ function CategoryList({ } function customSort(obj: CategoryAutocompleteItem, value: string): number { - const name = getNormalisedString(obj.name); - const groupName = obj.group ? getNormalisedString(obj.group.name) : ''; if (obj.id === 'split') { - return -2; + return -6; } - if (name.includes(value)) { - return -1; + const nameRank = rankAutocompleteMatch(obj.name, value); + if (nameRank < 0) { + return nameRank; } - if (groupName.includes(value)) { - return 0; + // Group name matching: ranks above no-match but below all name tiers. + const groupName = obj.group ? getNormalisedString(obj.group.name) : ''; + if (groupName.includes(getNormalisedString(value))) { + return -0.5; } - return 1; + return 0; } type CategoryAutocompleteProps = ComponentProps< diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index cdf329804e0..772c9814344 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -28,6 +28,7 @@ import { AutocompleteFooter, defaultFilterSuggestion, } from './Autocomplete'; +import { rankAutocompleteMatch } from './autocompleteRanking'; import { ItemHeader } from './ItemHeader'; import { useAccounts } from '@desktop-client/hooks/useAccounts'; @@ -295,14 +296,10 @@ function PayeeList({ } function customSort(obj: PayeeAutocompleteItem, value: string): number { - const name = getNormalisedString(obj.name); if (obj.id === 'new') { - return -2; + return -5; } - if (name.includes(value)) { - return -1; - } - return 1; + return rankAutocompleteMatch(obj.name, value); } export type PayeeAutocompleteProps = ComponentProps< diff --git a/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts b/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts new file mode 100644 index 00000000000..0fbf627658a --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'vitest'; + +import { rankAutocompleteMatch } from './autocompleteRanking'; + +describe('rankAutocompleteMatch', () => { + test('exact match returns -4', () => { + expect(rankAutocompleteMatch('Me', 'me')).toBe(-4); + expect(rankAutocompleteMatch('me', 'Me')).toBe(-4); + expect(rankAutocompleteMatch('Groceries', 'groceries')).toBe(-4); + }); + + test('prefix match returns -3', () => { + expect(rankAutocompleteMatch('Memory Express', 'me')).toBe(-3); + expect(rankAutocompleteMatch('Merchant', 'me')).toBe(-3); + }); + + test('word-boundary match returns -2', () => { + expect(rankAutocompleteMatch('French Meadow', 'me')).toBe(-2); + expect(rankAutocompleteMatch('Self-medicate', 'me')).toBe(-2); + }); + + test('contains match returns -1', () => { + expect(rankAutocompleteMatch('Framework', 'me')).toBe(-1); + expect(rankAutocompleteMatch('Homestead', 'me')).toBe(-1); + expect(rankAutocompleteMatch('Gamestop', 'me')).toBe(-1); + }); + + test('no match returns 0', () => { + expect(rankAutocompleteMatch('Apple Store', 'me')).toBe(0); + expect(rankAutocompleteMatch('Target', 'me')).toBe(0); + }); + + test('empty input returns 0', () => { + expect(rankAutocompleteMatch('Anything', '')).toBe(0); + }); + + test('diacritics are normalised', () => { + expect(rankAutocompleteMatch('Cafe', 'café')).toBe(-4); + expect(rankAutocompleteMatch('Café', 'cafe')).toBe(-4); + expect(rankAutocompleteMatch('Résumé', 're')).toBe(-3); + }); + + test('case insensitive', () => { + expect(rankAutocompleteMatch('METRO', 'me')).toBe(-3); + expect(rankAutocompleteMatch('metro', 'ME')).toBe(-3); + }); + + test('realistic payee scenario for "me"', () => { + const payees = [ + 'Me', + 'Memory Express', + 'Merchant', + 'French Meadow', + 'Self-medicate', + 'Framework', + 'Homestead', + 'Gamestop', + 'Apple Store', + 'Target', + ]; + + const ranked = payees + .map(name => ({ name, rank: rankAutocompleteMatch(name, 'me') })) + .sort((a, b) => a.rank - b.rank); + + // Exact match first + expect(ranked[0]).toEqual({ name: 'Me', rank: -4 }); + + // Prefix matches next + const prefixMatches = ranked.filter(r => r.rank === -3); + expect(prefixMatches.map(r => r.name)).toEqual( + expect.arrayContaining(['Memory Express', 'Merchant']), + ); + + // Word-boundary matches + const wordBoundaryMatches = ranked.filter(r => r.rank === -2); + expect(wordBoundaryMatches.map(r => r.name)).toEqual( + expect.arrayContaining(['French Meadow', 'Self-medicate']), + ); + + // Contains matches + const containsMatches = ranked.filter(r => r.rank === -1); + expect(containsMatches.map(r => r.name)).toEqual( + expect.arrayContaining(['Framework', 'Homestead', 'Gamestop']), + ); + + // No matches + const noMatches = ranked.filter(r => r.rank === 0); + expect(noMatches.map(r => r.name)).toEqual( + expect.arrayContaining(['Apple Store', 'Target']), + ); + }); + + test('single character input', () => { + expect(rankAutocompleteMatch('A', 'a')).toBe(-4); + expect(rankAutocompleteMatch('Apple', 'a')).toBe(-3); + expect(rankAutocompleteMatch('Big Apple', 'a')).toBe(-2); + expect(rankAutocompleteMatch('Banana', 'a')).toBe(-1); + }); + + test('hyphenated word boundaries', () => { + expect(rankAutocompleteMatch('Co-op', 'op')).toBe(-2); + expect(rankAutocompleteMatch('Self-checkout', 'check')).toBe(-2); + }); +}); diff --git a/packages/desktop-client/src/components/autocomplete/autocompleteRanking.ts b/packages/desktop-client/src/components/autocomplete/autocompleteRanking.ts new file mode 100644 index 00000000000..307b2f037d4 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/autocompleteRanking.ts @@ -0,0 +1,43 @@ +import { getNormalisedString } from 'loot-core/shared/normalisation'; + +/** + * Returns a numeric rank for how well `name` matches `input`. + * Lower values = better match (convention used by `.sort()`). + * + * -4 Exact match (normalised) + * -3 Prefix match (name starts with input) + * -2 Word-boundary match (a non-first word starts with input) + * -1 Contains match (input found anywhere) + * 0 No match + */ +export function rankAutocompleteMatch(name: string, input: string): number { + if (!input) { + return 0; + } + + const normName = getNormalisedString(name); + const normInput = getNormalisedString(input); + + if (normName === normInput) { + return -4; + } + + if (normName.startsWith(normInput)) { + return -3; + } + + // Check if any non-first word starts with the input. + // Words are split on whitespace and hyphens. + const words = normName.split(/[\s-]/); + for (let i = 1; i < words.length; i++) { + if (words[i].startsWith(normInput)) { + return -2; + } + } + + if (normName.includes(normInput)) { + return -1; + } + + return 0; +} diff --git a/upcoming-release-notes/6972.md b/upcoming-release-notes/6972.md new file mode 100644 index 00000000000..7c8ee4c3750 --- /dev/null +++ b/upcoming-release-notes/6972.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [J-LCRX] +--- + +Improve autocomplete sorting with tiered ranking for payee and category dropdowns