diff --git a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-labels-button-shows-the-labels-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-labels-button-shows-the-labels-1-chromium-linux.png index 6cf9b1c2fd..f32b72c9de 100644 Binary files a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-labels-button-shows-the-labels-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-custom-reports-Validates-that-show-labels-button-shows-the-labels-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png index 83c424f46b..10b65f5aa1 100644 Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png index 23f6789ebd..b74a83c7eb 100644 Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png index f16d50a89c..823782585e 100644 Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png differ diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 77e3590fa1..65404b59bf 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -161,6 +161,7 @@ "cross-env": "^10.1.0", "date-fns": "^4.1.0", "downshift": "9.3.2", + "fzf": "^0.5.2", "html-to-image": "^1.11.13", "hyperformula": "^3.2.0", "i18next": "^25.10.10", diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index 4f0b2a8167..dacac7e270 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -17,13 +17,13 @@ import { Text } from '@actual-app/components/text'; import { TextOneLine } from '@actual-app/components/text-one-line'; import { theme } from '@actual-app/components/theme'; import { View } from '@actual-app/components/view'; -import { getNormalisedString } from '@actual-app/core/shared/normalisation'; import { integerToCurrency } from '@actual-app/core/shared/util'; import type { CategoryEntity, CategoryGroupEntity, } from '@actual-app/core/types/models'; import { css, cx } from '@emotion/css'; +import { Fzf } from 'fzf'; import { useEnvelopeSheetValue } from '#components/budget/envelope/EnvelopeBudgetComponents'; import { makeAmountFullStyle } from '#components/budget/util'; @@ -33,8 +33,7 @@ import { useSheetValue } from '#hooks/useSheetValue'; import { useSyncedPref } from '#hooks/useSyncedPref'; import { envelopeBudget, trackingBudget } from '#spreadsheet/bindings'; -import { Autocomplete, defaultFilterSuggestion } from './Autocomplete'; -import { rankAutocompleteMatch } from './autocompleteRanking'; +import { Autocomplete } from './Autocomplete'; import { ItemHeader } from './ItemHeader'; type CategoryAutocompleteItem = Omit & { @@ -186,22 +185,6 @@ function CategoryList({ ); } -function customSort(obj: CategoryAutocompleteItem, value: string): number { - if (obj.id === 'split') { - return -6; - } - const nameRank = rankAutocompleteMatch(obj.name, value); - if (nameRank < 0) { - return nameRank; - } - // 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 0; -} - type CategoryAutocompleteProps = ComponentProps< typeof Autocomplete > & { @@ -271,30 +254,22 @@ export function CategoryAutocomplete({ suggestions: CategoryAutocompleteItem[], value: string, ): CategoryAutocompleteItem[] => { - const normalizedValue = getNormalisedString(value); - return suggestions - .filter(suggestion => { - if (suggestion.id === 'split') { - return true; - } + const splitItem = suggestions.find(s => s.id === 'split'); + const realSuggestions = suggestions.filter(s => s.id !== 'split'); - if (suggestion.group) { - return ( - getNormalisedString(suggestion.group.name).includes( - normalizedValue, - ) || - getNormalisedString( - suggestion.group.name + ' ' + suggestion.name, - ).includes(normalizedValue) - ); - } + if (!value) { + return suggestions; + } - return defaultFilterSuggestion(suggestion, value); - }) - .sort( - (a, b) => - customSort(a, normalizedValue) - customSort(b, normalizedValue), - ); + const filtered = new Fzf(realSuggestions, { + selector: item => + item.group ? item.group.name + ' ' + item.name : item.name, + limit: 100, + }) + .find(value) + .map(result => result.item); + + return splitItem ? [splitItem, ...filtered] : filtered; }, [], ); diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index 4ace373b6c..6a28f78286 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -30,6 +30,7 @@ import type { PayeeEntity, } from '@actual-app/core/types/models'; import { css, cx } from '@emotion/css'; +import { Fzf } from 'fzf'; import { useAccounts } from '#hooks/useAccounts'; import { useCommonPayees } from '#hooks/useCommonPayees'; @@ -41,12 +42,7 @@ import { useDeletePayeeLocationMutation, } from '#payees'; -import { - Autocomplete, - AutocompleteFooter, - defaultFilterSuggestion, -} from './Autocomplete'; -import { rankAutocompleteMatch } from './autocompleteRanking'; +import { Autocomplete, AutocompleteFooter } from './Autocomplete'; import { ItemHeader } from './ItemHeader'; type PayeeAutocompleteItem = PayeeEntity & @@ -342,13 +338,6 @@ function PayeeList({ ); } -function customSort(obj: PayeeAutocompleteItem, value: string): number { - if (obj.id === 'new') { - return -5; - } - return rankAutocompleteMatch(obj.name, value); -} - export type PayeeAutocompleteProps = ComponentProps< typeof Autocomplete > & { @@ -464,9 +453,12 @@ export function PayeeAutocomplete({ return nearbyPayeesWithType; } - return nearbyPayeesWithType.filter(payee => { - return defaultFilterSuggestion(payee, rawPayee); - }); + return new Fzf(nearbyPayeesWithType, { + selector: item => item.name ?? '', + limit: 100, + }) + .find(rawPayee) + .map(result => result.item); }, [nearbyPayeesWithType, rawPayee]); const handleForgetLocation = useCallback( @@ -506,34 +498,38 @@ export function PayeeAutocomplete({ suggestions: PayeeAutocompleteItem[], value: string, ) => { - const normalizedValue = getNormalisedString(value); - const filtered = suggestions - .filter(suggestion => { - if (suggestion.id === 'new') { - return !value || value === '' || focusTransferPayees ? false : true; - } + // Separate the 'new' sentinel from real payees + const newItem = suggestions.find(s => s.id === 'new'); + const realSuggestions = suggestions.filter(s => s.id !== 'new'); - return defaultFilterSuggestion(suggestion, value); + let filtered: PayeeAutocompleteItem[]; + if (!value) { + filtered = realSuggestions.slice(0, 100); + } else { + filtered = new Fzf(realSuggestions, { + selector: item => item.name ?? '', + limit: 100, }) - .sort( - (a, b) => - customSort(a, normalizedValue) - customSort(b, normalizedValue), - ) - // Only show the first 100 results, users can search to find more. - // If user want to view all payees, it can be done via the manage payees page. - .slice(0, 100); + .find(value) + .map(result => result.item); + } - if (filtered.length >= 2 && filtered[0].id === 'new') { - const firstFiltered = filtered[1]; + // Re-prepend 'new' when the user has typed something + const showNew = newItem && value && value !== '' && !focusTransferPayees; + const results = showNew ? [newItem, ...filtered] : filtered; + + if (results.length >= 2 && results[0].id === 'new') { + const firstFiltered = results[1]; if ( - getNormalisedString(firstFiltered.name) === normalizedValue && + getNormalisedString(firstFiltered.name) === + getNormalisedString(value) && !firstFiltered.transfer_acct ) { - // Exact match found, remove the 'Create payee` option. - return filtered.slice(1); + // Exact match found, remove the 'Create payee' option. + return results.slice(1); } } - return filtered; + return results; }; return ( diff --git a/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts b/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts deleted file mode 100644 index 0fbf627658..0000000000 --- a/packages/desktop-client/src/components/autocomplete/autocompleteRanking.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 42f5deadda..0000000000 --- a/packages/desktop-client/src/components/autocomplete/autocompleteRanking.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { getNormalisedString } from '@actual-app/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/7261.md b/upcoming-release-notes/7261.md new file mode 100644 index 0000000000..61f14e28d3 --- /dev/null +++ b/upcoming-release-notes/7261.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [riid] +--- + +Improve the search algorithm when selecting a payee or category diff --git a/yarn.lock b/yarn.lock index 8a931c1660..e9fb684585 100644 --- a/yarn.lock +++ b/yarn.lock @@ -260,6 +260,7 @@ __metadata: cross-env: "npm:^10.1.0" date-fns: "npm:^4.1.0" downshift: "npm:9.3.2" + fzf: "npm:^0.5.2" html-to-image: "npm:^1.11.13" hyperformula: "npm:^3.2.0" i18next: "npm:^25.10.10" @@ -16738,6 +16739,13 @@ __metadata: languageName: node linkType: hard +"fzf@npm:^0.5.2": + version: 0.5.2 + resolution: "fzf@npm:0.5.2" + checksum: 10/820f9fd068792e792a4c4ab79c719889ee61b2f152089e11a3ba7e9ea081b06bde3b5b6a18131fe4aac61f189640e426a269ccb8b41e946fac9f8c1cc8e98635 + languageName: node + linkType: hard + "generator-function@npm:^2.0.0": version: 2.0.1 resolution: "generator-function@npm:2.0.1"