diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 8d51fe1c5..b63e71cef 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -202,7 +202,16 @@ "step": "Schritt", "requirements": "Voraussetzungen", "repeat": "Wiederhole Regel", - "requirementsHelpText": "Wähle das Trainingsergebnis (aus einem vorherigen Eintrag), welcher erreicht werden muss, damit die Regel auslöst", + "requirementsHelpText": "Wähle die Trainingsergebnisse (aus vorherigen Einträgen), die erreicht werden müssen, damit die Regel auslöst. Die Regeln vergleichen mit dem geplanten Wert, die Varianten \"obere Grenze\" mit dem geplanten Maximum. Für eine doppelte Progression koppelst du das Gewicht an \"Wiederholungen (obere Grenze)\" und verlangst alle Sätze.", + "requirementRules": { + "weight": "Gewicht", + "repetitions": "Wiederholungen", + "rir": "RiR", + "rest": "Pausenzeit", + "max_repetitions": "Wiederholungen (obere Grenze)", + "max_weight": "Gewicht (obere Grenze)" + }, + "requirementsAllSets": "Alle Sätze müssen die Regeln erfüllen", "markAsTemplate": "Verwalte Vorlage", "template": "Vorlage", "templates": "Vorlagen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5950a5536..108600872 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -303,7 +303,16 @@ "operation": "Operation", "step": "Step", "requirements": "Requirements", - "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect", + "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect. The rules compare against the planned value, the 'top of range' variants against the planned maximum. For a double progression, gate the weight on 'Repetitions (top of range)' and require all sets.", + "requirementRules": { + "weight": "Weight", + "repetitions": "Repetitions", + "rir": "RiR", + "rest": "Rest time", + "max_repetitions": "Repetitions (top of range)", + "max_weight": "Weight (top of range)" + }, + "requirementsAllSets": "All sets must qualify", "repeat": "Repeat rule", "repeatHelpText": "Check the check box if you want this rule to continue to apply to subsequent workouts until you define a new one", "markAsTemplate": "Manage template", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index c203f498e..2cbe4488e 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -146,7 +146,7 @@ "noResults": "Sin resultados", "success": "¡Éxito!", "routines": { - "rir": "Repeticiones", + "rir": "RiR", "addDay": "Añadir un día de entrenamiento", "addWeightLog": "Añadir un registro para el entrenamiento", "addLogToDay": "Añadir un registro a este día", @@ -230,7 +230,16 @@ "repeatHelpText": "Compruebe la casilla de verificación si desea que esta regla se siga aplicando a ejercicios posteriores hasta que defina una nueva", "templatesHelpText": "Las plantillas son una manera de guardar su rutina para uso posterior y como punto de partida para futuras rutinas. No puedes editar plantillas, pero puedes duplicarlas y hacer cambios en la copia (así como convertirlas en una rutina regular, por supuesto).", "workoutSession": "Sesión de entrenamiento", - "requirementsHelpText": "Seleccione los resultados de entrenamiento (de registros anteriores) que deben cumplirse para que esta regla surta efecto", + "requirementsHelpText": "Seleccione los resultados de entrenamiento (de registros anteriores) que deben cumplirse para que esta regla surta efecto. Las reglas comparan con el valor planificado, las variantes \"límite superior\" con el máximo planificado. Para una progresión doble, condicione el peso a \"Repeticiones (límite superior)\" y exija todas las series.", + "requirementRules": { + "weight": "Peso", + "repetitions": "Repeticiones", + "rir": "RiR", + "rest": "Descanso", + "max_repetitions": "Repeticiones (límite superior)", + "max_weight": "Peso (límite superior)" + }, + "requirementsAllSets": "Todas las series deben cumplir las reglas", "template": "Plantilla", "templates": "Plantillas", "publicTemplate": "Plantilla pública", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 9376f7bcc..b5dc997b2 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -217,7 +217,16 @@ "impressionHelpText": "Ce formulaire enregistre vos résultats d'entraînement (répétitions, poids, etc.) pour chaque exercice. Les modifications que vous effectuez ici, comme la suppression ou le remplacement d'exercices, n'affectent que les journaux spécifiques que vous enregistrez et ne modifient pas votre routine globale. Seules les lignes contenant des valeurs de poids ou de répétitions sont enregistrées.", "operation": "Opération", "step": "Étape", - "requirementsHelpText": "Sélectionnez les résultats d'entraînement (à partir des journaux précédents) qui doivent être atteints pour que cette règle prenne effet", + "requirementsHelpText": "Sélectionnez les résultats d'entraînement (à partir des journaux précédents) qui doivent être atteints pour que cette règle prenne effet. Les règles comparent avec la valeur prévue, les variantes \"limite haute\" avec le maximum prévu. Pour une double progression, conditionnez le poids à \"Répétitions (limite haute)\" et exigez toutes les séries.", + "requirementRules": { + "weight": "Poids", + "repetitions": "Répétitions", + "rir": "ReR", + "rest": "Temps de repos", + "max_repetitions": "Répétitions (limite haute)", + "max_weight": "Poids (limite haute)" + }, + "requirementsAllSets": "Toutes les séries doivent remplir les règles", "requirements": "Exigences", "repeat": "Répéter la règle", "template": "Modèle", diff --git a/src/components/Routines/api/baseConfig.ts b/src/components/Routines/api/baseConfig.ts index 812feaa50..1bec06122 100644 --- a/src/components/Routines/api/baseConfig.ts +++ b/src/components/Routines/api/baseConfig.ts @@ -15,6 +15,7 @@ export interface AddBaseConfigParams { iteration?: number; operation?: OperationType; step?: StepType; + repeat?: boolean; requirements?: RuleRequirements; } diff --git a/src/components/Routines/models/BaseConfig.ts b/src/components/Routines/models/BaseConfig.ts index 5150466f3..51760fb9e 100644 --- a/src/components/Routines/models/BaseConfig.ts +++ b/src/components/Routines/models/BaseConfig.ts @@ -18,7 +18,7 @@ export interface BaseConfigEntryForm { step: StepType; stepMax: StepType; requirements: RequirementsType[]; - requirementsMax: RequirementsType[]; + allSets: boolean; repeat: boolean; repeatMax: boolean; } @@ -27,11 +27,19 @@ export const OPERATION_REPLACE = 'r'; export const OPERATION_ADD = '+'; export const OPERATION_SUBSTRACT = '-'; -export const REQUIREMENTS_VALUES = ["weight", "repetitions", "rir", "rest"] as const; +/** Rules a progression can be gated on, see REQUIREMENT_RULES on the server */ +export const REQUIREMENTS_VALUES = [ + "weight", + "max_weight", + "repetitions", + "max_repetitions", + "rir", + "rest", +] as const; export type OperationType = "+" | "-" | "r"; export type StepType = "abs" | "percent"; -export type RequirementsType = typeof REQUIREMENTS_VALUES; +export type RequirementsType = (typeof REQUIREMENTS_VALUES)[number]; export const STEP_VALUES_SELECT = [ @@ -55,6 +63,8 @@ export const RIR_VALUES_SELECT = [...RIR_VALUES_SELECT_LIST] as const; export interface RuleRequirements { rules: RequirementsType[]; + /** Every logged set has to meet the rules, not just one */ + all_sets?: boolean; } export class BaseConfig { diff --git a/src/components/Routines/widgets/forms/BaseConfigForm.test.tsx b/src/components/Routines/widgets/forms/BaseConfigForm.test.tsx index 336de34e6..5eb98c64a 100644 --- a/src/components/Routines/widgets/forms/BaseConfigForm.test.tsx +++ b/src/components/Routines/widgets/forms/BaseConfigForm.test.tsx @@ -1,9 +1,12 @@ import { BaseConfig, OPERATION_REPLACE } from '@/components/Routines/models/BaseConfig'; -import { SlotBaseConfigValueField } from '@/components/Routines/widgets/forms/BaseConfigForm'; +import { + ConfigDetailsRequirementsField, + SlotBaseConfigValueField +} from '@/components/Routines/widgets/forms/BaseConfigForm'; import { testQueryClient } from "@/tests/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; import React from 'react'; @@ -157,4 +160,59 @@ describe('EntryDetailsField Component', () => { }); }); }); +}); + +describe('ConfigDetailsRequirementsField', () => { + const onChange = vi.fn(); + + beforeEach(() => { + onChange.mockClear(); + }); + + test('drops the all sets flag once the last rule is removed', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + + await user.click(screen.getByRole('menuitem', { name: /requirementRules\.repetitions/ })); + // Nothing selected any more, so all sets has nothing to apply to + expect(screen.getByRole('menuitem', { name: /requirementsAllSets/ })).toHaveAttribute('aria-disabled', 'true'); + + await user.click(within(screen.getByRole('menu')).getByRole('button', { name: /save/i })); + expect(onChange).toHaveBeenCalledExactlyOnceWith([], false); + }); + + test('picking one end of a range drops the other one', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + + await user.click(screen.getByRole('menuitem', { name: /requirementRules\.max_repetitions/ })); + const bottomItem = screen.getByRole('menuitem', { name: /requirementRules\.repetitions/ }); + expect(within(bottomItem).queryByTestId('CheckBoxIcon')).not.toBeInTheDocument(); + + await user.click(within(screen.getByRole('menu')).getByRole('button', { name: /save/i })); + expect(onChange).toHaveBeenCalledExactlyOnceWith(['rir', 'max_repetitions'], false); + }); + + test('reopens with the values of the parent, not with its last selection', async () => { + const user = userEvent.setup(); + const { rerender } = render( + + ); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('menuitem', { name: /requirementRules\.max_repetitions/ })); + await user.click(screen.getByRole('menuitem', { name: /requirementsAllSets/ })); + await user.click(within(screen.getByRole('menu')).getByRole('button', { name: /save/i })); + expect(onChange).toHaveBeenCalledExactlyOnceWith(['max_repetitions'], true); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + + // The parent reset the requirements, e.g. by switching the operation to replace + rerender(); + await user.click(screen.getByRole('button')); + + const ruleItem = screen.getByRole('menuitem', { name: /requirementRules\.max_repetitions/ }); + expect(within(ruleItem).queryByTestId('CheckBoxIcon')).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /requirementsAllSets/ })).toHaveAttribute('aria-disabled', 'true'); + }); }); \ No newline at end of file diff --git a/src/components/Routines/widgets/forms/BaseConfigForm.tsx b/src/components/Routines/widgets/forms/BaseConfigForm.tsx index 08a879a35..f7b65224d 100644 --- a/src/components/Routines/widgets/forms/BaseConfigForm.tsx +++ b/src/components/Routines/widgets/forms/BaseConfigForm.tsx @@ -234,10 +234,19 @@ export const SlotBaseConfigValueField = (props: { }; +/** Rules that gate the same field on the bottom and the top of its range */ +const RANGE_COUNTERPART: Partial> = { + 'weight': 'max_weight', + 'max_weight': 'weight', + 'repetitions': 'max_repetitions', + 'max_repetitions': 'repetitions', +}; + export const ConfigDetailsRequirementsField = (props: { values: RequirementsType[], + allSets: boolean, /** Called with the selection once the user saves the menu */ - onChange: (values: RequirementsType[]) => void, + onChange: (values: RequirementsType[], allSets: boolean) => void, disabled?: boolean }) => { @@ -245,27 +254,40 @@ export const ConfigDetailsRequirementsField = (props: { const disable = props.disabled ?? false; const [selectedElements, setSelectedElements] = useState(props.values); + const [allSets, setAllSets] = useState(props.allSets); const [anchorEl, setAnchorEl] = React.useState(null); const handleSelection = (value: RequirementsType) => { - // if the value is not in selectedElements, add it - if (!selectedElements.includes(value)) { - setSelectedElements([...selectedElements, value]); - } else { - setSelectedElements(selectedElements.filter((e) => e !== value)); + // if the value is not in selectedElements, add it and drop the other + // end of its range, the top already implies the bottom + const selection = selectedElements.includes(value) + ? selectedElements.filter((e) => e !== value) + : [...selectedElements.filter((e) => e !== RANGE_COUNTERPART[value]), value]; + setSelectedElements(selection); + + // "All sets" only means something together with rules + if (selection.length === 0) { + setAllSets(false); } }; const handleSubmit = () => { - props.onChange(selectedElements); + props.onChange(selectedElements, allSets); setAnchorEl(null); }; + const handleOpen = (event: React.MouseEvent) => { + // The parent may have reset the values since the menu was last saved + setSelectedElements(props.values); + setAllSets(props.allSets); + setAnchorEl(event.currentTarget); + }; + return <> setAnchorEl(event.currentTarget)} + onClick={handleOpen} > {anchorEl ? : } @@ -276,18 +298,32 @@ export const ConfigDetailsRequirementsField = (props: { > {...REQUIREMENTS_VALUES.map((e) => handleSelection(e as unknown as RequirementsType)}> + onClick={() => handleSelection(e)}> - {selectedElements.includes(e as unknown as RequirementsType) + {selectedElements.includes(e) ? : } - {e} + {t(`routines.requirementRules.${e}`)} )} + setAllSets(!allSets)}> + + {allSets + ? + : + } + + + {t('routines.requirementsAllSets')} + + +