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
2 changes: 2 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
125 changes: 123 additions & 2 deletions src/components/WorkoutRoutines/widgets/DayDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof userEvent.setup>;
Expand Down Expand Up @@ -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 })
);
});
});
});
169 changes: 146 additions & 23 deletions src/components/WorkoutRoutines/widgets/DayDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
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,
Stack,
Switch,
Tab,
Tabs,
Typography,
useTheme,
} from "@mui/material";
import Grid from '@mui/material/Grid';
import { LoadingPlaceholder, LoadingProgressIcon } from "components/Core/LoadingWidget/LoadingWidget";
Expand Down Expand Up @@ -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 (
<Box sx={{
border: `1px solid ${theme.palette.grey[300]}`,
backgroundColor: "white",
marginBottom: 1,
padding: 1,
}}>
<Grid container justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Grid>
<Typography variant={"h6"}>
{props.group.exerciseName}
</Typography>
</Grid>
<Grid>
<ButtonGroup variant="outlined">
<Button
onClick={() => props.onDuplicate(lastSlot.id!)}
size={"small"}
startIcon={<ContentCopyIcon />}
>
{t('routines.addSet')}
</Button>
</ButtonGroup>
</Grid>
</Grid>
{props.children}
</Box>
);
};


export const DayDetails = (props: {
day: Day,
routineId: number,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -279,28 +394,36 @@ export const DayDetails = (props: {
ref={provided.innerRef}
style={getListStyle(snapshot.isDraggingOver)}
>
{props.day.slots.map((slot, index) =>
<DraggableSlotItem
key={slot.id}
slot={slot}
index={index}
routineId={props.routineId}
simpleMode={simpleMode}
showAutocompleter={showAutocompleterForSlot === slot.id || slot.entries.length === 0}
onDelete={handleDeleteSlot}
onAddSuperset={handleAddSlotEntry}
addSupersetIsPending={addSlotEntryQuery.isPending}
onExerciseSelected={(exercise) => {
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) =>
<SlotGroupContainer key={group.slots[0].slot.id} group={group} routineId={props.routineId}
onDuplicate={handleDuplicateSlot}>
{group.slots.map(({ slot, originalIndex }, indexInGroup) =>
<DraggableSlotItem
key={slot.id}
slot={slot}
index={originalIndex}
routineId={props.routineId}
simpleMode={simpleMode}
showAutocompleter={showAutocompleterForSlot === slot.id || slot.entries.length === 0}
onDelete={handleDeleteSlot}
onDuplicate={handleDuplicateSlot}
onAddSuperset={handleAddSlotEntry}
addSupersetIsPending={addSlotEntryQuery.isPending}
groupSize={group.slots.length}
indexInGroup={indexInGroup}
onExerciseSelected={(exercise) => {
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);
}}
/>
)}
</SlotGroupContainer>
)}
{provided.placeholder}
</div>
Expand Down
17 changes: 17 additions & 0 deletions src/components/WorkoutRoutines/widgets/SlotDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<QueryClientProvider client={testQueryClient}>
<SlotDetails slot={testSlot} routineId={1} simpleMode={true} isGrouped={true} />
</QueryClientProvider>
);

// 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();
});
});
Loading
Loading