diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 9e25cf86..3be10e5b 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -238,8 +238,10 @@ "needsLogsToAdvanceHelpText": "If you select this option, the routine will only progress to the next scheduled day if you've logged a workout for the current day. If this option is not selected, the routine will automatically advance to the next day regardless of whether you logged a workout or not.", "addSuperset": "Add superset", "addExercise": "Add exercise", + "addSet": "Add set", "exerciseNr": "Exercise {{number}}", "supersetNr": "Superset {{number}}", + "setNr": "Set {{number}}", "editProgression": "Edit progression", "progressionNeedsReplace": "One of the previous entries must have a replace operation", "exerciseHasProgression": "This exercise has progression rules and can't be edited here. To do so, click the button.", diff --git a/src/components/WorkoutRoutines/widgets/DayDetails.test.tsx b/src/components/WorkoutRoutines/widgets/DayDetails.test.tsx index ada6ef60..301bda04 100644 --- a/src/components/WorkoutRoutines/widgets/DayDetails.test.tsx +++ b/src/components/WorkoutRoutines/widgets/DayDetails.test.tsx @@ -2,15 +2,107 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; import { Day } from "components/WorkoutRoutines/models/Day"; -import { DayDetails, DayDragAndDropGrid } from "components/WorkoutRoutines/widgets/DayDetails"; +import { Slot } from "components/WorkoutRoutines/models/Slot"; +import { SlotEntry } from "components/WorkoutRoutines/models/SlotEntry"; +import { DayDetails, DayDragAndDropGrid, groupSlotsByExercise } from "components/WorkoutRoutines/widgets/DayDetails"; import React from 'react'; import { MemoryRouter } from "react-router-dom"; -import { addDay, getLanguages, getProfile, getRoutine } from "services"; +import { addDay, addSlot, getLanguages, getProfile, getRoutine } from "services"; +import { addSlotEntry } from "services/slot_entry"; import { getTestQueryClient } from "tests/queryClient"; import { testProfileDataVerified } from "tests/userTestdata"; import { testDayLegs, testRoutine1 } from "tests/workoutRoutinesTestData"; jest.mock("services"); +jest.mock("services/slot_entry"); + +const makeSlot = (id: number, exerciseId: number) => new Slot({ + id, dayId: 1, order: id, comment: '', config: null, + entries: [ + new SlotEntry({ + id, slotId: id, exerciseId, + repetitionUnitId: 1, repetitionRounding: 1, + weightUnitId: 1, weightRounding: 1, + order: 1, comment: '', type: 'normal', config: null, + }) + ] +}); + +const makeEmptySlot = (id: number) => new Slot({ + id, dayId: 1, order: id, comment: '', config: null, entries: [] +}); + +const makeSupersetSlot = (id: number, exerciseIds: number[]) => new Slot({ + id, dayId: 1, order: id, comment: '', config: null, + entries: exerciseIds.map((exId, i) => new SlotEntry({ + id: id * 100 + i, slotId: id, exerciseId: exId, + repetitionUnitId: 1, repetitionRounding: 1, + weightUnitId: 1, weightRounding: 1, + order: i + 1, comment: '', type: 'normal', config: null, + })) +}); + +describe("groupSlotsByExercise", () => { + + test('groups consecutive slots with the same exercise', () => { + const slots = [makeSlot(1, 10), makeSlot(2, 10), makeSlot(3, 10)]; + const groups = groupSlotsByExercise(slots); + + expect(groups).toHaveLength(1); + expect(groups[0].exerciseId).toBe(10); + expect(groups[0].slots).toHaveLength(3); + }); + + test('does not group non-consecutive slots with the same exercise', () => { + const slots = [makeSlot(1, 10), makeSlot(2, 20), makeSlot(3, 10)]; + const groups = groupSlotsByExercise(slots); + + expect(groups).toHaveLength(3); + expect(groups[0].exerciseId).toBe(10); + expect(groups[1].exerciseId).toBe(20); + expect(groups[2].exerciseId).toBe(10); + }); + + test('does not group superset slots (multiple entries)', () => { + const slots = [makeSupersetSlot(1, [10, 20]), makeSlot(2, 10)]; + const groups = groupSlotsByExercise(slots); + + expect(groups).toHaveLength(2); + expect(groups[0].slots).toHaveLength(1); + expect(groups[1].slots).toHaveLength(1); + }); + + test('does not group empty slots', () => { + const slots = [makeEmptySlot(1), makeEmptySlot(2)]; + const groups = groupSlotsByExercise(slots); + + expect(groups).toHaveLength(2); + }); + + test('handles mixed slots correctly', () => { + const slots = [ + makeSlot(1, 10), + makeSlot(2, 10), + makeSupersetSlot(3, [10, 20]), + makeSlot(4, 30), + makeSlot(5, 30), + ]; + const groups = groupSlotsByExercise(slots); + + expect(groups).toHaveLength(3); + expect(groups[0].slots).toHaveLength(2); // 2x exercise 10 + expect(groups[1].slots).toHaveLength(1); // superset + expect(groups[2].slots).toHaveLength(2); // 2x exercise 30 + }); + + test('preserves original indices', () => { + const slots = [makeSlot(1, 10), makeSlot(2, 20), makeSlot(3, 20)]; + const groups = groupSlotsByExercise(slots); + + expect(groups[1].slots[0].originalIndex).toBe(1); + expect(groups[1].slots[1].originalIndex).toBe(2); + }); +}); describe("Test the DayDragAndDropGrid component", () => { let user: ReturnType; @@ -141,4 +233,33 @@ describe("DayDetails component", () => { expect(screen.getByText('Set successfully deleted')).toBeInTheDocument(); expect(screen.getByText('undo')).toBeInTheDocument(); }); + + // handleDuplicateSlot + test('duplicate inserts new slot after source with correct order and exerciseId', async () => { + const user = userEvent.setup(); + + const mockAddSlot = addSlot as jest.Mock; + mockAddSlot.mockResolvedValue(new Slot({ id: 999, dayId: 5, order: 2 })); + (addSlotEntry as jest.Mock).mockResolvedValue({}); + + renderComponent(testDayLegs); + + // Click "Add set" on the first slot + const addSetButtons = screen.getAllByText('routines.addSet'); + await user.click(addSetButtons[0]); + + // New slot should be created with order = sourceIndex + 2 (1-based, after source) + await waitFor(() => { + expect(mockAddSlot).toHaveBeenCalledWith( + expect.objectContaining({ order: 2 }) + ); + }); + + // SlotEntry should be created with the same exerciseId as the source + await waitFor(() => { + expect(addSlotEntry).toHaveBeenCalledWith( + expect.objectContaining({ exerciseId: testDayLegs.slots[0].entries[0].exerciseId }) + ); + }); + }); }); diff --git a/src/components/WorkoutRoutines/widgets/DayDetails.tsx b/src/components/WorkoutRoutines/widgets/DayDetails.tsx index 5a54384c..551dcaca 100644 --- a/src/components/WorkoutRoutines/widgets/DayDetails.tsx +++ b/src/components/WorkoutRoutines/widgets/DayDetails.tsx @@ -1,12 +1,14 @@ import { DragDropContext, Draggable, DraggableStyle, Droppable, DropResult } from "@hello-pangea/dnd"; import AddIcon from "@mui/icons-material/Add"; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { Alert, AlertTitle, Box, Button, + ButtonGroup, FormControlLabel, Snackbar, SnackbarCloseReason, @@ -14,6 +16,8 @@ import { Switch, Tab, Tabs, + Typography, + useTheme, } from "@mui/material"; import Grid from '@mui/material/Grid'; import { LoadingPlaceholder, LoadingProgressIcon } from "components/Core/LoadingWidget/LoadingWidget"; @@ -202,6 +206,82 @@ const useSlotDeletion = (day: Day, routineId: number) => { }; +type SlotGroup = { + exerciseId: number | null, + exerciseName: string | null, + slots: { slot: Slot, originalIndex: number }[], +}; + +export const groupSlotsByExercise = (slots: Slot[]): SlotGroup[] => { + const groups: SlotGroup[] = []; + + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]; + const primaryEntry = slot.entries.length === 1 ? slot.entries[0] : null; + const exerciseId = primaryEntry?.exerciseId ?? null; + + const lastGroup = groups[groups.length - 1]; + if (lastGroup && exerciseId !== null && lastGroup.exerciseId === exerciseId) { + lastGroup.slots.push({ slot, originalIndex: i }); + } else { + groups.push({ + exerciseId, + exerciseName: primaryEntry?.exercise?.getTranslation()?.name ?? null, + slots: [{ slot, originalIndex: i }], + }); + } + } + + return groups; +}; + + +const SlotGroupContainer = (props: { + group: SlotGroup, + routineId: number, + onDuplicate: (slotId: number) => void, + children: React.ReactNode, +}) => { + const theme = useTheme(); + const [t] = useTranslation(); + + if (props.group.slots.length <= 1) { + return <>{props.children}; + } + + const lastSlot = props.group.slots[props.group.slots.length - 1].slot; + + return ( + + + + + {props.group.exerciseName} + + + + + + + + + {props.children} + + ); +}; + + export const DayDetails = (props: { day: Day, routineId: number, @@ -232,6 +312,41 @@ export const DayDetails = (props: { } }; + const handleDuplicateSlot = async (slotId: number) => { + const sourceIndex = props.day.slots.findIndex(s => s.id === slotId); + const sourceSlot = props.day.slots[sourceIndex]; + if (!sourceSlot || sourceSlot.entries.length === 0) { + return; + } + + // Use array index for ordering (1-based), insert after source + const insertOrder = sourceIndex + 2; + + // First, bump the order of all slots after the source (wait for completion) + const slotsToUpdate = props.day.slots + .slice(sourceIndex + 1) + .map((s, i) => Slot.clone(s, { order: insertOrder + 1 + i })); + if (slotsToUpdate.length > 0) { + await editSlotOrderQuery.mutateAsync(slotsToUpdate); + } + + // Then create the new slot at the correct position + const entry = sourceSlot.entries[0]; + const newSlot = await addSlotQuery.mutateAsync(new Slot({ + dayId: props.day.id!, + order: insertOrder, + })); + + // Finally add the exercise entry + await addSlotEntryQuery.mutateAsync(new SlotEntry({ + slotId: newSlot.id!, + exerciseId: entry.exerciseId, + type: 'normal', + order: 1, + weightUnitId: entry.weightUnitId, + })); + }; + const handleAddSlot = () => addSlotQuery.mutate(new Slot({ dayId: props.day.id!, order: props.day.slots.length + 1 @@ -279,28 +394,36 @@ export const DayDetails = (props: { ref={provided.innerRef} style={getListStyle(snapshot.isDraggingOver)} > - {props.day.slots.map((slot, index) => - { - addSlotEntryQuery.mutate(new SlotEntry({ - slotId: slot.id!, - exerciseId: exercise.id!, - type: 'normal', - order: slot.entries.length + 1, - weightUnitId: userProfileQuery.data!.useMetric ? WEIGHT_UNIT_KG : WEIGHT_UNIT_LB, - })); - setShowAutocompleterForSlot(null); - }} - /> + {groupSlotsByExercise(props.day.slots).map((group) => + + {group.slots.map(({ slot, originalIndex }, indexInGroup) => + { + addSlotEntryQuery.mutate(new SlotEntry({ + slotId: slot.id!, + exerciseId: exercise.id!, + type: 'normal', + order: slot.entries.length + 1, + weightUnitId: userProfileQuery.data!.useMetric ? WEIGHT_UNIT_KG : WEIGHT_UNIT_LB, + })); + setShowAutocompleterForSlot(null); + }} + /> + )} + )} {provided.placeholder} diff --git a/src/components/WorkoutRoutines/widgets/SlotDetails.test.tsx b/src/components/WorkoutRoutines/widgets/SlotDetails.test.tsx index 136d2f22..ec8ecd2a 100644 --- a/src/components/WorkoutRoutines/widgets/SlotDetails.test.tsx +++ b/src/components/WorkoutRoutines/widgets/SlotDetails.test.tsx @@ -69,4 +69,21 @@ describe('SlotDetails Component', () => { expect(screen.getByTestId('rest-field')).toBeInTheDocument(); expect(screen.getByTestId('max-rest-field')).toBeInTheDocument(); }); + + test('hides edit/delete icons and exercise name when isGrouped', () => { + render( + + + + ); + + // Config fields should still render + expect(screen.getByTestId('sets-field')).toBeInTheDocument(); + expect(screen.getByTestId('weight-field')).toBeInTheDocument(); + expect(screen.getByTestId('reps-field')).toBeInTheDocument(); + + // Edit/delete icons and exercise name should be hidden + expect(screen.queryByTestId('EditIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('DeleteOutlinedIcon')).not.toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/WorkoutRoutines/widgets/SlotDetails.tsx b/src/components/WorkoutRoutines/widgets/SlotDetails.tsx index 75ad3a2b..586eda90 100644 --- a/src/components/WorkoutRoutines/widgets/SlotDetails.tsx +++ b/src/components/WorkoutRoutines/widgets/SlotDetails.tsx @@ -54,7 +54,7 @@ const getConfigComponent = (type: ConfigType, configs: BaseConfig[], routineId: ; }; -export const SlotDetails = (props: { slot: Slot, routineId: number, simpleMode: boolean }) => { +export const SlotDetails = (props: { slot: Slot, routineId: number, simpleMode: boolean, isGrouped?: boolean }) => { const { t } = useTranslation(); return (<> @@ -72,6 +72,7 @@ export const SlotDetails = (props: { slot: Slot, routineId: number, simpleMode: simpleMode={props.simpleMode} index={index} total={props.slot.entries.length} + isGrouped={props.isGrouped} /> ))} ); @@ -82,7 +83,8 @@ export const SlotEntryDetails = (props: { routineId: number, simpleMode: boolean, index: number, - total: number + total: number, + isGrouped?: boolean, }) => { const { t, i18n } = useTranslation(); @@ -116,7 +118,8 @@ export const SlotEntryDetails = (props: { ? + size={{ xs: 12, sm: 2 }} + offset={props.isGrouped ? { sm: 4 } : undefined}> {getConfigComponent('sets', props.slotEntry.nrOfSetsConfigs, props.routineId, props.slotEntry.id!)} - + {getConfigComponent('sets', props.slotEntry.nrOfSetsConfigs, props.routineId, props.slotEntry.id!)} @@ -188,36 +192,35 @@ export const SlotEntryDetails = (props: { return ( ( - - {/**/} - {/* */} - {/**/} - - {editExercise ? : } - - deleteSlotEntryQuery.mutate(props.slotEntry.id!)} - disabled={isPending} - > - - - + {!props.isGrouped && <> + + + {editExercise ? : } + + deleteSlotEntryQuery.mutate(props.slotEntry.id!)} + disabled={isPending} + > + + + - - - {counter} {props.slotEntry.exercise?.getTranslation(language).name} - - + + + {counter} {props.slotEntry.exercise?.getTranslation(language).name} + + - {editExercise - && - - - - - - } + {editExercise + && + + + + + + } + } {props.slotEntry.hasProgressionRules ? diff --git a/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.test.tsx b/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.test.tsx index 6a1f8c94..3c530570 100644 --- a/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.test.tsx +++ b/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.test.tsx @@ -19,6 +19,7 @@ describe("DraggableSlotItem component", () => { simpleMode: true, showAutocompleter: false, onDelete: jest.fn(), + onDuplicate: jest.fn(), onAddSuperset: jest.fn(), addSupersetIsPending: false, onExerciseSelected: jest.fn(), diff --git a/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.tsx b/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.tsx index 58dd3cff..3113a8be 100644 --- a/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.tsx +++ b/src/components/WorkoutRoutines/widgets/slots/DraggableSlotItem.tsx @@ -16,17 +16,24 @@ export const DraggableSlotItem = (props: { simpleMode: boolean, showAutocompleter: boolean, onDelete: (slotId: number) => void, + onDuplicate: (slotId: number) => void, onAddSuperset: (slotId: number) => void, addSupersetIsPending: boolean, onExerciseSelected: (exercise: Exercise) => void, + groupSize?: number, + indexInGroup?: number, }) => { const theme = useTheme(); const grid = 8; + const isGrouped = props.groupSize !== undefined && props.groupSize > 1; + const isLastInGroup = isGrouped && props.indexInGroup === (props.groupSize! - 1); + const getItemStyle = (isDragging: boolean, draggableStyle: DraggableStyle) => ({ - border: isDragging ? `1px solid ${theme.palette.grey[900]}` : `1px solid ${theme.palette.grey[300]}`, + border: isGrouped ? 'none' : isDragging ? `1px solid ${theme.palette.grey[900]}` : `1px solid ${theme.palette.grey[300]}`, + borderBottom: isGrouped && !isLastInGroup ? `1px solid ${theme.palette.grey[200]}` : isGrouped ? 'none' : undefined, backgroundColor: "white", - marginBottom: grid, + marginBottom: isGrouped ? 0 : grid, ...draggableStyle }); @@ -48,8 +55,11 @@ export const DraggableSlotItem = (props: { dragHandleProps={provided.dragHandleProps} routineId={props.routineId} onDelete={props.onDelete} + onDuplicate={props.onDuplicate} onAddSuperset={props.onAddSuperset} addSupersetIsPending={props.addSupersetIsPending} + groupSize={props.groupSize} + indexInGroup={props.indexInGroup} /> {!props.simpleMode && diff --git a/src/components/WorkoutRoutines/widgets/slots/SlotHeader.test.tsx b/src/components/WorkoutRoutines/widgets/slots/SlotHeader.test.tsx index 10a03fbe..a269d339 100644 --- a/src/components/WorkoutRoutines/widgets/slots/SlotHeader.test.tsx +++ b/src/components/WorkoutRoutines/widgets/slots/SlotHeader.test.tsx @@ -31,6 +31,7 @@ describe("SlotHeader component", () => { dragHandleProps: null, routineId: 1, onDelete: jest.fn(), + onDuplicate: jest.fn(), onAddSuperset: jest.fn(), addSupersetIsPending: false, }; @@ -88,4 +89,17 @@ describe("SlotHeader component", () => { expect(onAddSuperset).toHaveBeenCalledWith(testDayLegs.slots[0].id); }); + + // grouped mode + test('renders "Set N" label when grouped', () => { + renderComponent({ groupSize: 3, indexInGroup: 1 }); + expect(screen.getByText('routines.setNr')).toBeInTheDocument(); + expect(screen.queryByText('routines.exerciseNr')).not.toBeInTheDocument(); + }); + + test('does not render add superset or add set buttons when grouped', () => { + renderComponent({ groupSize: 3, indexInGroup: 0 }); + expect(screen.queryByText('routines.addSuperset')).not.toBeInTheDocument(); + expect(screen.queryByText('routines.addSet')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/WorkoutRoutines/widgets/slots/SlotHeader.tsx b/src/components/WorkoutRoutines/widgets/slots/SlotHeader.tsx index f0ccafcc..0de7857e 100644 --- a/src/components/WorkoutRoutines/widgets/slots/SlotHeader.tsx +++ b/src/components/WorkoutRoutines/widgets/slots/SlotHeader.tsx @@ -1,6 +1,7 @@ import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; import { SsidChart } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteIcon from "@mui/icons-material/Delete"; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { @@ -24,12 +25,37 @@ export const SlotHeader = (props: { dragHandleProps: DraggableProvidedDragHandleProps | null | undefined, routineId: number, onDelete: (slotId: number) => void, + onDuplicate: (slotId: number) => void, onAddSuperset: (slotId: number) => void, addSupersetIsPending: boolean, + groupSize?: number, + indexInGroup?: number, }) => { const [t, i18n] = useTranslation(); const theme = useTheme(); + const isGrouped = props.groupSize !== undefined && props.groupSize > 1; + + if (isGrouped) { + return ( + + + + + + + + props.onDelete(props.slot.id!)}> + + + {t('routines.setNr', { number: (props.indexInGroup ?? 0) + 1 })} + + + + + ); + } + return ( props.onDelete(props.slot.id!)}> - {props.slot.entries.length > 1 ? t('routines.supersetNr', { number: props.index + 1 }) : t('routines.exerciseNr', { number: props.index + 1 })} + {props.slot.entries.length > 1 + ? t('routines.supersetNr', { number: props.index + 1 }) + : t('routines.exerciseNr', { number: props.index + 1 }) + } @@ -63,17 +92,25 @@ export const SlotHeader = (props: { {t('routines.addSuperset')} - {props.slot.entries.length > 0 && + + + {props.slot.entries.length === 1 && } }