Skip to content

Commit 2355afe

Browse files
MohamedKiouazLiamMorrow
authored andcommitted
Use fuzzy exercise matching
1 parent af3ed46 commit 2355afe

3 files changed

Lines changed: 108 additions & 15 deletions

File tree

app/components/presentation/workout-editor/exercise-filterer.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fuzzyMatchScore } from '@/components/presentation/workout-editor/exercise-fuzzy-match';
12
import ExerciseSearchAndFilters from '@/components/presentation/workout-editor/exercise-search-and-filters';
23
import { ExerciseDescriptor } from '@/models/exercise-models';
34
import { useAppSelector } from '@/store';
@@ -19,35 +20,39 @@ export default function ExerciseFilterer(props: {
1920

2021
const search = useDebouncedCallback(() => {
2122
const trimmed = searchText.trim();
22-
const escaped = escapeRegExp(trimmed);
23-
const searchRegex = new RegExp(escaped, 'i');
24-
const startsWithRegex = new RegExp('^' + escaped, 'i');
25-
const fullMatchRegex = new RegExp('^' + escaped + '$', 'i');
23+
const trimmedSearchText = escapeRegExp(trimmed);
24+
const fullMatchRegex = new RegExp('^' + trimmedSearchText + '$', 'i');
2625
let hasExactMatch = false;
2726
const newFilteredExercises = Enumerable.from(Object.entries(exercises))
27+
.select((x) => ({
28+
entry: { id: x[0], exercise: x[1] },
29+
score: trimmedSearchText
30+
? fuzzyMatchScore(trimmedSearchText, x[1].name)
31+
: 0,
32+
}))
2833
.where(
2934
(x) =>
3035
(!muscleFilters.length ||
31-
x[1].muscles.some((exerciseMuscle) =>
36+
x.entry.exercise.muscles.some((exerciseMuscle) =>
3237
muscleFilters.includes(exerciseMuscle),
3338
)) &&
34-
(!trimmed || searchRegex.test(x[1].name)),
39+
(!trimmedSearchText || x.score !== null),
3540
)
41+
.orderByDescending((x) => x.score ?? 0)
42+
.thenBy((x) => x.entry.exercise.name)
3643
.doAction((x) => {
37-
if (!hasExactMatch && trimmed && fullMatchRegex.test(x[1].name)) {
44+
if (
45+
!hasExactMatch &&
46+
trimmedSearchText &&
47+
fullMatchRegex.test(x.entry.exercise.name)
48+
) {
3849
hasExactMatch = true;
3950
}
4051
})
41-
// If the exercise starts with the search term, then it is a good match and should be brought to the top
42-
.orderByDescending(
43-
(x) =>
44-
(startsWithRegex.test(x[1].name) ? 1 : 0) +
45-
(fullMatchRegex.test(x[1].name) ? 1 : 0),
46-
)
47-
.select((x) => x[0])
52+
.select((x) => x.entry.id)
4853
.toArray();
4954
onFilteredExerciseIdsChange(newFilteredExercises);
50-
if (!hasExactMatch && searchText) {
55+
if (!hasExactMatch && trimmedSearchText) {
5156
onSuggestedNewExercise({
5257
name: trimmed,
5358
category: '',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { fuzzyMatchScore } from './exercise-fuzzy-match';
3+
4+
describe('fuzzyMatchScore', () => {
5+
it('matches subsequences even when the letters are not contiguous', () => {
6+
expect(fuzzyMatchScore('bpr', 'Bench Press Rows')).not.toBeNull();
7+
});
8+
9+
it('prefers exact matches over fuzzy ones', () => {
10+
const exact = fuzzyMatchScore('press', 'press');
11+
const fuzzy = fuzzyMatchScore('press', 'Bench Press');
12+
13+
expect(exact).not.toBeNull();
14+
expect(fuzzy).not.toBeNull();
15+
expect(exact!).toBeGreaterThan(fuzzy!);
16+
});
17+
18+
it('prefers prefix matches over fuzzy ones', () => {
19+
const inner = fuzzyMatchScore('press', 'Bench press');
20+
const prefix = fuzzyMatchScore('bench', 'Bench Press');
21+
22+
expect(prefix!).toBeGreaterThan(inner!);
23+
});
24+
25+
it('rejects strings that do not contain the query letters in order', () => {
26+
expect(fuzzyMatchScore('abc', 'cab')).toBeNull();
27+
});
28+
29+
it('ignores accents when matching', () => {
30+
expect(fuzzyMatchScore('epee', 'Épée')).not.toBeNull();
31+
});
32+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const diacriticRegex = /[\u0300-\u036f]/g;
2+
3+
export function normalizeFuzzyText(value: string) {
4+
return value
5+
.normalize('NFD')
6+
.replace(diacriticRegex, '')
7+
.toLowerCase()
8+
.trim();
9+
}
10+
11+
export function fuzzyMatchScore(query: string, candidate: string) {
12+
const normalizedQuery = normalizeFuzzyText(query);
13+
const normalizedCandidate = normalizeFuzzyText(candidate);
14+
15+
if (!normalizedQuery) {
16+
return null;
17+
}
18+
19+
let queryIndex = 0;
20+
let firstMatch = -1;
21+
let lastMatch = -1;
22+
let gapCount = 0;
23+
24+
for (
25+
let candidateIndex = 0;
26+
candidateIndex < normalizedCandidate.length &&
27+
queryIndex < normalizedQuery.length;
28+
candidateIndex++
29+
) {
30+
if (normalizedCandidate[candidateIndex] !== normalizedQuery[queryIndex]) {
31+
continue;
32+
}
33+
34+
if (firstMatch === -1) {
35+
firstMatch = candidateIndex;
36+
}
37+
if (lastMatch !== -1) {
38+
gapCount += candidateIndex - lastMatch - 1;
39+
}
40+
lastMatch = candidateIndex;
41+
queryIndex++;
42+
}
43+
44+
if (queryIndex !== normalizedQuery.length || firstMatch === -1 || lastMatch === -1) {
45+
return null;
46+
}
47+
48+
const span = lastMatch - firstMatch + 1;
49+
const compactness = normalizedQuery.length / span;
50+
const coverage = normalizedQuery.length / normalizedCandidate.length;
51+
const exactBonus = normalizedCandidate === normalizedQuery ? 3 : 0;
52+
const prefixBonus = normalizedCandidate.startsWith(normalizedQuery) ? 1.5 : 0;
53+
54+
return exactBonus + prefixBonus + compactness * 5 + coverage * 2 - gapCount * 0.1;
55+
}
56+

0 commit comments

Comments
 (0)