diff --git a/SparkyFitnessFrontend/public/locales/en/translation.json b/SparkyFitnessFrontend/public/locales/en/translation.json index 1c2c4c9483..ce25a1eaae 100644 --- a/SparkyFitnessFrontend/public/locales/en/translation.json +++ b/SparkyFitnessFrontend/public/locales/en/translation.json @@ -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", @@ -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", diff --git a/SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts b/SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts index 9252854a95..49d1412ebf 100644 --- a/SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts +++ b/SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts @@ -56,6 +56,13 @@ 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( + null + ); const handleAddExercise = (exercise: Exercise | undefined) => { if (exercise) { @@ -63,28 +70,107 @@ export function useWorkoutPresetForm({ 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, @@ -324,7 +410,10 @@ export function useWorkoutPresetForm({ setExercises, setIsAddExerciseDialogOpen, handleAddExercise, + handleOpenAddExercise, + handleOpenReplaceExercise, handleRemoveExercise, + handleDuplicateExercise, handleSetChange, handleAddSet, handleDuplicateSet, diff --git a/SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx b/SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx index 0bfec0323f..f06a8bc8e0 100644 --- a/SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx +++ b/SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { GripVertical, @@ -7,6 +8,8 @@ import { ChevronDown, ChevronUp, Copy, + CopyPlus, + Repeat, Book, Dumbbell, HeartPulse, @@ -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, @@ -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(); @@ -229,6 +239,42 @@ export const SortableExerciseItem = ({ )} )} + {onReplaceExercise && ( + + )} + {onDuplicateExercise && ( + + )} {onCopyExercise && ( @@ -166,6 +165,8 @@ const WorkoutPresetForm: React.FC = ({ exerciseIndex={exerciseIndex} weightUnit={weightUnit} onRemoveExercise={handleRemoveExercise} + onReplaceExercise={handleOpenReplaceExercise} + onDuplicateExercise={handleDuplicateExercise} onSetChange={handleSetChange} onDuplicateSet={handleDuplicateSet} onRemoveSet={handleRemoveSet} diff --git a/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx b/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx index 5f81198b46..a59306dbd4 100644 --- a/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx +++ b/SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetsManager.tsx @@ -22,6 +22,7 @@ import { Play, X, MoreHorizontal, + CopyPlus, } from 'lucide-react'; import { DropdownMenu, @@ -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(); @@ -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, + }), + 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 @@ -305,6 +354,10 @@ const WorkoutPresetsManager = () => { {t('workoutPresetsManager.logToDiary', 'Log to Diary')} + handleDuplicatePreset(preset)}> + + {t('workoutPresetsManager.duplicate', 'Duplicate')} + { @@ -335,6 +388,7 @@ const WorkoutPresetsManager = () => { user?.id, weightUnit, handleLogPresetToDiary, + handleDuplicatePreset, handleDeletePreset, handleStartWorkoutPlayback, ] diff --git a/SparkyFitnessFrontend/src/tests/components/SortableExerciseItem.test.tsx b/SparkyFitnessFrontend/src/tests/components/SortableExerciseItem.test.tsx index 573c63746d..fc4af6f35c 100644 --- a/SparkyFitnessFrontend/src/tests/components/SortableExerciseItem.test.tsx +++ b/SparkyFitnessFrontend/src/tests/components/SortableExerciseItem.test.tsx @@ -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( + {}} + 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(); + }); +}); diff --git a/SparkyFitnessFrontend/src/tests/components/WorkoutPresetsManager.test.tsx b/SparkyFitnessFrontend/src/tests/components/WorkoutPresetsManager.test.tsx new file mode 100644 index 0000000000..f2b8a32430 --- /dev/null +++ b/SparkyFitnessFrontend/src/tests/components/WorkoutPresetsManager.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import WorkoutPresetsManager from '@/pages/Exercises/WorkoutPresetsManager'; +import type { WorkoutPreset } from '@/types/workout'; + +const mockCreatePreset = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, defaultOrValues?: string | Record) => { + if (typeof defaultOrValues === 'string') return defaultOrValues; + if (defaultOrValues && typeof defaultOrValues === 'object') { + // Real interpolation only for the key exercised in these tests + // (matches en/translation.json's "{{name}} (Copy)"), so the + // duplicate-name truncation tests assert on the actual rendered + // string rather than a synthetic JSON blob. Other keyed + // interpolations fall back to the JSON-stringified form. + if (key === 'workoutPresetsManager.duplicateNameSuffix') { + const { name } = defaultOrValues as { name: string }; + return `${name} (Copy)`; + } + return `${key}:${JSON.stringify(defaultOrValues)}`; + } + return key; + }, + }), + initReactI18next: { + type: '3rdParty', + init: () => {}, + }, +})); + +jest.mock('react-router-dom', () => ({ + useNavigate: () => jest.fn(), + useLocation: () => ({ pathname: '/exercises', search: '' }), +})); + +jest.mock('@/contexts/PreferencesContext', () => ({ + usePreferences: () => ({ weightUnit: 'kg' }), +})); + +jest.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ user: { id: 'user-1' } }), +})); + +jest.mock('@/hooks/Exercises/useExerciseEntries', () => ({ + useLogWorkoutPresetMutation: () => ({ mutateAsync: jest.fn() }), +})); + +const presetFixture: WorkoutPreset = { + id: 'preset-1', + user_id: 'user-1', + name: 'Upper Body', + description: 'Push + Pull', + exercises: [ + { + exercise_id: 'exercise-1', + exercise_name: 'Bench Press', + sets: [{ set_number: 1, reps: 8, weight: 80, rest_time: 90 }], + }, + ], +} as unknown as WorkoutPreset; + +jest.mock('@/hooks/Exercises/useWorkoutPresets', () => ({ + useWorkoutPresets: () => ({ + data: { pages: [{ presets: [presetFixture], total: 1 }] }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + isFetchingNextPage: false, + }), + useCreateWorkoutPresetMutation: () => ({ + mutateAsync: (...args: unknown[]) => mockCreatePreset(...args), + }), + useUpdateWorkoutPresetMutation: () => ({ mutateAsync: jest.fn() }), + useDeleteWorkoutPresetMutation: () => ({ mutateAsync: jest.fn() }), +})); + +describe('WorkoutPresetsManager duplicate preset', () => { + beforeEach(() => { + mockCreatePreset.mockReset(); + }); + + it('creates a private copy with the original exercises/sets and a "(Copy)" name, regardless of the source visibility', async () => { + render(); + + // DataTable renders both a desktop table and a mobile card list at once + // (toggled with CSS media queries jsdom doesn't apply), so each row's + // menu trigger appears twice; only one needs to be exercised here. + // Radix's dropdown trigger opens on pointerDown, not click. + const trigger = screen.getAllByRole('button', { name: /open menu/i })[0]!; + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerId: 1 }); + fireEvent.click(trigger); + fireEvent.click((await screen.findAllByText('Duplicate'))[0]!); + + await waitFor(() => expect(mockCreatePreset).toHaveBeenCalledTimes(1)); + expect(mockCreatePreset).toHaveBeenCalledWith({ + user_id: 'user-1', + name: 'Upper Body (Copy)', + description: 'Push + Pull', + is_public: false, + exercises: presetFixture.exercises.map((exercise, index) => ({ + ...exercise, + sort_order: index, + })), + }); + }); + + it('truncates a max-length preset name so the duplicate stays within 255 characters', async () => { + const originalName = presetFixture.name; + presetFixture.name = 'A'.repeat(255); + + try { + render(); + + const trigger = screen.getAllByRole('button', { name: /open menu/i })[0]!; + fireEvent.pointerDown(trigger, { + button: 0, + ctrlKey: false, + pointerId: 1, + }); + fireEvent.click(trigger); + fireEvent.click((await screen.findAllByText('Duplicate'))[0]!); + + await waitFor(() => expect(mockCreatePreset).toHaveBeenCalledTimes(1)); + const duplicateName = ( + mockCreatePreset.mock.calls[0]![0] as { name: string } + ).name; + // 248-char truncated name + " (Copy)" (7 chars) = 255, the + // workout_presets.name VARCHAR(255) limit. + expect(duplicateName).toBe(`${'A'.repeat(248)} (Copy)`); + expect(duplicateName.length).toBe(255); + } finally { + presetFixture.name = originalName; + } + }); +}); diff --git a/SparkyFitnessFrontend/src/tests/hooks/useWorkoutPresetForm.test.tsx b/SparkyFitnessFrontend/src/tests/hooks/useWorkoutPresetForm.test.tsx index b6e7e8a17b..fddc7bd97d 100644 --- a/SparkyFitnessFrontend/src/tests/hooks/useWorkoutPresetForm.test.tsx +++ b/SparkyFitnessFrontend/src/tests/hooks/useWorkoutPresetForm.test.tsx @@ -134,3 +134,187 @@ describe('useWorkoutPresetForm modality seeding', () => { ); }); }); + +describe('useWorkoutPresetForm replace exercise', () => { + const presetWithWeightRepsSet = { + id: 1, + name: 'Push', + description: '', + is_public: false, + exercises: [ + { + id: 7, + exercise_id: 'exercise-1', + exercise_name: 'Bench Press', + modality: 'weight_reps', + sets: [ + { + id: 1, + set_number: 1, + set_type: 'Working Set', + reps: 8, + weight: 60, + duration: null, + rest_time: null, + notes: null, + }, + { + id: 2, + set_number: 2, + set_type: 'Working Set', + reps: 6, + weight: 70, + duration: null, + rest_time: null, + notes: null, + }, + ], + }, + ], + } as unknown as WorkoutPreset; + + it('preserves the existing sets when the replacement has the same modality', () => { + const onSave = jest.fn(); + const { result } = renderHook(() => + useWorkoutPresetForm({ initialPreset: presetWithWeightRepsSet, onSave }) + ); + + act(() => { + result.current.handleOpenReplaceExercise(0); + }); + act(() => { + // Same 'strength' -> 'weight_reps' modality as the entry being replaced. + result.current.handleAddExercise({ + id: 'exercise-2', + name: 'Squat', + category: 'strength', + images: [], + } as unknown as Exercise); + }); + + // Swapped in place, not appended. + expect(result.current.exercises).toHaveLength(1); + const replaced = result.current.exercises[0]!; + expect(replaced.exercise_id).toBe('exercise-2'); + expect(replaced.exercise_name).toBe('Squat'); + expect(replaced.modality).toBe('weight_reps'); + // The already-entered sets must survive the swap — the whole point of + // replace over remove-then-re-add. + expect(replaced.sets).toHaveLength(2); + expect(replaced.sets[0]).toEqual( + expect.objectContaining({ reps: 8, weight: 60 }) + ); + expect(replaced.sets[1]).toEqual( + expect.objectContaining({ reps: 6, weight: 70 }) + ); + }); + + it('resets to a fresh default set when the replacement changes modality', () => { + const onSave = jest.fn(); + const { result } = renderHook(() => + useWorkoutPresetForm({ initialPreset: presetWithWeightRepsSet, onSave }) + ); + + act(() => { + result.current.handleOpenReplaceExercise(0); + }); + act(() => { + // 'isometric' -> 'duration' differs from the original 'weight_reps': + // reusing the old reps:8/weight:60 sets would show a blank reps column + // instead of the duration default, and silently keep weight/reps + // values the new UI hides but the server would still persist. + result.current.handleAddExercise({ + id: 'exercise-3', + name: 'Plank', + category: 'isometric', + images: [], + } as unknown as Exercise); + }); + + expect(result.current.exercises).toHaveLength(1); + const replaced = result.current.exercises[0]!; + expect(replaced.exercise_id).toBe('exercise-3'); + expect(replaced.modality).toBe('duration'); + expect(replaced.sets).toHaveLength(1); + expect(replaced.sets[0]).toEqual( + expect.objectContaining({ reps: null, weight: null, duration: null }) + ); + // A fresh id, not one of the original two sets. + expect(replaced.sets[0]?.id).not.toBe('1'); + expect(replaced.sets[0]?.id).not.toBe('2'); + }); + + it('does not misroute a plain Add Exercise after a replace was opened then cancelled', () => { + const onSave = jest.fn(); + const { result } = renderHook(() => + useWorkoutPresetForm({ initialPreset: presetWithTimedSet, onSave }) + ); + + act(() => { + result.current.handleOpenReplaceExercise(0); + }); + // Cancel the replace: opening plain Add must drop the pending target. + act(() => { + result.current.handleOpenAddExercise(); + }); + act(() => { + result.current.handleAddExercise({ + id: 'exercise-2', + name: 'Squat', + category: 'strength', + images: [], + } as unknown as Exercise); + }); + + expect(result.current.exercises).toHaveLength(2); + expect(result.current.exercises[0]?.exercise_id).toBe('exercise-1'); + expect(result.current.exercises[1]?.exercise_id).toBe('exercise-2'); + }); +}); + +describe('useWorkoutPresetForm duplicate exercise', () => { + it('adds an independent copy of the exercise, with the same sets, right after it', () => { + const onSave = jest.fn(); + const { result } = renderHook(() => + useWorkoutPresetForm({ initialPreset: presetWithTimedSet, onSave }) + ); + + act(() => { + result.current.handleDuplicateExercise(0); + }); + + expect(result.current.exercises).toHaveLength(2); + const original = result.current.exercises[0]!; + const duplicate = result.current.exercises[1]!; + expect(duplicate.exercise_id).toBe(original.exercise_id); + expect(duplicate.exercise_name).toBe(original.exercise_name); + expect(duplicate.sets).toHaveLength(2); + expect(duplicate.sets[0]).toEqual( + expect.objectContaining({ duration: 355, weight: null }) + ); + expect(duplicate.sets[1]).toEqual( + expect.objectContaining({ reps: 5, weight: 0 }) + ); + // Independent identity: editing one must never touch the other. + expect(duplicate.id).not.toBe(original.id); + expect(duplicate.sets[0]?.id).not.toBe(original.sets[0]?.id); + }); + + it("does not silently join the original exercise's superset (the web editor has no superset UI)", () => { + const onSave = jest.fn(); + const presetInSuperset = { + ...presetWithTimedSet, + exercises: [{ ...presetWithTimedSet.exercises[0], superset_group: 3 }], + } as unknown as WorkoutPreset; + const { result } = renderHook(() => + useWorkoutPresetForm({ initialPreset: presetInSuperset, onSave }) + ); + + act(() => { + result.current.handleDuplicateExercise(0); + }); + + expect(result.current.exercises[0]?.superset_group).toBe(3); + expect(result.current.exercises[1]?.superset_group).toBeNull(); + }); +}); diff --git a/SparkyFitnessFrontend/src/types/workout.ts b/SparkyFitnessFrontend/src/types/workout.ts index ff1909fad0..9adee6a4bc 100644 --- a/SparkyFitnessFrontend/src/types/workout.ts +++ b/SparkyFitnessFrontend/src/types/workout.ts @@ -27,6 +27,7 @@ export interface WorkoutPresetExercise { sets: WorkoutPresetSet[]; category?: string; modality?: ExerciseModality | null; // Populated from backend join + superset_group?: number | null; } export interface WorkoutPreset { diff --git a/SparkyFitnessMobile/__tests__/hooks/draftExercisesSlice.test.ts b/SparkyFitnessMobile/__tests__/hooks/draftExercisesSlice.test.ts new file mode 100644 index 0000000000..f34527e0a5 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/hooks/draftExercisesSlice.test.ts @@ -0,0 +1,80 @@ +import { draftExercisesReducer } from '../../src/hooks/draftExercisesSlice'; +import { getDefaultRestSec } from '../../src/stores/appPreferencesStore'; +import type { WorkoutDraftExercise } from '../../src/types/drafts'; +import type { Exercise } from '../../src/types/exercise'; + +function buildWeightRepsExercise(): WorkoutDraftExercise { + return { + clientId: 'ex-1', + exerciseId: 'exercise-1', + exerciseName: 'Bench Press', + exerciseCategory: 'strength', + exerciseModality: 'weight_reps', + images: [], + sets: [ + { clientId: 'set-1', weight: '60', reps: '8', distance: '' }, + { clientId: 'set-2', weight: '70', reps: '6', distance: '' }, + ], + } as unknown as WorkoutDraftExercise; +} + +describe('draftExercisesReducer REPLACE_EXERCISE', () => { + it('preserves the existing sets when the replacement has the same effective modality', () => { + const exercises = [buildWeightRepsExercise()]; + + const next = draftExercisesReducer(exercises, { + type: 'REPLACE_EXERCISE', + clientId: 'ex-1', + exercise: { id: 'exercise-2', name: 'Squat', category: 'strength' } as Exercise, + setClientId: 'new-set', + preserveSets: true, + }); + + expect(next[0].exerciseId).toBe('exercise-2'); + expect(next[0].sets).toHaveLength(2); + expect(next[0].sets[0]).toEqual( + expect.objectContaining({ clientId: 'set-1', weight: '60', reps: '8' }), + ); + expect(next[0].sets[1]).toEqual( + expect.objectContaining({ clientId: 'set-2', weight: '70', reps: '6' }), + ); + }); + + it('resets to a fresh default set when the replacement changes modality', () => { + const exercises = [buildWeightRepsExercise()]; + + const next = draftExercisesReducer(exercises, { + type: 'REPLACE_EXERCISE', + clientId: 'ex-1', + exercise: { id: 'exercise-3', name: 'Plank', category: 'isometric' } as Exercise, + setClientId: 'new-set', + preserveSets: true, + }); + + expect(next[0].exerciseId).toBe('exercise-3'); + expect(next[0].sets).toEqual([ + { + clientId: 'new-set', + weight: '', + reps: '', + distance: '', + restTime: getDefaultRestSec(), + }, + ]); + }); + + it('still resets when preserveSets is not requested, regardless of modality', () => { + const exercises = [buildWeightRepsExercise()]; + + const next = draftExercisesReducer(exercises, { + type: 'REPLACE_EXERCISE', + clientId: 'ex-1', + exercise: { id: 'exercise-2', name: 'Squat', category: 'strength' } as Exercise, + setClientId: 'new-set', + preserveSets: false, + }); + + expect(next[0].sets).toHaveLength(1); + expect(next[0].sets[0]?.clientId).toBe('new-set'); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/hooks/useScreenHeader.test.tsx b/SparkyFitnessMobile/__tests__/hooks/useScreenHeader.test.tsx index fc2dd76b07..4b5b88481f 100644 --- a/SparkyFitnessMobile/__tests__/hooks/useScreenHeader.test.tsx +++ b/SparkyFitnessMobile/__tests__/hooks/useScreenHeader.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform } from 'react-native'; +import { Platform, View } from 'react-native'; import { render } from '@testing-library/react-native'; import { useScreenHeader } from '../../src/hooks/useScreenHeader'; @@ -120,6 +120,61 @@ describe('useScreenHeader accessibility label (custom path)', () => { }); }); +describe('useScreenHeader custom bar title layout', () => { + let osSpy: jest.SpyInstance | null = null; + + beforeEach(async () => { + jest.clearAllMocks(); + __resetAppPreferencesStoreForTests(); + mockUsesNativeHeader = false; + osSpy = jest.replaceProperty(Platform, 'OS', 'android'); + await initializeI18n('en'); + await i18n.changeLanguage('en'); + }); + + afterEach(() => { + if (osSpy) osSpy.restore(); + }); + + // Confirmed via onLayout measurement on a real device (row:411 L:0 T:379 + // R:0): a `flex-1` (flexBasis: 0%) side cell next to a flexShrink-only + // (flexBasis: auto/content) title gets ZERO share of both the shrink + // distribution (scaled shrink factor = flexShrink × flexBasis = 0 for + // basis:0% items) and the growth (growth doesn't apply during overflow) — + // a long title claims the entire row and the side cells vanish. Fixed via + // an absolutely-positioned title layer (decoupled from the side cells' + // flex layout entirely, so it can never compete with them for space) plus + // content-sized (flexShrink: 0) side cells, so neither a long title nor + // wide side content can squeeze the other. Asserts the inline `style` (not + // a className string) since Uniwind's classes are processed at build time + // and are opaque to this test either way — the inline style is what + // actually guarantees the behavior at runtime. + it('renders the title as an untouchable absolute layer and keeps the side cells content-sized, so a long title cannot squeeze them to zero', () => { + const { UNSAFE_getAllByType } = render( + , + ); + + const views = UNSAFE_getAllByType(View); + const titleLayer = views.find((view) => view.props.pointerEvents === 'box-none'); + expect(titleLayer?.props.style).toEqual( + expect.objectContaining({ position: 'absolute', left: 16, right: 16 }), + ); + expect(titleLayer?.props.children.props.children).toBe( + 'A very long preset name that would otherwise overflow the header bar', + ); + + const leftContainer = views.find( + (view) => view.props.className === 'flex-row items-center gap-4', + ); + expect(leftContainer?.props.style).toEqual(expect.objectContaining({ flexShrink: 0 })); + + const rightContainer = views.find( + (view) => view.props.className === 'flex-row items-center justify-end gap-4', + ); + expect(rightContainer?.props.style).toEqual(expect.objectContaining({ flexShrink: 0 })); + }); +}); + describe('useScreenHeader accessibility label (native path)', () => { let osSpy: jest.SpyInstance | null = null; diff --git a/SparkyFitnessMobile/__tests__/screens/WorkoutPresetDetailScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/WorkoutPresetDetailScreen.test.tsx index 0a5cdd8f41..247336bb6e 100644 --- a/SparkyFitnessMobile/__tests__/screens/WorkoutPresetDetailScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/WorkoutPresetDetailScreen.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import WorkoutPresetDetailScreen from '../../src/screens/WorkoutPresetDetailScreen'; -import { usePreferences } from '../../src/hooks'; +import { usePreferences, useCreateWorkoutPreset } from '../../src/hooks'; import { useStartLiveWorkout } from '../../src/hooks/useStartLiveWorkout'; import { loadActiveDraft } from '../../src/services/workoutDraftService'; import { buildPresetStartExercisesPayload } from '../../src/utils/workoutSession'; @@ -13,6 +13,9 @@ import { __resetAppPreferencesStoreForTests, } from '../../src/stores/appPreferencesStore'; import type { WorkoutPreset, WorkoutPresetSet } from '../../src/types/workoutPresets'; +import type { RootStackScreenProps } from '../../src/types/navigation'; + +type ScreenProps = RootStackScreenProps<'WorkoutPresetDetail'>; jest.mock('../../src/hooks', () => ({ usePreferences: jest.fn(), @@ -20,12 +23,21 @@ jest.mock('../../src/hooks', () => ({ useServerConnection: jest.fn(() => ({ isConnected: true, isLoading: false })), useDeleteWorkoutPreset: jest.fn(() => ({ confirmAndDelete: jest.fn(), isPending: false })), useUpdateWorkoutPreset: jest.fn(() => ({ updateWorkoutPreset: jest.fn(), isPending: false })), + useCreateWorkoutPreset: jest.fn(() => ({ createPresetAsync: jest.fn(), isPending: false })), })); jest.mock('../../src/hooks/useStartLiveWorkout', () => ({ useStartLiveWorkout: jest.fn(), })); +// Force the custom (non-native) header path so header action buttons render +// as pressable React elements instead of being handed off to +// unstable_header*Items, which the test renderer can't press. +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSTabsActive: jest.fn(() => false), + useNativeIOSHeadersActive: jest.fn(() => false), +})); + jest.mock('../../src/components/ActiveWorkoutBar', () => ({ useActiveWorkoutBarPadding: jest.fn(() => 0), })); @@ -38,8 +50,9 @@ jest.mock('../../src/services/workoutDraftService', () => ({ const mockNavigation = { setOptions: jest.fn(), navigate: jest.fn(), + push: jest.fn(), goBack: jest.fn(), -} as any; +} as unknown as ScreenProps['navigation']; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => mockNavigation, @@ -53,6 +66,9 @@ const mockLoadActiveDraft = loadActiveDraft as jest.MockedFunction; +const mockUseCreateWorkoutPreset = useCreateWorkoutPreset as jest.MockedFunction< + typeof useCreateWorkoutPreset +>; const insets = { top: 0, bottom: 0, left: 0, right: 0 }; const frame = { x: 0, y: 0, width: 390, height: 844 }; @@ -118,6 +134,10 @@ describe('WorkoutPresetDetailScreen', () => { } as any); mockLoadActiveDraft.mockResolvedValue(null); mockUseStartLiveWorkout.mockReturnValue({ startLiveWorkout, isStarting: false }); + mockUseCreateWorkoutPreset.mockReturnValue({ + createPresetAsync: jest.fn(), + isPending: false, + }); }); it('starts a live workout with the preset-built payload on Start workout', () => { @@ -144,6 +164,95 @@ describe('WorkoutPresetDetailScreen', () => { expect(navigation.navigate).not.toHaveBeenCalled(); }); + it('duplicates the preset (available even though the fixture profile does not own it) into a private copy with the original exercises/sets', async () => { + const created = buildPreset({ id: 8, name: 'Push Day (Copy)' }); + const createPresetAsync = jest.fn().mockResolvedValue(created); + mockUseCreateWorkoutPreset.mockReturnValue({ createPresetAsync, isPending: false }); + + const preset = buildPreset({ + exercises: [ + { + id: 'pe-1', + exercise_id: 'ex-1', + exercise_name: 'Bench Press', + image_url: null, + sets: [buildSet({ id: 's-1', set_number: 1, reps: 5, weight: 100 })], + }, + ], + }); + const screen = renderScreen(preset); + + fireEvent.press(screen.getByLabelText('Duplicate workout preset')); + + await waitFor(() => expect(createPresetAsync).toHaveBeenCalledTimes(1)); + expect(createPresetAsync).toHaveBeenCalledWith({ + name: 'Push Day (Copy)', + description: 'Chest, shoulders, triceps', + is_public: false, + exercises: [ + { + exercise_id: 'ex-1', + image_url: null, + sort_order: 0, + superset_group: undefined, + sets: [ + { + set_number: 1, + set_type: 'normal', + reps: 5, + weight: 100, + duration: null, + distance: undefined, + rest_time: 60, + notes: null, + }, + ], + }, + ], + }); + await waitFor(() => { + // push, not navigate: this runs from WorkoutPresetDetail itself, and + // navigate() to the already-focused route would replace its params + // instead of pushing a new screen, silently turning the original + // detail screen into the copy. + expect(navigation.push).toHaveBeenCalledWith('WorkoutPresetDetail', { + preset: created, + }); + }); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); + + it('re-indexes sort_order from array position on duplicate (the read query never returns it)', async () => { + const createPresetAsync = jest.fn().mockResolvedValue(buildPreset({ id: 8 })); + mockUseCreateWorkoutPreset.mockReturnValue({ createPresetAsync, isPending: false }); + + const preset = buildPreset({ + exercises: [ + { + id: 'pe-1', + exercise_id: 'ex-1', + exercise_name: 'Bench Press', + image_url: null, + sets: [buildSet()], + }, + { + id: 'pe-2', + exercise_id: 'ex-2', + exercise_name: 'Squat', + image_url: null, + sets: [buildSet()], + }, + ], + }); + const screen = renderScreen(preset); + + fireEvent.press(screen.getByLabelText('Duplicate workout preset')); + + await waitFor(() => expect(createPresetAsync).toHaveBeenCalledTimes(1)); + const sentExercises = createPresetAsync.mock.calls[0][0].exercises; + expect(sentExercises.map((e: { sort_order: number }) => e.sort_order)).toEqual([0, 1]); + }); + it('navigates to WorkoutAdd with the preset and popCount=2 on Log past workout', async () => { const preset = buildPreset(); const screen = renderScreen(preset); @@ -215,11 +324,10 @@ describe('WorkoutPresetDetailScreen', () => { }); const screen = renderScreen(preset); - // The name lives in the header title; the body keeps the description, + // The name lives in the header title (rendered as plain text on this + // file's forced custom header path); the body keeps the description, // exercise count, and the exercise card. - expect(navigation.setOptions).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Push Day' }), - ); + expect(screen.getByText('Push Day')).toBeTruthy(); expect(screen.getByText('Chest, shoulders, triceps')).toBeTruthy(); expect(screen.getByText('1 exercise')).toBeTruthy(); expect(screen.getByText('Bench Press')).toBeTruthy(); diff --git a/SparkyFitnessMobile/src/components/WorkoutFormExerciseList.tsx b/SparkyFitnessMobile/src/components/WorkoutFormExerciseList.tsx index e52cd99e46..62a650e0e0 100644 --- a/SparkyFitnessMobile/src/components/WorkoutFormExerciseList.tsx +++ b/SparkyFitnessMobile/src/components/WorkoutFormExerciseList.tsx @@ -94,6 +94,12 @@ interface WorkoutFormExerciseListProps { * screen sets its replace target and routes the ExerciseSearch return. */ onReplaceExercise?: (clientId: string) => void; + /** + * Enables the ⋮ "Duplicate exercise" item: adds an independent copy of the + * entry (same sets, notes, calories) right after it, ungrouped even if the + * original is in a superset. Preset form only. + */ + onDuplicateExercise?: (clientId: string) => void; /** * Enables the ⋮ "Clear logged sets" item, shown only when the exercise has * a completed set and renders a set table — cardio-effort-form exercises @@ -166,6 +172,7 @@ const WorkoutFormExerciseList = forwardRef< setExerciseCalories, setExerciseNotes, onReplaceExercise, + onDuplicateExercise, clearExerciseCompletions, supersetWith, ungroupExercise, @@ -540,6 +547,13 @@ const WorkoutFormExerciseList = forwardRef< onPress: () => onReplaceExercise(clientId), }); } + if (onDuplicateExercise) { + items.push({ + key: 'duplicate', + label: 'Duplicate exercise', + onPress: () => onDuplicateExercise(clientId), + }); + } if (clearExerciseCompletions) { const target = exercises.find(e => e.clientId === clientId); // The cardio effort form shows no completion state in the forms, so a @@ -577,6 +591,7 @@ const WorkoutFormExerciseList = forwardRef< supersetWith, ungroupExercise, onReplaceExercise, + onDuplicateExercise, clearExerciseCompletions, onRemoveExercise, onViewExercise, diff --git a/SparkyFitnessMobile/src/hooks/draftExercisesSlice.ts b/SparkyFitnessMobile/src/hooks/draftExercisesSlice.ts index 138bf8ea06..ccc242c7f9 100644 --- a/SparkyFitnessMobile/src/hooks/draftExercisesSlice.ts +++ b/SparkyFitnessMobile/src/hooks/draftExercisesSlice.ts @@ -1,5 +1,6 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { Dispatch, MutableRefObject } from 'react'; +import { resolveExerciseModality } from '@workspace/shared'; import { getDefaultRestSec, moveDraftExerciseItem, @@ -28,7 +29,26 @@ export function generateClientId(): string { export type DraftExercisesAction = | { type: 'ADD_EXERCISE'; exercise: Exercise; exerciseClientId: string; setClientId: string } | { type: 'REMOVE_EXERCISE'; clientId: string } - | { type: 'REPLACE_EXERCISE'; clientId: string; exercise: Exercise; setClientId: string } + | { + type: 'REPLACE_EXERCISE'; + clientId: string; + exercise: Exercise; + setClientId: string; + /** + * Keep the entry's already-entered sets instead of resetting to one + * empty set. Opt-in (preset form only) — the workout form still wants + * a fresh set, since replacing a logged exercise's identity while + * keeping its recorded numbers would misrepresent what was actually + * performed. + */ + preserveSets?: boolean; + } + | { + type: 'DUPLICATE_EXERCISE'; + clientId: string; + newExerciseClientId: string; + setClientIds: string[]; + } | { type: 'CLEAR_EXERCISE_COMPLETIONS'; clientId: string } | { type: 'ADD_SET'; exerciseClientId: string; setClientId: string } | { type: 'REMOVE_SET'; exerciseClientId: string; setClientId: string } @@ -80,13 +100,33 @@ export function draftExercisesReducer( ); // Mirrors the live store's replaceExercise: swap the exercise identity in - // place (keeping clientId, position, and superset grouping) and reset to - // one default set — the old sets no longer describe the new movement. - // Dropping serverId sends the whole session down the server's - // delete-and-recreate path (mixed old/new exercise ids aren't allowed). + // place (keeping clientId, position, and superset grouping). Dropping + // serverId sends the whole session down the server's delete-and-recreate + // path (mixed old/new exercise ids aren't allowed). Sets are only + // preserved when the replacement's effective modality matches the + // original's — otherwise a duration set's null reps, or a weight_reps + // set's stale duration, would carry into a UI that hides those fields + // but the server would still persist them. When preserveSets isn't + // requested, there's nothing to preserve, or the modality changed, reset + // to one default set — the old sets no longer describe the new movement. case 'REPLACE_EXERCISE': return exercises.map(exercise => { if (exercise.clientId !== action.clientId) return exercise; + const modalityUnchanged = + resolveExerciseModality(exercise.exerciseModality, exercise.exerciseCategory) === + resolveExerciseModality(action.exercise.modality, action.exercise.category); + const sets = + action.preserveSets && exercise.sets.length > 0 && modalityUnchanged + ? exercise.sets + : [ + { + clientId: action.setClientId, + weight: '', + reps: '', + distance: '', + restTime: getDefaultRestSec(), + }, + ]; return { ...exercise, serverId: undefined, @@ -96,18 +136,49 @@ export function draftExercisesReducer( exerciseCategory: action.exercise.category, exerciseModality: action.exercise.modality ?? null, images: action.exercise.images ?? [], - sets: [ - { - clientId: action.setClientId, - weight: '', - reps: '', - distance: '', - restTime: getDefaultRestSec(), - }, - ], + sets, }; }); + // Adds an independent copy of the exercise (same sets, notes, calories) + // right after its own run so a duplicate can never visually split an + // existing superset's border. The copy starts ungrouped — silently + // joining the original's superset would change the original's structure. + case 'DUPLICATE_EXERCISE': { + const index = exercises.findIndex(e => e.clientId === action.clientId); + if (index === -1) return exercises; + const original = exercises[index]; + const duplicate: WorkoutDraftExercise = { + ...original, + clientId: action.newExerciseClientId, + serverId: undefined, + snapshot: null, + supersetGroup: null, + sets: original.sets.map((set, i) => ({ + ...set, + // setClientIds is precomputed against exercisesRef before dispatch + // (see useDraftExerciseActions); fall back to a deterministic id if + // it and the live reducer state ever desync on set count. + clientId: action.setClientIds[i] ?? `${action.newExerciseClientId}-${i}`, + completedAt: null, + isPr: false, + })), + }; + let insertAt = index + 1; + const groupId = original.supersetGroup ?? null; + if (groupId != null) { + while ( + insertAt < exercises.length && + (exercises[insertAt].supersetGroup ?? null) === groupId + ) { + insertAt++; + } + } + const next = [...exercises]; + next.splice(insertAt, 0, duplicate); + return next; + } + // Mirrors the live store's clearExerciseCompletions: un-log every set, // dropping the stale PR flags with the completions. Identity return when // nothing is logged. @@ -235,6 +306,11 @@ export function draftExercisesReducer( */ export function useDraftExerciseActions( dispatch: Dispatch, + exercises: WorkoutDraftExercise[], + options?: { + /** See REPLACE_EXERCISE's `preserveSets`. Off by default (workout form). */ + preserveSetsOnReplace?: boolean; + }, ): { exercisesModifiedRef: MutableRefObject; addExercise: (exercise: Exercise) => { exerciseClientId: string; setClientId: string }; @@ -242,7 +318,8 @@ export function useDraftExerciseActions( replaceExercise: ( clientId: string, exercise: Exercise, - ) => { exerciseClientId: string; setClientId: string }; + ) => { exerciseClientId: string; setClientId: string | null }; + duplicateExercise: (clientId: string) => { exerciseClientId: string }; clearExerciseCompletions: (clientId: string) => void; addSet: (exerciseClientId: string) => string; removeSet: (exerciseClientId: string, setClientId: string) => void; @@ -265,6 +342,18 @@ export function useDraftExerciseActions( reorderExercises: (fromItemIndex: number, toItemIndex: number) => void; } { const exercisesModifiedRef = useRef(false); + // Read inside the memoized wrappers below without joining the useMemo deps + // (which would churn every wrapper's identity on every draft edit) — only + // replaceExercise/duplicateExercise need the current array, to look up a + // target's existing sets synchronously before dispatch. Synced in an + // effect (not inline) since writing a ref during render is disallowed; by + // the time a user action calls replaceExercise/duplicateExercise, the + // effect from the latest render has already run. + const exercisesRef = useRef(exercises); + useEffect(() => { + exercisesRef.current = exercises; + }); + const preserveSetsOnReplace = options?.preserveSetsOnReplace ?? false; return useMemo( () => ({ exercisesModifiedRef, @@ -282,8 +371,36 @@ export function useDraftExerciseActions( replaceExercise: (clientId: string, exercise: Exercise) => { exercisesModifiedRef.current = true; const setClientId = generateClientId(); - dispatch({ type: 'REPLACE_EXERCISE', clientId, exercise, setClientId }); - return { exerciseClientId: clientId, setClientId }; + dispatch({ + type: 'REPLACE_EXERCISE', + clientId, + exercise, + setClientId, + preserveSets: preserveSetsOnReplace, + }); + // Mirrors REPLACE_EXERCISE's own preserve/reset decision: sets are + // preserved (nothing new to focus) only when preserving was + // requested, there was something to preserve, and the modality is + // unchanged. Otherwise a fresh single set was created; focus it. + const target = exercisesRef.current.find(e => e.clientId === clientId); + const hadExistingSets = (target?.sets.length ?? 0) > 0; + const modalityUnchanged = + target != null && + resolveExerciseModality(target.exerciseModality, target.exerciseCategory) === + resolveExerciseModality(exercise.modality, exercise.category); + const setsWerePreserved = preserveSetsOnReplace && hadExistingSets && modalityUnchanged; + return { + exerciseClientId: clientId, + setClientId: setsWerePreserved ? null : setClientId, + }; + }, + duplicateExercise: (clientId: string) => { + exercisesModifiedRef.current = true; + const newExerciseClientId = generateClientId(); + const target = exercisesRef.current.find(e => e.clientId === clientId); + const setClientIds = (target?.sets ?? []).map(() => generateClientId()); + dispatch({ type: 'DUPLICATE_EXERCISE', clientId, newExerciseClientId, setClientIds }); + return { exerciseClientId: newExerciseClientId }; }, clearExerciseCompletions: (clientId: string) => { exercisesModifiedRef.current = true; @@ -341,6 +458,6 @@ export function useDraftExerciseActions( dispatch({ type: 'REORDER_EXERCISES', fromItemIndex, toItemIndex }); }, }), - [dispatch], + [dispatch, preserveSetsOnReplace], ); } diff --git a/SparkyFitnessMobile/src/hooks/useExerciseSetEditing.ts b/SparkyFitnessMobile/src/hooks/useExerciseSetEditing.ts index 0aa227d5e7..1acf231a09 100644 --- a/SparkyFitnessMobile/src/hooks/useExerciseSetEditing.ts +++ b/SparkyFitnessMobile/src/hooks/useExerciseSetEditing.ts @@ -11,11 +11,12 @@ interface ExerciseSetEditingActions { removeExercise: (clientId: string) => void; addSet: (exerciseClientId: string) => string; /** Enables replace routing: while a replace target is set, the next selected - * exercise swaps in place instead of appending. */ + * exercise swaps in place instead of appending. A null setClientId means + * the replace preserved the existing sets, so there's nothing new to focus. */ replaceExercise?: ( clientId: string, exercise: Exercise, - ) => { exerciseClientId: string; setClientId: string }; + ) => { exerciseClientId: string; setClientId: string | null }; } export function useExerciseSetEditing(actions: ExerciseSetEditingActions) { @@ -56,7 +57,11 @@ export function useExerciseSetEditing(actions: ExerciseSetEditingActions) { replaceTarget != null && actions.replaceExercise ? actions.replaceExercise(replaceTarget, exercise) : actions.addExercise(exercise); - pendingActivationRef.current = `${exerciseClientId}:${setClientId}`; + // null means a replace preserved the existing sets — nothing new to + // focus. Clear (not just skip) so a stale pending activation from an + // earlier action can't fire on a later transitionEnd. + pendingActivationRef.current = + setClientId != null ? `${exerciseClientId}:${setClientId}` : null; // eslint-disable-next-line react-hooks/exhaustive-deps -- using stable sub-properties; spreading `actions` would break memoization }, [actions.addExercise, actions.replaceExercise]); diff --git a/SparkyFitnessMobile/src/hooks/useScreenHeader.tsx b/SparkyFitnessMobile/src/hooks/useScreenHeader.tsx index efac545ae1..fe51121c78 100644 --- a/SparkyFitnessMobile/src/hooks/useScreenHeader.tsx +++ b/SparkyFitnessMobile/src/hooks/useScreenHeader.tsx @@ -721,14 +721,36 @@ export function useScreenHeader(config: ScreenHeaderConfig): React.ReactNode { const bar = ( - {/* Equal-width side cells keep the title cell geometrically centered in - the bar even when the left/right actions have different widths; the - title stays content-sized (shrinking to truncate) so it can use more - than a third of the width when the sides are light. */} - {leftCustom} - + {/* The title is a separate, absolutely-positioned layer centered on the + bar's full width, independent of the side cells' own flex layout — + the same technique native iOS/Android headers use. Centering the + title by giving the side cells equal flex-grow instead (so an empty + side matched the populated one) is what let a long title squeeze + both side cells to zero width in the first place: under CSS/Yoga's + shrink algorithm, a `flexBasis: 0%` sibling always computes a scaled + shrink factor of 0, so once the title overflowed the row it claimed + 100% of the space and the side cells rendered at 0 width (confirmed + via on-device onLayout measurement). Decoupling the title from that + layout means it can never compete with the side cells for space, so + it can never squeeze them — and it still lands on the bar's true + center regardless of how the left/right content widths differ. + pointerEvents="box-none" keeps the title layer itself untouchable so + it can never sit "on top of" a button for hit-testing purposes. */} + {center ?? ( )} - {rightCustom} + {/* flexShrink: 0 (content-sized) rather than flex-1: these cells can + never be squeezed by the title, at the cost of no longer truncating + if their own content ever got wide enough to overflow — a non-issue + for the icon/short-text buttons this bar renders. */} + + {leftCustom} + + + {rightCustom} + ); diff --git a/SparkyFitnessMobile/src/hooks/useWorkoutForm.ts b/SparkyFitnessMobile/src/hooks/useWorkoutForm.ts index 17c1a085b7..2236b1ed75 100644 --- a/SparkyFitnessMobile/src/hooks/useWorkoutForm.ts +++ b/SparkyFitnessMobile/src/hooks/useWorkoutForm.ts @@ -230,7 +230,7 @@ export function useWorkoutForm(options?: UseWorkoutFormOptions) { supersetWith, ungroupExercise, reorderExercises, - } = useDraftExerciseActions(dispatch); + } = useDraftExerciseActions(dispatch, state.exercises); const { clearPersistedDraft } = useDraftPersistence({ state, diff --git a/SparkyFitnessMobile/src/hooks/useWorkoutPresetForm.ts b/SparkyFitnessMobile/src/hooks/useWorkoutPresetForm.ts index 7797fc0f28..58ab8f85a4 100644 --- a/SparkyFitnessMobile/src/hooks/useWorkoutPresetForm.ts +++ b/SparkyFitnessMobile/src/hooks/useWorkoutPresetForm.ts @@ -137,6 +137,7 @@ export function useWorkoutPresetForm() { addExercise, removeExercise, replaceExercise, + duplicateExercise, addSet, removeSet, updateSetField, @@ -145,7 +146,7 @@ export function useWorkoutPresetForm() { supersetWith, ungroupExercise, reorderExercises, - } = useDraftExerciseActions(dispatch); + } = useDraftExerciseActions(dispatch, state.exercises, { preserveSetsOnReplace: true }); const setName = useCallback((name: string) => { dispatch({ type: 'SET_NAME', name }); @@ -196,6 +197,7 @@ export function useWorkoutPresetForm() { addExercise, removeExercise, replaceExercise, + duplicateExercise, addSet, removeSet, updateSetField, diff --git a/SparkyFitnessMobile/src/screens/WorkoutPresetDetailScreen.tsx b/SparkyFitnessMobile/src/screens/WorkoutPresetDetailScreen.tsx index e34c6234a2..41a5aa742f 100644 --- a/SparkyFitnessMobile/src/screens/WorkoutPresetDetailScreen.tsx +++ b/SparkyFitnessMobile/src/screens/WorkoutPresetDetailScreen.tsx @@ -10,6 +10,7 @@ import { type AnchorRect } from '../components/AnchoredMenu'; import { useActiveWorkoutBarPadding } from '../components/ActiveWorkoutBar'; import { clearDraft, loadActiveDraft } from '../services/workoutDraftService'; import { + useCreateWorkoutPreset, useDeleteWorkoutPreset, usePreferences, useProfile, @@ -211,31 +212,73 @@ const WorkoutPresetDetailScreen: React.FC = ({ }); }, [navigation, preset, route.key]); - const rightItems: HeaderItem[] = [ - ...(canManagePreset - ? [ - { - kind: 'icon', - sfSymbol: isPublic ? 'lock.fill' : 'square.and.arrow.up', - ionicon: isPublic ? 'lock-closed-outline' : 'share-social-outline', - role: 'secondary', - useIoniconOnIOS: !isPublic, - disabled: isSharePending, - onPress: handleToggleShare, - accessibilityLabel: isPublic ? 'Make private' : 'Share with public', - identifier: 'workout-preset-detail-share', - } as const, - { - kind: 'text', - label: 'Edit', - role: 'secondary', - onPress: handleEdit, - accessibilityLabel: 'Edit workout preset', - identifier: 'workout-preset-detail-edit', - } as const, - ] - : []), - ]; + // Available regardless of ownership (unlike Share/Edit below) — duplicating + // someone else's public preset is exactly how you'd fork it into your own + // library. + const { createPresetAsync, isPending: isDuplicatePending } = useCreateWorkoutPreset(); + const handleDuplicatePreset = useCallback(async () => { + try { + const created = await createPresetAsync({ + name: `${preset.name} (Copy)`, + description: preset.description, + is_public: false, + // The list/detail read queries never select wpe.sort_order (see + // workoutPresetRepository), so exercise.sort_order is always + // undefined here — every duplicated row would otherwise insert with + // the same sort_order and rely 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. + exercises: preset.exercises.map((exercise, index) => ({ + exercise_id: exercise.exercise_id, + image_url: exercise.image_url, + sort_order: index, + superset_group: exercise.superset_group, + sets: exercise.sets.map(set => ({ + set_number: set.set_number, + set_type: set.set_type, + reps: set.reps, + weight: set.weight, + duration: set.duration, + distance: set.distance, + rest_time: set.rest_time, + notes: set.notes, + })), + })), + }); + Toast.show({ type: 'success', text1: 'Workout preset duplicated' }); + // navigate() to the route already focused replaces its params instead of + // pushing a new screen (React Navigation 7) — push so Back still returns + // to the original preset instead of the fresh copy. Same fix as + // MealDetailScreen's MealDetail -> MealDetail link. + navigation.push('WorkoutPresetDetail', { preset: created }); + } catch { + // useCreateWorkoutPreset already shows an error Toast on failure. + } + }, [createPresetAsync, preset, navigation]); + + const rightItems: HeaderItem[] = canManagePreset + ? [ + { + kind: 'icon', + sfSymbol: isPublic ? 'lock.fill' : 'square.and.arrow.up', + ionicon: isPublic ? 'lock-closed-outline' : 'share-social-outline', + role: 'secondary', + useIoniconOnIOS: !isPublic, + disabled: isSharePending, + onPress: handleToggleShare, + accessibilityLabel: isPublic ? 'Make private' : 'Share with public', + identifier: 'workout-preset-detail-share', + } as const, + { + kind: 'text', + label: 'Edit', + role: 'secondary', + onPress: handleEdit, + accessibilityLabel: 'Edit workout preset', + identifier: 'workout-preset-detail-edit', + } as const, + ] + : []; const header = useScreenHeader({ title: preset.name, @@ -333,6 +376,17 @@ const WorkoutPresetDetailScreen: React.FC = ({ Log past workout + + {canManagePreset && (