[AI] Replace payee and category autocomplete filter/sort with fzf fuzy search (#7261)

* [AI] feat(web): replace custom autocomplete ranking with fzf

Replace substring-based filter/sort in PayeeAutocomplete and
CategoryAutocomplete with fzf fuzzy search. Remove deprecated
autocompleteRanking utility.

Closes #7261

* Update #7261 release notes

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7261

* Regenerate yarn.lock file

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7261

* Restore e2e snapshots

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7261

---------

Co-authored-by: Nadir Miralimov <riid@pm.me>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Nadir Miralimov
2026-05-11 14:33:36 -07:00
committed by GitHub
parent d015858e4a
commit 8263e58eb2
11 changed files with 63 additions and 225 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -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",

View File

@@ -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<CategoryEntity, 'group'> & {
@@ -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<CategoryAutocompleteItem>
> & {
@@ -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;
},
[],
);

View File

@@ -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<PayeeAutocompleteItem>
> & {
@@ -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 (

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [riid]
---
Improve the search algorithm when selecting a payee or category

View File

@@ -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"