Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion public/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/components/Routines/api/baseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface AddBaseConfigParams {
iteration?: number;
operation?: OperationType;
step?: StepType;
repeat?: boolean;
requirements?: RuleRequirements;
}

Expand Down
16 changes: 13 additions & 3 deletions src/components/Routines/models/BaseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface BaseConfigEntryForm {
step: StepType;
stepMax: StepType;
requirements: RequirementsType[];
requirementsMax: RequirementsType[];
allSets: boolean;
repeat: boolean;
repeatMax: boolean;
}
Expand All @@ -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 = [
Expand All @@ -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 {
Expand Down
62 changes: 60 additions & 2 deletions src/components/Routines/widgets/forms/BaseConfigForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(<ConfigDetailsRequirementsField values={['repetitions']} allSets={true} onChange={onChange} />);
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(<ConfigDetailsRequirementsField values={['repetitions', 'rir']} allSets={false} onChange={onChange} />);
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(
<ConfigDetailsRequirementsField values={[]} allSets={false} onChange={onChange} />
);
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(<ConfigDetailsRequirementsField values={[]} allSets={false} onChange={onChange} />);
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');
});
});
58 changes: 47 additions & 11 deletions src/components/Routines/widgets/forms/BaseConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,38 +234,60 @@ export const SlotBaseConfigValueField = (props: {
};


/** Rules that gate the same field on the bottom and the top of its range */
const RANGE_COUNTERPART: Partial<Record<RequirementsType, RequirementsType>> = {
'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
}) => {

const { t } = useTranslation();
const disable = props.disabled ?? false;

const [selectedElements, setSelectedElements] = useState<RequirementsType[]>(props.values);
const [allSets, setAllSets] = useState<boolean>(props.allSets);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(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<HTMLElement>) => {
// The parent may have reset the values since the menu was last saved
setSelectedElements(props.values);
setAllSets(props.allSets);
setAnchorEl(event.currentTarget);
};


return <>
<IconButton
disabled={disable}
onClick={(event) => setAnchorEl(event.currentTarget)}
onClick={handleOpen}
>
{anchorEl ? <ArrowDropUpIcon fontSize="small" /> : <SettingsIcon fontSize="small" />}
</IconButton>
Expand All @@ -276,18 +298,32 @@ export const ConfigDetailsRequirementsField = (props: {
>
{...REQUIREMENTS_VALUES.map((e) => <MenuItem
key={e}
onClick={() => handleSelection(e as unknown as RequirementsType)}>
onClick={() => handleSelection(e)}>
<ListItemIcon>
{selectedElements.includes(e as unknown as RequirementsType)
{selectedElements.includes(e)
? <CheckBoxIcon fontSize="small" />
: <CheckBoxOutlineBlank fontSize="small" />
}
</ListItemIcon>
<ListItemText>
{e}
{t(`routines.requirementRules.${e}`)}
</ListItemText>
</MenuItem>)}
<Divider />
<MenuItem
disabled={selectedElements.length === 0}
onClick={() => setAllSets(!allSets)}>
<ListItemIcon>
{allSets
? <CheckBoxIcon fontSize="small" />
: <CheckBoxOutlineBlank fontSize="small" />
}
</ListItemIcon>
<ListItemText>
{t('routines.requirementsAllSets')}
</ListItemText>
</MenuItem>
<Divider />
<MenuItem>

<Button color="primary" variant="contained" type="submit" size="small" onClick={handleSubmit}>
Expand Down
Loading
Loading