From 38feb943fde42ba26ba4bb3433bb2edce10e4a05 Mon Sep 17 00:00:00 2001 From: Armen Kocharyan <243963394+armen-kocharyan@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:42:22 +0400 Subject: [PATCH] feat: allow reordering exercises in the active workout Add Move up / Move down actions to each exercise's menu on the session screen, moving both recordedExercises and the blueprint's exercises in lockstep via new Session.withExerciseMovedUp/Down. --- .../workout/cardio/cardio-exercise.tsx | 4 ++ .../presentation/workout/exercise-section.tsx | 22 +++++++++ .../workout/weighted/weighted-exercise.tsx | 4 ++ .../components/smart/session-component.tsx | 16 ++++++ app/src/i18n/en.json | 2 + app/src/models/session-models/session.spec.ts | 49 +++++++++++++++++++ app/src/models/session-models/session.ts | 30 ++++++++++++ 7 files changed, 127 insertions(+) diff --git a/app/src/components/presentation/workout/cardio/cardio-exercise.tsx b/app/src/components/presentation/workout/cardio/cardio-exercise.tsx index 16a69a03a..9963a1886 100644 --- a/app/src/components/presentation/workout/cardio/cardio-exercise.tsx +++ b/app/src/components/presentation/workout/cardio/cardio-exercise.tsx @@ -37,6 +37,8 @@ interface CardioExerciseProps { onStartTimer: (setIndex: number) => void; onEditExercise: (() => void) | undefined; onRemoveExercise: () => void; + onMoveExerciseUp: (() => void) | undefined; + onMoveExerciseDown: (() => void) | undefined; } export function CardioExercise(props: CardioExerciseProps) { @@ -52,6 +54,8 @@ export function CardioExercise(props: CardioExerciseProps) { updateExercise={updateExercise} onEditExercise={props.onEditExercise} onRemoveExercise={props.onRemoveExercise} + onMoveExerciseUp={props.onMoveExerciseUp} + onMoveExerciseDown={props.onMoveExerciseDown} > {recordedExercise.sets.map((set, setIndex) => ( diff --git a/app/src/components/presentation/workout/exercise-section.tsx b/app/src/components/presentation/workout/exercise-section.tsx index 435dbb42a..132ae7ae1 100644 --- a/app/src/components/presentation/workout/exercise-section.tsx +++ b/app/src/components/presentation/workout/exercise-section.tsx @@ -27,6 +27,8 @@ interface ExerciseSectionProps { updateExercise: (update: Updater) => void; onEditExercise: (() => void) | undefined; onRemoveExercise: () => void; + onMoveExerciseUp: (() => void) | undefined; + onMoveExerciseDown: (() => void) | undefined; } export default function ExerciseSection(props: ExerciseSectionProps) { @@ -75,6 +77,26 @@ export default function ExerciseSection(props: Exerc } satisfies MenuItem, ] : []), + ...(props.onMoveExerciseUp + ? [ + { + label: t('exercise.move_up.button'), + icon: 'arrowUpward', + systemImage: 'arrow.up', + onPress: props.onMoveExerciseUp, + } satisfies MenuItem, + ] + : []), + ...(props.onMoveExerciseDown + ? [ + { + label: t('exercise.move_down.button'), + icon: 'arrowDownward', + systemImage: 'arrow.down', + onPress: props.onMoveExerciseDown, + } satisfies MenuItem, + ] + : []), { label: t('generic.notes.label'), icon: 'notes', diff --git a/app/src/components/presentation/workout/weighted/weighted-exercise.tsx b/app/src/components/presentation/workout/weighted/weighted-exercise.tsx index 787e4c2b3..cdbc75925 100644 --- a/app/src/components/presentation/workout/weighted/weighted-exercise.tsx +++ b/app/src/components/presentation/workout/weighted/weighted-exercise.tsx @@ -19,6 +19,8 @@ interface WeightedExerciseProps { resetSetTimer: () => void; onEditExercise: (() => void) | undefined; onRemoveExercise: () => void; + onMoveExerciseUp: (() => void) | undefined; + onMoveExerciseDown: (() => void) | undefined; } export default function WeightedExercise(props: WeightedExerciseProps) { @@ -38,6 +40,8 @@ export default function WeightedExercise(props: WeightedExerciseProps) { updateExercise={props.updateExercise} onEditExercise={props.onEditExercise} onRemoveExercise={props.onRemoveExercise} + onMoveExerciseUp={props.onMoveExerciseUp} + onMoveExerciseDown={props.onMoveExerciseDown} > {recordedExercise.potentialSets.map((set, index) => ( diff --git a/app/src/components/smart/session-component.tsx b/app/src/components/smart/session-component.tsx index 50757e4cb..ff120dd0c 100644 --- a/app/src/components/smart/session-component.tsx +++ b/app/src/components/smart/session-component.tsx @@ -151,6 +151,14 @@ export default function SessionComponent(props: { editableSessionId ? () => push(getSessionExerciseEditorHref(editableSessionId, index)) : undefined } onRemoveExercise={() => updateSession((s) => s.withRemovedExercise(index))} + onMoveExerciseUp={ + !isReadonly && index > 0 ? () => updateSession((s) => s.withExerciseMovedUp(index)) : undefined + } + onMoveExerciseDown={ + !isReadonly && index < session.recordedExercises.length - 1 + ? () => updateSession((s) => s.withExerciseMovedDown(index)) + : undefined + } isReadonly={isReadonly} showPreviousButton={!!isActiveWorkout} previousRecordedExercises={recentlyCompletedExercises(item.movementKey()) as RecordedWeightedExercise[]} @@ -169,6 +177,14 @@ export default function SessionComponent(props: { editableSessionId ? () => push(getSessionExerciseEditorHref(editableSessionId, index)) : undefined } onRemoveExercise={() => updateSession((s) => s.withRemovedExercise(index))} + onMoveExerciseUp={ + !isReadonly && index > 0 ? () => updateSession((s) => s.withExerciseMovedUp(index)) : undefined + } + onMoveExerciseDown={ + !isReadonly && index < session.recordedExercises.length - 1 + ? () => updateSession((s) => s.withExerciseMovedDown(index)) + : undefined + } isReadonly={isReadonly} showPreviousButton={!!isActiveWorkout} previousRecordedExercises={recentlyCompletedExercises(item.movementKey()) as RecordedCardioExercise[]} diff --git a/app/src/i18n/en.json b/app/src/i18n/en.json index b5d4968a3..680f9dea8 100644 --- a/app/src/i18n/en.json +++ b/app/src/i18n/en.json @@ -132,6 +132,8 @@ "exercise.manage.subtitle": "Manage your exercise list", "exercise.manage.title": "Manage Exercises", "exercise.name.label": "Exercise name", + "exercise.move_up.button": "Move up", + "exercise.move_down.button": "Move down", "exercise.description.cardio_set.body": "Set {setNumber} - {targetType}: {targetValue}", "exercise.never_done_before.message": "You have never done this exercise before", "exercise.no_exercises_added.message": "No exercises added yet", diff --git a/app/src/models/session-models/session.spec.ts b/app/src/models/session-models/session.spec.ts index 1ec668e2e..1bcb9c550 100644 --- a/app/src/models/session-models/session.spec.ts +++ b/app/src/models/session-models/session.spec.ts @@ -824,6 +824,55 @@ describe('Session structural mutations', () => { expect(result.recordedExercises[0]!.blueprint.name).toBe('Bench'); }); + describe('withExerciseMovedUp/Down', () => { + it('moving up keeps recordedExercises and blueprint exercises in lockstep', () => { + const session = makeSession([ + makeWeightedBlueprint({ name: 'Squat' }), + makeWeightedBlueprint({ name: 'Bench' }), + makeWeightedBlueprint({ name: 'Row' }), + ]); + + const result = session.withExerciseMovedUp(1); + + expect(result.recordedExercises.map((x) => x.blueprint.name)).toEqual(['Bench', 'Squat', 'Row']); + expect(result.blueprint.exercises.map((x) => x.name)).toEqual(['Bench', 'Squat', 'Row']); + }); + + it('moving down keeps recordedExercises and blueprint exercises in lockstep', () => { + const session = makeSession([ + makeWeightedBlueprint({ name: 'Squat' }), + makeWeightedBlueprint({ name: 'Bench' }), + makeWeightedBlueprint({ name: 'Row' }), + ]); + + const result = session.withExerciseMovedDown(1); + + expect(result.recordedExercises.map((x) => x.blueprint.name)).toEqual(['Squat', 'Row', 'Bench']); + expect(result.blueprint.exercises.map((x) => x.name)).toEqual(['Squat', 'Row', 'Bench']); + }); + + it('preserves the recorded state of the moved exercise', () => { + const session = makeSession([makeWeightedBlueprint({ name: 'Squat' }), makeWeightedBlueprint({ name: 'Bench' })]); + const moved = session.withExerciseMovedUp(1).recordedExercises[0] as RecordedWeightedExercise; + + expect(moved.potentialSets[0]!.set?.repsCompleted).toBe( + (session.recordedExercises[1] as RecordedWeightedExercise).potentialSets[0]!.set?.repsCompleted, + ); + }); + + it('moving the first exercise up is a no-op', () => { + const session = makeSession([makeWeightedBlueprint({ name: 'Squat' }), makeWeightedBlueprint({ name: 'Bench' })]); + + expect(session.withExerciseMovedUp(0)).toBe(session); + }); + + it('moving the last exercise down is a no-op', () => { + const session = makeSession([makeWeightedBlueprint({ name: 'Squat' }), makeWeightedBlueprint({ name: 'Bench' })]); + + expect(session.withExerciseMovedDown(1)).toBe(session); + }); + }); + it('withName renames the blueprint', () => { const session = makeSession([makeWeightedBlueprint()]); expect(session.withName('Push Day').blueprint.name).toBe('Push Day'); diff --git a/app/src/models/session-models/session.ts b/app/src/models/session-models/session.ts index 177de7b68..a6e8e4f94 100644 --- a/app/src/models/session-models/session.ts +++ b/app/src/models/session-models/session.ts @@ -318,6 +318,36 @@ export class Session { }); } + withExerciseMovedUp(exerciseIndex: number): Session { + return this.withExerciseMoved(exerciseIndex, exerciseIndex - 1); + } + + withExerciseMovedDown(exerciseIndex: number): Session { + return this.withExerciseMoved(exerciseIndex, exerciseIndex + 1); + } + + withExerciseMoved(exerciseIndex: number, newIndex: number): Session { + if ( + exerciseIndex < 0 || + exerciseIndex >= this.recordedExercises.length || + newIndex < 0 || + newIndex >= this.recordedExercises.length || + exerciseIndex === newIndex + ) { + return this; + } + // Remove first, then re-insert at the target index: since removal shifts nothing before it, + // inserting at newIndex lands the item at its final position for moves in either direction. + const move = (items: T[]): T[] => + items.toSpliced(exerciseIndex, 1).toSpliced(newIndex, 0, items[exerciseIndex]!); + return this.with({ + recordedExercises: move(this.recordedExercises), + blueprint: this.blueprint.with({ + exercises: move(this.blueprint.exercises), + }), + }); + } + toJSON(): SessionJSON { return { version: 7,