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
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 @@ -56,6 +57,9 @@ import { ColumnDef, RowSelectionState } from '@tanstack/react-table';
import { useIsMobile } from '@/hooks/use-mobile';
import { Badge } from '@/components/ui/badge';

// Matches workout_presets.name VARCHAR(255) in the database.
const MAX_PRESET_NAME_LENGTH = 255;

const WorkoutPresetsManager = () => {
const { t } = useTranslation();
const navigate = useNavigate();
Expand Down Expand Up @@ -132,6 +136,51 @@ 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.
// workout_presets.name is VARCHAR(255) with no client-side length cap on
// creation, so a max-length preset name must be truncated here to leave
// room for the localized suffix — otherwise the duplicate insert fails.
const suffixOnly = t('workoutPresetsManager.duplicateNameSuffix', {
name: '',
});
const availableNameLength = Math.max(
0,
MAX_PRESET_NAME_LENGTH - suffixOnly.length
);
const truncatedName =
preset.name.length > availableNameLength
? preset.name.slice(0, availableNameLength)
: preset.name;

await createPreset({
user_id: user.id,
name: t('workoutPresetsManager.duplicateNameSuffix', {
name: truncatedName,
}),
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 +354,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 +388,7 @@ const WorkoutPresetsManager = () => {
user?.id,
weightUnit,
handleLogPresetToDiary,
handleDuplicatePreset,
handleDeletePreset,
handleStartWorkoutPlayback,
]
Expand Down
Loading
Loading