From 11595ddb8a65cf6c60ca747cdcc61803acba2b30 Mon Sep 17 00:00:00 2001 From: MohamedKiouaz Date: Sat, 4 Apr 2026 08:37:44 +0200 Subject: [PATCH 1/3] Add inline exercise suggestions --- .../workout-editor/exercise-editor.tsx | 260 +++++++++++++----- .../workout-editor/exercise-filterer.tsx | 6 +- app/i18n/en.json | 4 + app/i18n/fr.json | 4 + 4 files changed, 196 insertions(+), 78 deletions(-) diff --git a/app/components/presentation/workout-editor/exercise-editor.tsx b/app/components/presentation/workout-editor/exercise-editor.tsx index 83f911e08..ea0f95962 100644 --- a/app/components/presentation/workout-editor/exercise-editor.tsx +++ b/app/components/presentation/workout-editor/exercise-editor.tsx @@ -1,7 +1,5 @@ -import AppBottomSheet from '@/components/presentation/foundation/app-bottom-sheet'; import DurationEditor from '@/components/presentation/foundation/editors/duration-editor'; import EditableIncrementer from '@/components/presentation/foundation/editors/editable-incrementer'; -import ExerciseFilterer from '@/components/presentation/workout-editor/exercise-filterer'; import FixedIncrementer from '@/components/presentation/foundation/editors/fixed-incrementer'; import Button from '@/components/presentation/foundation/gesture-wrappers/button'; import LabelledForm from '@/components/presentation/foundation/labelled-form'; @@ -23,58 +21,163 @@ import { WeightedExerciseBlueprint, WeightedExerciseBlueprintPOJO, } from '@/models/blueprint-models'; -import { useAppSelector, useAppSelectorWithArg } from '@/store'; -import { - ExerciseDescriptor, - selectExerciseById, - selectExerciseIds, -} from '@/store/stored-sessions'; +import { useAppSelector } from '@/store'; +import { ExerciseDescriptor } from '@/store/stored-sessions'; import { assertUnreachable } from '@/utils/assert-unreachable'; -import BottomSheet, { - useBottomSheetScrollableCreator, -} from '@gorhom/bottom-sheet'; import { Duration } from '@js-joda/core'; import { T, useTranslate } from '@tolgee/react'; import BigNumber from 'bignumber.js'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Keyboard, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; import { Card, Divider, List, SegmentedButtons, + Text, TextInput, } from 'react-native-paper'; import { match, P } from 'ts-pattern'; -import { LegendList } from '@legendapp/list'; interface ExerciseEditorProps { exercise: ExerciseBlueprint; updateExercise: (ex: ExerciseBlueprint) => void; } +interface ExerciseSuggestion extends ExerciseDescriptor { + source: 'user' | 'base'; +} + +const builtInExercisesJson = require('../../../assets/exercises.json') as { + exercises: { name: string }[]; +}; +const builtInExerciseIds = new Set( + builtInExercisesJson.exercises.map((exercise) => exercise.name), +); + const distanceUnitOptions = DistanceUnits.map((value) => ({ value, label: value + 's', })); export function ExerciseEditor(props: ExerciseEditorProps) { - const exerciseIds = useAppSelector(selectExerciseIds); - const bottomSheetRef = useRef(null); - const BottomSheetScrollView = useBottomSheetScrollableCreator(); + const suggestedExercises = useAppSelector((state) => { + const allExercises = new Map(); + const addExerciseDescriptor = ( + exercise: ExerciseDescriptor, + source: ExerciseSuggestion['source'], + ) => { + const id = exercise.name.trim().toLocaleLowerCase(); + if (!id) { + return; + } + const existing = allExercises.get(id); + if (existing && (existing.source === 'user' || source === 'base')) { + return; + } + allExercises.set(id, { ...exercise, source }); + }; + const addBlueprintExercise = (exercise: ExerciseBlueprint) => { + addExerciseDescriptor( + { + name: exercise.name, + force: null, + level: 'beginner', + mechanic: null, + equipment: null, + muscles: [], + instructions: exercise.notes, + category: + exercise instanceof CardioExerciseBlueprint ? 'cardio' : 'strength', + }, + 'user', + ); + }; + + Object.entries(state.storedSessions.savedExercises).forEach( + ([exerciseId, exercise]) => { + addExerciseDescriptor( + exercise, + builtInExerciseIds.has(exerciseId) && exercise.name === exerciseId + ? 'base' + : 'user', + ); + }, + ); + + Object.values(state.program.savedPrograms).forEach((program) => { + program.sessions.forEach((session) => { + session.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + }); + }); + + state.sessionEditor.sessionBlueprint?.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + + [ + state.currentSession.workoutSession, + state.currentSession.historySession, + state.currentSession.feedSession, + state.currentSession.sharedSession, + ].forEach((session) => { + session?.blueprint.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + }); + + return Array.from(allExercises.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + }); const selectExerciseFromSearch = (ex: ExerciseDescriptor) => { updateExercise({ name: ex.name, notes: ex.instructions }); - bottomSheetRef.current?.close(); }; - - const [bottomSheetShown, setBottomSheetShown] = useState(false); - const [filteredExerciseIds, setFilteredExerciseIds] = useState(exerciseIds); - const exerciseListItems = useMemo( - () => ['filter', ...filteredExerciseIds], - [filteredExerciseIds], - ); const { t } = useTranslate(); const { exercise: propsExercise, updateExercise: updatePropsExercise } = props; const [exercise, setExercise] = useState(propsExercise); + const matchingExercises = useMemo(() => { + const searchText = exercise.name.trim(); + if (!searchText) { + return []; + } + const searchRegex = new RegExp(escapeRegExp(searchText), 'i'); + const prefixRegex = new RegExp('^' + escapeRegExp(searchText), 'i'); + const exactRegex = new RegExp('^' + escapeRegExp(searchText) + '$', 'i'); + + return suggestedExercises + .filter((item) => searchRegex.test(item.name)) + .sort((a, b) => { + const exactDiff = + Number(exactRegex.test(b.name)) - Number(exactRegex.test(a.name)); + if (exactDiff !== 0) { + return exactDiff; + } + const prefixDiff = + Number(prefixRegex.test(b.name)) - Number(prefixRegex.test(a.name)); + if (prefixDiff !== 0) { + return prefixDiff; + } + if (a.source !== b.source) { + return a.source === 'user' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .slice(0, 8); + }, [exercise.name, suggestedExercises]); // Bit of a hack to let us update exercise immediately without going through the whole props loop useEffect(() => { @@ -149,71 +252,76 @@ export function ExerciseEditor(props: ExerciseEditorProps) { /> - updateExercise({ name })} - selectTextOnFocus={true} - right={ - { - setBottomSheetShown(true); - Keyboard.dismiss(); - bottomSheetRef.current?.expand(); - }} - /> - } - /> + + updateExercise({ name })} + selectTextOnFocus={true} + /> + {!!matchingExercises.length && ( + + {matchingExercises.map((item, index) => ( + + {!!index && } + + + ))} + + )} + {exerciseEditor} - - {bottomSheetShown && ( - (index === 0 ? 'filters' : 'exercise')} - keyExtractor={(item, index) => (index === 0 ? 'filters' : item)} - renderItem={(i) => { - if (i.index === 0) { - return ( - - ); - } - return ( - - ); - }} - /> - )} - ); } function ExerciseSearchListItem(props: { - exerciseId: string; + exercise: ExerciseSuggestion; onPress: (exercise: ExerciseDescriptor) => void; }) { - const exercise = useAppSelectorWithArg(selectExerciseById, props.exerciseId); + const { t } = useTranslate(); return ( - props.onPress(exercise)} /> + ( + + )} + right={() => ( + + {props.exercise.source === 'user' + ? t('exercise.source.user.badge') + : t('exercise.source.base.badge')} + + )} + onPress={() => props.onPress(props.exercise)} + /> ); } +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function CardioExerciseEditor({ exercise, updateExercise, diff --git a/app/components/presentation/workout-editor/exercise-filterer.tsx b/app/components/presentation/workout-editor/exercise-filterer.tsx index 8586237fb..d2e637a96 100644 --- a/app/components/presentation/workout-editor/exercise-filterer.tsx +++ b/app/components/presentation/workout-editor/exercise-filterer.tsx @@ -1,14 +1,16 @@ import ExerciseSearchAndFilters from '@/components/presentation/workout-editor/exercise-search-and-filters'; import { useAppSelector } from '@/store'; -import { selectExercises } from '@/store/stored-sessions'; +import { ExerciseDescriptor, selectExercises } from '@/store/stored-sessions'; import Enumerable from 'linq'; import { useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; export default function ExerciseFilterer(props: { onFilteredExerciseIdsChange: (ids: string[]) => void; + exercises?: Record; }) { - const exercises = useAppSelector(selectExercises); + const storedExercises = useAppSelector(selectExercises); + const exercises = props.exercises ?? storedExercises; const { onFilteredExerciseIdsChange } = props; const [muscleFilters, setMuscleFilters] = useState([] as string[]); const [searchText, setSearchText] = useState(''); diff --git a/app/i18n/en.json b/app/i18n/en.json index b0f97a519..da7c7ebf3 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -63,6 +63,10 @@ "exercise.remove.confirm.body": "Exercise will be removed from the current workout, future workouts in the plan will not be impacted.", "exercise.remove.confirm.title": "Remove exercise?", "exercise.remove_from_workout.confirm.body": "This will remove the exercise {exercise} from {session}.", + "exercise.source.user.label": "From your exercises", + "exercise.source.base.label": "From base app", + "exercise.source.user.badge": "Yours", + "exercise.source.base.badge": "App", "exercise.reps.label": "Reps", "exercise.resistance.label": "Resistance", "exercise.select_reps.title": "Select Reps", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index c9cdcc0a9..c678d0293 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -52,6 +52,10 @@ "exercise.reps.label": "Répétitions", "exercise.select_reps.title": "Sélectionner les répétitions", "exercise.sets.label": "Séries", + "exercise.source.user.label": "De vos exercices", + "exercise.source.base.label": "De l'application", + "exercise.source.user.badge": "Vous", + "exercise.source.base.badge": "App", "feed.anonymous_user.label": "Utilisateur anonyme", "feed.backup_account.confirm.body": "Inclure votre compte de fil et les utilisateurs suivis dans cette sauvegarde ?
Attention : Cela permettrait à quiconque ayant cette sauvegarde de publier du contenu avec votre compte", "feed.backup_account.subtitle": "Inclure les données de votre compte de fil dans les sauvegardes", From 363ac32ce932f9d954bfc70817b9bb858388cd7b Mon Sep 17 00:00:00 2001 From: MohamedKiouaz Date: Mon, 6 Apr 2026 15:59:45 +0200 Subject: [PATCH 2/3] Hide add exercise suggestions after selection --- .../presentation/workout-editor/exercise-editor.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/components/presentation/workout-editor/exercise-editor.tsx b/app/components/presentation/workout-editor/exercise-editor.tsx index ea0f95962..259e00a24 100644 --- a/app/components/presentation/workout-editor/exercise-editor.tsx +++ b/app/components/presentation/workout-editor/exercise-editor.tsx @@ -143,12 +143,14 @@ export function ExerciseEditor(props: ExerciseEditorProps) { ); }); const selectExerciseFromSearch = (ex: ExerciseDescriptor) => { + setShowMatchingExercises(false); updateExercise({ name: ex.name, notes: ex.instructions }); }; const { t } = useTranslate(); const { exercise: propsExercise, updateExercise: updatePropsExercise } = props; const [exercise, setExercise] = useState(propsExercise); + const [showMatchingExercises, setShowMatchingExercises] = useState(true); const matchingExercises = useMemo(() => { const searchText = exercise.name.trim(); if (!searchText) { @@ -257,10 +259,13 @@ export function ExerciseEditor(props: ExerciseEditorProps) { testID="exercise-name" mode="outlined" value={exercise.name} - onChangeText={(name) => updateExercise({ name })} + onChangeText={(name) => { + setShowMatchingExercises(true); + updateExercise({ name }); + }} selectTextOnFocus={true} /> - {!!matchingExercises.length && ( + {showMatchingExercises && !!matchingExercises.length && ( {matchingExercises.map((item, index) => ( From c8f6f022698abd5f7a57977d9507a869e2e394fd Mon Sep 17 00:00:00 2001 From: Mohamed Kiouaz Date: Tue, 14 Apr 2026 08:35:33 +0200 Subject: [PATCH 3/3] Use fuzzy exercise matching --- .../workout-editor/exercise-editor.tsx | 54 +++++++++++------- .../workout-editor/exercise-filterer.tsx | 22 +++++--- .../exercise-fuzzy-match.spec.ts | 26 +++++++++ .../workout-editor/exercise-fuzzy-match.ts | 56 +++++++++++++++++++ 4 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts create mode 100644 app/components/presentation/workout-editor/exercise-fuzzy-match.ts diff --git a/app/components/presentation/workout-editor/exercise-editor.tsx b/app/components/presentation/workout-editor/exercise-editor.tsx index 259e00a24..fd296ebd3 100644 --- a/app/components/presentation/workout-editor/exercise-editor.tsx +++ b/app/components/presentation/workout-editor/exercise-editor.tsx @@ -5,6 +5,7 @@ import Button from '@/components/presentation/foundation/gesture-wrappers/button import LabelledForm from '@/components/presentation/foundation/labelled-form'; import LabelledFormRow from '@/components/presentation/foundation/labelled-form-row'; import ListSwitch from '@/components/presentation/foundation/list-switch'; +import { fuzzyMatchScore } from '@/components/presentation/workout-editor/exercise-fuzzy-match'; import RestEditorGroup from '@/components/presentation/workout-editor/rest-editor-group'; import SelectButton from '@/components/presentation/foundation/select-button'; import { spacing, useAppTheme } from '@/hooks/useAppTheme'; @@ -156,28 +157,27 @@ export function ExerciseEditor(props: ExerciseEditorProps) { if (!searchText) { return []; } - const searchRegex = new RegExp(escapeRegExp(searchText), 'i'); - const prefixRegex = new RegExp('^' + escapeRegExp(searchText), 'i'); - const exactRegex = new RegExp('^' + escapeRegExp(searchText) + '$', 'i'); return suggestedExercises - .filter((item) => searchRegex.test(item.name)) + .map((item) => ({ + item, + score: fuzzyMatchScore(searchText, item.name), + })) + .filter( + (entry): entry is { item: ExerciseSuggestion; score: number } => + entry.score !== null, + ) .sort((a, b) => { - const exactDiff = - Number(exactRegex.test(b.name)) - Number(exactRegex.test(a.name)); - if (exactDiff !== 0) { - return exactDiff; + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) { + return scoreDiff; } - const prefixDiff = - Number(prefixRegex.test(b.name)) - Number(prefixRegex.test(a.name)); - if (prefixDiff !== 0) { - return prefixDiff; + if (a.item.source !== b.item.source) { + return a.item.source === 'user' ? -1 : 1; } - if (a.source !== b.source) { - return a.source === 'user' ? -1 : 1; - } - return a.name.localeCompare(b.name); + return a.item.name.localeCompare(b.item.name); }) + .map((entry) => entry.item) .slice(0, 8); }, [exercise.name, suggestedExercises]); @@ -267,6 +267,13 @@ export function ExerciseEditor(props: ExerciseEditorProps) { /> {showMatchingExercises && !!matchingExercises.length && ( + + setShowMatchingExercises(false)} + /> + + {matchingExercises.map((item, index) => ( {!!index && } @@ -286,6 +293,17 @@ export function ExerciseEditor(props: ExerciseEditorProps) { ); } +function NewExerciseListItem(props: { name: string; onPress: () => void }) { + return ( + } + onPress={props.onPress} + /> + ); +} + function ExerciseSearchListItem(props: { exercise: ExerciseSuggestion; onPress: (exercise: ExerciseDescriptor) => void; @@ -323,10 +341,6 @@ function ExerciseSearchListItem(props: { ); } -function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - function CardioExerciseEditor({ exercise, updateExercise, diff --git a/app/components/presentation/workout-editor/exercise-filterer.tsx b/app/components/presentation/workout-editor/exercise-filterer.tsx index d2e637a96..28891a39f 100644 --- a/app/components/presentation/workout-editor/exercise-filterer.tsx +++ b/app/components/presentation/workout-editor/exercise-filterer.tsx @@ -1,4 +1,5 @@ import ExerciseSearchAndFilters from '@/components/presentation/workout-editor/exercise-search-and-filters'; +import { fuzzyMatchScore } from '@/components/presentation/workout-editor/exercise-fuzzy-match'; import { useAppSelector } from '@/store'; import { ExerciseDescriptor, selectExercises } from '@/store/stored-sessions'; import Enumerable from 'linq'; @@ -16,19 +17,25 @@ export default function ExerciseFilterer(props: { const [searchText, setSearchText] = useState(''); const search = useDebouncedCallback(() => { - const searchRegex = new RegExp(escapeRegExp(searchText), 'i'); - const matchRegex = new RegExp('^' + escapeRegExp(searchText) + '$', 'i'); + const trimmedSearchText = searchText.trim(); const newFilteredExercises = Enumerable.from(Object.entries(exercises)) + .select((x) => ({ + entry: x, + score: trimmedSearchText + ? fuzzyMatchScore(trimmedSearchText, x[1].name) + : 0, + })) .where( (x) => (!muscleFilters.length || - x[1].muscles.some((exerciseMuscle) => + x.entry[1].muscles.some((exerciseMuscle) => muscleFilters.includes(exerciseMuscle), )) && - (!searchText || searchRegex.test(x[1].name)), + (!trimmedSearchText || x.score !== null), ) - .orderByDescending((x) => matchRegex.test(x[1].name)) - .select((x) => x[0]) + .orderByDescending((x) => x.score ?? 0) + .thenBy((x) => x.entry[1].name) + .select((x) => x.entry[0]) .toArray(); onFilteredExerciseIdsChange(newFilteredExercises); }, 100); @@ -48,6 +55,3 @@ export default function ExerciseFilterer(props: { /> ); } -function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts b/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts new file mode 100644 index 000000000..543e9081b --- /dev/null +++ b/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { fuzzyMatchScore } from './exercise-fuzzy-match'; + +describe('fuzzyMatchScore', () => { + it('matches subsequences even when the letters are not contiguous', () => { + expect(fuzzyMatchScore('bpr', 'Bench Press Rows')).not.toBeNull(); + }); + + it('prefers exact matches over fuzzy ones', () => { + const exact = fuzzyMatchScore('press', 'press'); + const fuzzy = fuzzyMatchScore('press', 'Bench Press'); + + expect(exact).not.toBeNull(); + expect(fuzzy).not.toBeNull(); + expect(exact!).toBeGreaterThan(fuzzy!); + }); + + it('rejects strings that do not contain the query letters in order', () => { + expect(fuzzyMatchScore('abc', 'cab')).toBeNull(); + }); + + it('ignores accents when matching', () => { + expect(fuzzyMatchScore('epee', 'Épée')).not.toBeNull(); + }); +}); + diff --git a/app/components/presentation/workout-editor/exercise-fuzzy-match.ts b/app/components/presentation/workout-editor/exercise-fuzzy-match.ts new file mode 100644 index 000000000..954df7846 --- /dev/null +++ b/app/components/presentation/workout-editor/exercise-fuzzy-match.ts @@ -0,0 +1,56 @@ +const diacriticRegex = /[\u0300-\u036f]/g; + +export function normalizeFuzzyText(value: string) { + return value + .normalize('NFD') + .replace(diacriticRegex, '') + .toLowerCase() + .trim(); +} + +export function fuzzyMatchScore(query: string, candidate: string) { + const normalizedQuery = normalizeFuzzyText(query); + const normalizedCandidate = normalizeFuzzyText(candidate); + + if (!normalizedQuery) { + return null; + } + + let queryIndex = 0; + let firstMatch = -1; + let lastMatch = -1; + let gapCount = 0; + + for ( + let candidateIndex = 0; + candidateIndex < normalizedCandidate.length && + queryIndex < normalizedQuery.length; + candidateIndex++ + ) { + if (normalizedCandidate[candidateIndex] !== normalizedQuery[queryIndex]) { + continue; + } + + if (firstMatch === -1) { + firstMatch = candidateIndex; + } + if (lastMatch !== -1) { + gapCount += candidateIndex - lastMatch - 1; + } + lastMatch = candidateIndex; + queryIndex++; + } + + if (queryIndex !== normalizedQuery.length || firstMatch === -1 || lastMatch === -1) { + return null; + } + + const span = lastMatch - firstMatch + 1; + const compactness = normalizedQuery.length / span; + const coverage = normalizedQuery.length / normalizedCandidate.length; + const exactBonus = normalizedCandidate === normalizedQuery ? 3 : 0; + const prefixBonus = normalizedCandidate.startsWith(normalizedQuery) ? 1.5 : 0; + + return exactBonus + prefixBonus + compactness * 5 + coverage * 2 - gapCount * 0.1; +} +