Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions SparkyFitnessFrontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,8 @@
"logError": "Failed to log workout preset \"{{presetName}}\" to diary.",
"exercises": "Exercises",
"logToDiary": "Log to Diary",
"duplicate": "Duplicate",
"duplicateNameSuffix": "{{name}} (Copy)",
"name": "Name",
"presets": "presets",
"startWorkout": "Start Workout",
Expand Down Expand Up @@ -2947,6 +2949,8 @@
"shareWithPublicLabel": "Share with Public",
"exercisesLabel": "Exercises",
"addExerciseButton": "Add Exercise",
"replaceExerciseButton": "Replace exercise",
"duplicateExerciseButton": "Duplicate exercise",
"addSetButton": "Add Set",
"createPresetButton": "Create Preset",
"validationErrorTitle": "Validation Error",
Expand Down
117 changes: 103 additions & 14 deletions SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,121 @@ export function useWorkoutPresetForm({
);
});
const [isAddExerciseDialogOpen, setIsAddExerciseDialogOpen] = useState(false);
// While set, the next AddExerciseDialog selection swaps this entry's
// exercise identity in place instead of appending a new one. Cleared on
// every plain "Add Exercise" open so a cancelled replace can't misroute a
// later add; overwritten (not accumulated) by opening Replace on another row.
const [replaceTargetIndex, setReplaceTargetIndex] = useState<number | null>(
null
);

const handleAddExercise = (exercise: Exercise | undefined) => {
if (exercise) {
const modality = resolveExerciseModality(
exercise.modality,
exercise.category
);
const newExercise: WorkoutPresetExercise = {
id: generateClientId(), // Stable ID for DND
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url:
exercise.images && exercise.images.length > 0
? exercise.images[0]
: '',
exercise: exercise,
sets: [{ ...defaultSetForModality(modality), id: generateClientId() }],
category: exercise.category ?? '',
modality,
};
setExercises((prev) => [...prev, newExercise]);
const imageUrl =
exercise.images && exercise.images.length > 0 ? exercise.images[0] : '';
if (replaceTargetIndex !== null) {
// Swap the exercise identity in place, keeping the entry's already
// configured sets — the whole point of replace over remove-then-add.
// Only safe when the modality is unchanged, but a duration-only
// set carries `reps: null`, and a weight_reps set carries a
// `duration`/no `reps` default, reusing those fields across a
// modality change would either show a blank reps column instead of
// the default, or silently keep a stale field the new modality's UI
// hides, but the server still persists. On a modality change, reset
// to a fresh default set for the new modality instead.
setExercises((prev) =>
prev.map((ex, index) => {
if (index !== replaceTargetIndex) {
return ex;
}
const modalityChanged = modality !== ex.modality;
return {
...ex,
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url: imageUrl,
exercise,
category: exercise.category ?? '',
modality,
sets: modalityChanged
? [
{
...defaultSetForModality(modality),
id: generateClientId(),
},
]
: ex.sets,
};
})
);
} else {
const newExercise: WorkoutPresetExercise = {
id: generateClientId(), // Stable ID for DND
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url: imageUrl,
exercise: exercise,
sets: [
{ ...defaultSetForModality(modality), id: generateClientId() },
],
category: exercise.category ?? '',
modality,
};
setExercises((prev) => [...prev, newExercise]);
}
}
setReplaceTargetIndex(null);
setIsAddExerciseDialogOpen(false);
};

const handleOpenAddExercise = () => {
setReplaceTargetIndex(null);
setIsAddExerciseDialogOpen(true);
};

const handleOpenReplaceExercise = (exerciseIndex: number) => {
setReplaceTargetIndex(exerciseIndex);
setIsAddExerciseDialogOpen(true);
};

const handleRemoveExercise = (index: number) => {
setExercises((prev) => prev.filter((_, i) => i !== index));
};

const handleDuplicateExercise = useCallback((exerciseIndex: number) => {
setExercises((prev) => {
const exerciseToDuplicate = prev[exerciseIndex];
if (!exerciseToDuplicate) {
return prev;
}
const duplicate: WorkoutPresetExercise = {
...exerciseToDuplicate,
id: generateClientId(),
// The web editor has no superset UI, so silently carrying the
// original's superset_group forward would join the copy to the same
// superset with no way for the user to see or intend that (mobile
// clears it for the same reason, inserting the copy after the whole
// run instead).
superset_group: null,
sets: exerciseToDuplicate.sets.map((set) => ({
...set,
id: generateClientId(),
completed_at: null,
is_pr: false,
})),
};
return [
...prev.slice(0, exerciseIndex + 1),
duplicate,
...prev.slice(exerciseIndex + 1),
];
});
}, []);

const handleSetChange = useCallback(
(
exerciseIndex: number,
Expand Down Expand Up @@ -324,7 +410,10 @@ export function useWorkoutPresetForm({
setExercises,
setIsAddExerciseDialogOpen,
handleAddExercise,
handleOpenAddExercise,
handleOpenReplaceExercise,
handleRemoveExercise,
handleDuplicateExercise,
handleSetChange,
handleAddSet,
handleDuplicateSet,
Expand Down
46 changes: 46 additions & 0 deletions SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
GripVertical,
Expand All @@ -7,6 +8,8 @@ import {
ChevronDown,
ChevronUp,
Copy,
CopyPlus,
Repeat,
Book,
Dumbbell,
HeartPulse,
Expand Down Expand Up @@ -59,6 +62,10 @@ interface SortableExerciseItemProps {
onRemoveSet: (exerciseIndex: number, setIndex: number) => void;
onAddSet?: (exerciseIndex: number) => void;
onCopyExercise?: (ex: SortableExerciseItemData) => void;
/** Swap which exercise this entry points to, keeping its configured sets. */
onReplaceExercise?: (exerciseIndex: number) => void;
/** Add an independent copy of this entry (same sets) right after it. */
onDuplicateExercise?: (exerciseIndex: number) => void;
onReorderSets?: (
exerciseIndex: number,
oldIndex: number,
Expand All @@ -78,11 +85,14 @@ export const SortableExerciseItem = ({
onRemoveSet,
onAddSet,
onCopyExercise,
onReplaceExercise,
onDuplicateExercise,
onReorderSets,
weightUnit,
workoutPresets,
simplified = false,
}: SortableExerciseItemProps) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
const { distanceUnit } = usePreferences();

Expand Down Expand Up @@ -229,6 +239,42 @@ export const SortableExerciseItem = ({
)}
</Button>
)}
{onReplaceExercise && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t(
'workoutPresetForm.replaceExerciseButton',
'Replace exercise'
)}
aria-label={t(
'workoutPresetForm.replaceExerciseButton',
'Replace exercise'
)}
onClick={() => onReplaceExercise(exerciseIndex)}
>
<Repeat className="h-4 w-4" />
</Button>
)}
{onDuplicateExercise && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t(
'workoutPresetForm.duplicateExerciseButton',
'Duplicate exercise'
)}
aria-label={t(
'workoutPresetForm.duplicateExerciseButton',
'Duplicate exercise'
)}
onClick={() => onDuplicateExercise(exerciseIndex)}
>
<CopyPlus className="h-4 w-4" />
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{onCopyExercise && (
<Button
variant="ghost"
Expand Down
11 changes: 6 additions & 5 deletions SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
setIsPublic,
setIsAddExerciseDialogOpen,
handleAddExercise,
handleOpenAddExercise,
handleOpenReplaceExercise,
handleRemoveExercise,
handleDuplicateExercise,
handleSetChange,
handleAddSet,
handleDuplicateSet,
Expand Down Expand Up @@ -118,11 +121,7 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
<h3 className="text-lg font-semibold">
{t('workoutPresetForm.exercisesLabel', 'Exercises')}
</h3>
<Button
type="button"
size="sm"
onClick={() => setIsAddExerciseDialogOpen(true)}
>
<Button type="button" size="sm" onClick={handleOpenAddExercise}>
<Plus className="h-4 w-4 mr-2" />
{t('workoutPresetForm.addExerciseButton', 'Add Exercise')}
</Button>
Expand Down Expand Up @@ -166,6 +165,8 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
exerciseIndex={exerciseIndex}
weightUnit={weightUnit}
onRemoveExercise={handleRemoveExercise}
onReplaceExercise={handleOpenReplaceExercise}
onDuplicateExercise={handleDuplicateExercise}
onSetChange={handleSetChange}
onDuplicateSet={handleDuplicateSet}
onRemoveSet={handleRemoveSet}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Play,
X,
MoreHorizontal,
CopyPlus,
} from 'lucide-react';
import {
DropdownMenu,
Expand Down Expand Up @@ -132,6 +133,36 @@ const WorkoutPresetsManager = () => {
setIsAddPresetDialogOpen(false);
};

const handleDuplicatePreset = React.useCallback(
async (preset: WorkoutPreset) => {
if (!user?.id) return;
// The server always inserts fresh rows for exercises/sets on create and
// ignores any incoming id (see workoutPresetRepository.createWorkoutPreset),
// so the original's exercises/sets can be sent as-is. Defaults to
// private regardless of the source's visibility — duplicating someone
// else's public preset shouldn't silently re-share it under this user.
// sort_order is the one field that can't be sent as-is: the read
// queries never select wpe.sort_order, so preset.exercises[].sort_order
// is always undefined here and every duplicated row would insert with
// the same value, relying on id-ASC as a display-order tiebreak.
// preset.exercises already arrives in display order (server sorts by
// sort_order then id), so the array index is the real sort_order.
await createPreset({
user_id: user.id,
name: t('workoutPresetsManager.duplicateNameSuffix', {
name: preset.name,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
description: preset.description,
is_public: false,
exercises: preset.exercises.map((exercise, index) => ({
...exercise,
sort_order: index,
})),
});
},
[createPreset, user?.id, t]
);

const handleUpdatePreset = async (
presetId: string,
updatedPresetData: Partial<WorkoutPreset>
Expand Down Expand Up @@ -305,6 +336,10 @@ const WorkoutPresetsManager = () => {
<CalendarPlus className="mr-2 h-4 w-4" />
{t('workoutPresetsManager.logToDiary', 'Log to Diary')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicatePreset(preset)}>
<CopyPlus className="mr-2 h-4 w-4" />
{t('workoutPresetsManager.duplicate', 'Duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwned}
onClick={() => {
Expand Down Expand Up @@ -335,6 +370,7 @@ const WorkoutPresetsManager = () => {
user?.id,
weightUnit,
handleLogPresetToDiary,
handleDuplicatePreset,
handleDeletePreset,
handleStartWorkoutPlayback,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,50 @@ describe('SortableExerciseItem set table columns', () => {
expect(container.querySelector('input[step="1"]')).toHaveValue(45);
});
});

describe('SortableExerciseItem replace/duplicate exercise actions', () => {
it('fires the replace and duplicate handlers with the entry index when clicked', () => {
const onReplaceExercise = jest.fn();
const onDuplicateExercise = jest.fn();
render(
<SortableExerciseItem
ex={createExercise({
category: 'strength',
modality: 'weight_reps',
sets: [{ set_number: 1, reps: 10, weight: 60 }],
})}
exerciseIndex={2}
onRemoveExercise={() => {}}
onSetChange={() => {}}
onDuplicateSet={() => {}}
onRemoveSet={() => {}}
onReplaceExercise={onReplaceExercise}
onDuplicateExercise={onDuplicateExercise}
weightUnit="kg"
/>
);

fireEvent.click(screen.getByRole('button', { name: 'Replace exercise' }));
fireEvent.click(screen.getByRole('button', { name: 'Duplicate exercise' }));

expect(onReplaceExercise).toHaveBeenCalledWith(2);
expect(onDuplicateExercise).toHaveBeenCalledWith(2);
});

it('omits the replace and duplicate buttons when their handlers are not provided', () => {
renderItem(
createExercise({
category: 'strength',
modality: 'weight_reps',
sets: [{ set_number: 1, reps: 10, weight: 60 }],
})
);

expect(
screen.queryByRole('button', { name: 'Replace exercise' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Duplicate exercise' })
).not.toBeInTheDocument();
});
});
Loading
Loading