diff --git a/mobile/api/contract.ts b/mobile/api/contract.ts index 45a0f35..0ccde54 100644 --- a/mobile/api/contract.ts +++ b/mobile/api/contract.ts @@ -51,7 +51,7 @@ export interface UserContext { export interface WorkoutContext { getCurrentMicrocycle: (session: Session) => Promise getActivePlanSummary: (session: Session) => Promise - getWorkout: (session: Session) => Promise + getWorkout: (session: Session) => Promise<(MicrocycleWorkout & { progressionMode: ProgressionMode }) | null> getWorkoutStats: (session: Session) => Promise startWorkout: (session: Session) => Promise confirmMesocycle: (session: Session) => Promise @@ -91,7 +91,7 @@ export interface WorkoutContext { session: Session, workoutId: string, workingExerciseId: string, - exerciseAssesment: ExerciseAssesment + exerciseAssesment: ExerciseAssesment | null ) => Promise undoExercise: (session: Session, workoutId: string, workingExerciseId: string) => Promise exerciseSetStateChanged: ( @@ -107,6 +107,12 @@ export interface WorkoutContext { workingExerciseId: string, weight: number ) => Promise + exerciseChangeReps: ( + session: Session, + workoutId: string, + workingExerciseId: string, + reps: number + ) => Promise } export interface MesoPlannerContext { diff --git a/mobile/api/router.ts b/mobile/api/router.ts index c3e1be9..0729223 100644 --- a/mobile/api/router.ts +++ b/mobile/api/router.ts @@ -120,7 +120,7 @@ const workout = trpcInstance.router({ ) }), exerciseFinished: trpcProcedureAuthProcedure - .input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), exerciseAssesment })) + .input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), exerciseAssesment: exerciseAssesment.nullable() })) .mutation(async ({ ctx, input }) => { return await ctx.workout.exerciseFinished( ctx.session, @@ -157,6 +157,11 @@ const workout = trpcInstance.router({ .mutation(async ({ ctx, input }) => { return await ctx.workout.exerciseChangeWeight(ctx.session, input.workoutId, input.workingExerciseId, input.weight) }), + exerciseChangeReps: trpcProcedureAuthProcedure + .input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), reps: z.number() })) + .mutation(async ({ ctx, input }) => { + return await ctx.workout.exerciseChangeReps(ctx.session, input.workoutId, input.workingExerciseId, input.reps) + }), changeMicrocycle: trpcProcedureAuthProcedure .input(z.object({ template: microcycleWorkoutsTemplateWithExercisesSchema, progressionMode: z.nativeEnum(ProgressionMode) })) .mutation(async ({ ctx, input }) => { diff --git a/mobile/db/mesocycle.dao.ts b/mobile/db/mesocycle.dao.ts index 03c45c5..210d38a 100644 --- a/mobile/db/mesocycle.dao.ts +++ b/mobile/db/mesocycle.dao.ts @@ -77,6 +77,7 @@ const mapWorkoutExerciseToDTO = ( if (row.assesment === ExerciseAssesmentScore.Hard && row.hardAssesmentTag) { return { assesment: ExerciseAssesmentScore.Hard, assesmentTag: row.hardAssesmentTag } as const } + if (row.assesment === null) return null throw new Error('Exercise is missing assesment') } @@ -519,16 +520,18 @@ export const updateMesocycle = async (events: MesocycleEvent[]) => { .where(eq(schema.workoutExercise.id, event.payload.exerciseId)) }) .with({ type: 'ExerciseFinished' }, async event => { - const assessmentData = match(event.payload.exerciseAssesment) - .with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({ - assesment: assessment.assesment as string, - hardAssesmentTag: assessment.assesmentTag as string, - })) - .with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({ - assesment: assessment.assesment as string, - hardAssesmentTag: null, - })) - .exhaustive() + const assessmentData = event.payload.exerciseAssesment + ? match(event.payload.exerciseAssesment) + .with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({ + assesment: assessment.assesment as string, + hardAssesmentTag: assessment.assesmentTag as string, + })) + .with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({ + assesment: assessment.assesment as string, + hardAssesmentTag: null, + })) + .exhaustive() + : { assesment: null, hardAssesmentTag: null } await db .update(schema.workoutExercise) @@ -602,6 +605,17 @@ export const updateMesocycle = async (events: MesocycleEvent[]) => { .set({ testingWeight: event.payload.weight }) .where(eq(schema.workoutExercise.id, event.payload.workoutExerciseId)) }) + .with({ type: 'ExerciseRepsChanged' }, async event => { + await db + .update(schema.workoutExerciseSet) + .set({ reps: event.payload.reps }) + .where( + and( + eq(schema.workoutExerciseSet.workoutExerciseId, event.payload.workoutExerciseId), + eq(schema.workoutExerciseSet.state, WorkingSetState.pending) + ) + ) + }) .with({ type: 'ExerciseRepsChangedDueToWeightChange' }, async event => { await db .update(schema.workoutExerciseSet) diff --git a/mobile/db/user.dao.ts b/mobile/db/user.dao.ts index ca3cb32..c9fa98c 100644 --- a/mobile/db/user.dao.ts +++ b/mobile/db/user.dao.ts @@ -309,12 +309,12 @@ export const getPastTrainingResults = async () => { const workoutExercises = exercisesByWorkout.get(workout.id) ?? [] const finishedExercises = workoutExercises.filter(e => e.state === WorkoutExerciseState.finished) + const assessedExercises = finishedExercises.filter(e => e.assesment !== null) const exercisesFeelingAvg = - finishedExercises.reduce((acc, exercise) => { - if (!exercise.assesment) throw new Error('Illegal State') + assessedExercises.reduce((acc, exercise) => { return acc + assesmentTable[exercise.assesment as ExerciseAssesmentScore] - }, 0) / finishedExercises.length + }, 0) / assessedExercises.length const successRate = finishedExercises.reduce((acc, exercise) => { diff --git a/mobile/domain/aggregate/mesocycle.aggregate-root.ts b/mobile/domain/aggregate/mesocycle.aggregate-root.ts index 2c762c1..1871afc 100644 --- a/mobile/domain/aggregate/mesocycle.aggregate-root.ts +++ b/mobile/domain/aggregate/mesocycle.aggregate-root.ts @@ -148,7 +148,7 @@ export class MesocycleAggregateRoot { return this.mesocycleDTO.isConfirmed } - finishExercise(exerciseId: string, exerciseAssesment: ExerciseAssesment) { + finishExercise(exerciseId: string, exerciseAssesment: ExerciseAssesment | null) { const activeWorkout = this.getActiveWorkout() const exercise = activeWorkout.exercises.find(exercise => exercise.id === exerciseId) if (!exercise) { @@ -803,6 +803,21 @@ export class MesocycleAggregateRoot { targetReps: originalExercise.targetReps, } + if (this.mesocycleDTO.progressionMode === ProgressionMode.Custom) { + return { + ...replacingExerciseBase, + state: WorkoutExerciseState.pending, + progressionType: ProgressionType.CustomUserProvided, + sets: Array.from({ length: originalExercise.targetSets }).map((_, index) => ({ + id: v4(), + state: WorkingSetState.pending, + weight: null, + reps: null, + orderIndex: index, + })), + } + } + const historicalResult = replacingExercise.historicalResult if (historicalResult === null) { return { @@ -934,34 +949,36 @@ export class MesocycleAggregateRoot { }) if (exercise.state === WorkoutExerciseState.pending) { - if (!historicalResults) { - throw new Error('Historical result not found') - } - const newReps = calculateRepsFromLoadedExercise( - { - loadingSet: { - weight: historicalResults.loadedWeight, - reps: historicalResults.loadedReps, + if (this.mesocycleDTO.progressionMode !== ProgressionMode.Custom) { + if (!historicalResults) { + throw new Error('Historical result not found') + } + const newReps = calculateRepsFromLoadedExercise( + { + loadingSet: { + weight: historicalResults.loadedWeight, + reps: historicalResults.loadedReps, + }, + targetWeight: snappedWeight, }, - targetWeight: snappedWeight, - }, - this.getCurrentRpe() - ) + this.getCurrentRpe() + ) - if (newReps === null) { - throw new Error("Couldn't resolve new rep count.") - } + if (newReps === null) { + throw new Error("Couldn't resolve new rep count.") + } - if (newReps !== exercise.sets[0].reps) { - this.apply({ - type: 'ExerciseRepsChangedDueToWeightChange', - payload: { - workoutExerciseId, - newReps, - workoutId: activeWorkout.id, - microcycleId: activeWorkout.microcycleId, - }, - }) + if (newReps !== exercise.sets[0].reps) { + this.apply({ + type: 'ExerciseRepsChangedDueToWeightChange', + payload: { + workoutExerciseId, + newReps, + workoutId: activeWorkout.id, + microcycleId: activeWorkout.microcycleId, + }, + }) + } } this.apply({ @@ -1028,6 +1045,27 @@ export class MesocycleAggregateRoot { }) } + changeReps(workoutExerciseId: string, reps: number) { + const activeWorkout = this.getActiveWorkout() + const exercise = activeWorkout.exercises.find(exercise => exercise.id === workoutExerciseId) + if (!exercise) { + throw new Error('Exercise not found in the workout') + } + + if (exercise.state !== WorkoutExerciseState.pending) { + throw new Error('Cant change reps of non-pending exercise') + } + + this.apply({ + type: 'ExerciseUpdated', + payload: { workoutExerciseId, workoutId: activeWorkout.id, microcycleId: activeWorkout.microcycleId }, + }) + this.apply({ + type: 'ExerciseRepsChanged', + payload: { workoutExerciseId, reps, workoutId: activeWorkout.id, microcycleId: activeWorkout.microcycleId }, + }) + } + setStateHasChanged(workoutExerciseId: string, setId: string, state: WorkingSetState) { const activeWorkout = this.getActiveWorkout() @@ -1463,16 +1501,18 @@ export class MesocycleAggregateRoot { ...workout, exercises: workout.exercises.map(exercise => { if (exercise.id === event.payload.exerciseId) { - const assessmentData = match(event.payload.exerciseAssesment) - .with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({ - assesment: assessment.assesment, - hardAssesmentTag: assessment.assesmentTag, - })) - .with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({ - assesment: assessment.assesment, - hardAssesmentTag: null, - })) - .exhaustive() + const assessmentData = event.payload.exerciseAssesment + ? match(event.payload.exerciseAssesment) + .with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({ + assesment: assessment.assesment, + hardAssesmentTag: assessment.assesmentTag, + })) + .with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({ + assesment: assessment.assesment, + hardAssesmentTag: null, + })) + .exhaustive() + : { assesment: null, hardAssesmentTag: null } return { ...exercise, @@ -1521,6 +1561,27 @@ export class MesocycleAggregateRoot { } as MicrocycleWorkout }) }) + .with({ type: 'ExerciseRepsChanged' }, event => { + updateMicrocycleWorkout(event.payload.microcycleId, event.payload.workoutId, workout => { + return { + ...workout, + exercises: workout.exercises.map(exercise => { + if (exercise.id === event.payload.workoutExerciseId) { + return { + ...exercise, + sets: exercise.sets.map(set => { + if (set.state === WorkingSetState.pending) { + return { ...set, reps: event.payload.reps } + } + return set + }), + } + } + return exercise + }), + } as MicrocycleWorkout + }) + }) .with({ type: 'ExerciseRepsChangedDueToWeightChange' }, event => { updateMicrocycleWorkout(event.payload.microcycleId, event.payload.workoutId, workout => { return { diff --git a/mobile/domain/aggregate/mesocycle.events.ts b/mobile/domain/aggregate/mesocycle.events.ts index b283e33..7f7455a 100644 --- a/mobile/domain/aggregate/mesocycle.events.ts +++ b/mobile/domain/aggregate/mesocycle.events.ts @@ -96,7 +96,7 @@ export interface ExerciseFinished { workoutId: string exerciseId: string when: string - exerciseAssesment: ExerciseAssesment + exerciseAssesment: ExerciseAssesment | null } } @@ -150,6 +150,16 @@ export interface ExerciseWeightChangedCalibration { } } +export interface ExerciseRepsChanged { + type: 'ExerciseRepsChanged' + payload: { + workoutExerciseId: string + reps: number + workoutId: string + microcycleId: string + } +} + export interface ExerciseRepsChangedDueToWeightChange { type: 'ExerciseRepsChangedDueToWeightChange' payload: { @@ -237,6 +247,7 @@ export type MesocycleEvent = | ExerciseWeightChangedTesting | ExerciseWeightChangedPending | ExerciseWeightChangedCalibration + | ExerciseRepsChanged | ExerciseRepsChangedDueToWeightChange | WorkoutStarted | ExerciseUpdated diff --git a/mobile/domain/progression/get-progression-type.ts b/mobile/domain/progression/get-progression-type.ts index 75acc75..3481573 100644 --- a/mobile/domain/progression/get-progression-type.ts +++ b/mobile/domain/progression/get-progression-type.ts @@ -39,6 +39,7 @@ export const getProgressionType = ( } if ( + lastPastExercise.exerciseAssesment && lastPastExercise.exerciseAssesment.assesment === ExerciseAssesmentScore.Hard && lastPastExercise.exerciseAssesment.assesmentTag === HardAssesmentTag.TooHeavy ) { diff --git a/mobile/domain/working-exercise.ts b/mobile/domain/working-exercise.ts index 916342c..fefa11e 100644 --- a/mobile/domain/working-exercise.ts +++ b/mobile/domain/working-exercise.ts @@ -134,7 +134,7 @@ export type ExerciseAssesment = z.infer export const finishedWorkingExerciseSchema = workingExerciseBaseSchema.extend({ state: z.literal(WorkoutExerciseState.finished), sets: z.array(workingSetSchema), - exerciseAssesment, + exerciseAssesment: exerciseAssesment.nullable(), }) export type FinishedWorkingExercise = z.infer diff --git a/mobile/local-context/workout-context.local.ts b/mobile/local-context/workout-context.local.ts index c304c72..e81c9e9 100644 --- a/mobile/local-context/workout-context.local.ts +++ b/mobile/local-context/workout-context.local.ts @@ -57,7 +57,7 @@ export const createLocalWorkoutContext = (): WorkoutContext => { const mesocycle = new MesocycleAggregateRoot(mesocycleDto, getUserCoefficient(session)) if (mesocycle.hasActiveWorkout()) { - return mesocycle.getActiveWorkout() + return { ...mesocycle.getActiveWorkout(), progressionMode: mesocycleDto.progressionMode } } return null } @@ -234,6 +234,19 @@ export const createLocalWorkoutContext = (): WorkoutContext => { await mesocycleDao.updateMesocycle(mesocycle.events) } + const exerciseChangeReps: WorkoutContext['exerciseChangeReps'] = async ( + session, + workoutId, + workoutExerciseId, + reps + ) => { + const mesocycleId = await mesocycleDao.getMesocycleIdByWorkoutId(workoutId) + const mesocycleDto = await mesocycleDao.getMesocycleById(mesocycleId) + const mesocycle = new MesocycleAggregateRoot(mesocycleDto, getUserCoefficient(session)) + mesocycle.changeReps(workoutExerciseId, reps) + await mesocycleDao.updateMesocycle(mesocycle.events) + } + const exerciseFinished: WorkoutContext['exerciseFinished'] = async ( session, workoutId, @@ -376,6 +389,7 @@ export const createLocalWorkoutContext = (): WorkoutContext => { replaceExercise, exerciseSetStateChanged, exerciseChangeWeight, + exerciseChangeReps, exerciseFinished, undoExercise, exerciseLoaded, diff --git a/mobile/ui/workout/components/exercise-card.tsx b/mobile/ui/workout/components/exercise-card.tsx index 8789348..a830fa2 100644 --- a/mobile/ui/workout/components/exercise-card.tsx +++ b/mobile/ui/workout/components/exercise-card.tsx @@ -43,7 +43,7 @@ export const ExerciseCard = ({ exerciseIndex, active }: { exerciseIndex: number; }, [exerciseIndex, workoutContext]) const onMoveNextAfterPending = useCallback( - (exerciseAssesment: ExerciseAssesment) => { + (exerciseAssesment: ExerciseAssesment | null) => { workoutContext.exerciseDone(exerciseIndex, exerciseAssesment) }, [exerciseIndex, workoutContext] @@ -66,6 +66,13 @@ export const ExerciseCard = ({ exerciseIndex, active }: { exerciseIndex: number; [workoutContext] ) + const onRepsChanged = useCallback( + (exerciseId: string, newReps: number) => { + workoutContext.changeReps(exerciseId, newReps) + }, + [workoutContext] + ) + const onSetChanged = useCallback( (setId: string, state: WorkingSetState) => { workoutContext.exerciseSetStateChanged(exercise.id, setId, state) @@ -118,8 +125,10 @@ export const ExerciseCard = ({ exerciseIndex, active }: { exerciseIndex: number; onExtraActions={onExtraActions} hasMoreExercises={hasMoreExercises} onWeightChanged={onWeightChanged} + onRepsChanged={onRepsChanged} onSetChanged={onSetChanged} unit={workoutContext.unit} + progressionMode={workoutContext.workout.progressionMode} /> )) .with({ state: WorkoutExerciseState.finished }, exercise => ( diff --git a/mobile/ui/workout/components/exercise-pending.tsx b/mobile/ui/workout/components/exercise-pending.tsx index 4026eba..248dc7b 100644 --- a/mobile/ui/workout/components/exercise-pending.tsx +++ b/mobile/ui/workout/components/exercise-pending.tsx @@ -3,6 +3,7 @@ import { ExerciseAssesmentScore, HardAssesmentTag, PendingWorkingExercise, + ProgressionMode, Unit, WorkingSetState, } from '@/mobile/domain' @@ -19,6 +20,7 @@ import { } from 'react-native' import { PrimaryButton } from '@/mobile/ui/ds/buttons' +import { NumericalInput } from '@/mobile/ui/ds/inputs' import { CycleProgressCircle } from '@/mobile/ui/workout/components/ux/cycle-progress-circle' import { HardAssessmentModal } from '@/mobile/ui/workout/components/ux/hard-assessment-modal' import { IncompleteSetsModal } from '@/mobile/ui/workout/components/ux/incomplete-sets-modal' @@ -28,6 +30,8 @@ import { Checkbox } from '@/mobile/ui/ds/controls' import { CardTitle } from '@/mobile/ui/ds/typography' import CogwheelFilled from '@/mobile/ui/icons/cogwheel-filled' +const parseDecimal = (value: string) => parseFloat(value.replace(',', '.')) + const CARD_PADDING = 18 const styles = StyleSheet.create({ @@ -114,6 +118,22 @@ const styles = StyleSheet.create({ fontFamily: theme.font.sairaBold, color: theme.colors.primary.main, }, + inputWrapper: { + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.23)', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + flexDirection: 'row', + flex: 1, + }, + inputLabel: { + color: theme.colors.text.primary, + lineHeight: 24, + letterSpacing: 0.15, + fontSize: 16, + fontFamily: theme.font.sairaRegular, + }, }) export const ExercisePending = (props: { @@ -121,15 +141,34 @@ export const ExercisePending = (props: { pendingExercise: PendingWorkingExercise hasMoreExercises: boolean onSkipped: (pendingExerciseId: string, newExerciseId: string | null) => void - onNext: (exerciseAssesment: ExerciseAssesment) => void + onNext: (exerciseAssesment: ExerciseAssesment | null) => void onExtraActions: () => void onSetChanged: (setId: string, state: WorkingSetState) => void onWeightChanged: (exerciseId: string, newWeight: number) => void + onRepsChanged: (exerciseId: string, newReps: number) => void unit: Unit + progressionMode: ProgressionMode }) => { const { pendingExercise } = props const [showIncompleteSetsModal, setShowIncompleteSetsModal] = useState(false) const [showHardAssessmentModal, setShowHardAssessmentModal] = useState(false) + const [customWeight, setCustomWeight] = useState('') + const [customReps, setCustomReps] = useState('') + + const isCustomMode = props.progressionMode === ProgressionMode.Custom + const hasBlankSets = pendingExercise.sets.some(set => set.state === WorkingSetState.pending && (set.weight === null || set.reps === null)) + const showCustomInputs = isCustomMode && hasBlankSets + + const handleCustomConfirm = () => { + const parsedWeight = parseDecimal(customWeight) + const parsedReps = parseInt(customReps, 10) + if (!isNaN(parsedWeight) && parsedWeight > 0) { + props.onWeightChanged(pendingExercise.id, parsedWeight) + } + if (!isNaN(parsedReps) && parsedReps > 0) { + props.onRepsChanged(pendingExercise.id, parsedReps) + } + } const { data: cycleProgress } = trpc.workout.getCycleProgress.useQuery( { @@ -168,17 +207,18 @@ export const ExercisePending = (props: { const hasFailedSets = failedSets.length > 0 const handleNextButtonPress = () => { + if (props.progressionMode === ProgressionMode.Custom) { + props.onNext(null) + return + } + if (allSetsCompleted) { - // All sets are completed, check if any are failed if (hasFailedSets) { - // Some sets failed, show hard assessment modal setShowHardAssessmentModal(true) } else { - // All sets completed successfully props.onNext({ assesment: ExerciseAssesmentScore.Ideal }) } } else { - // Some sets are still pending, show confirmation modal setShowIncompleteSetsModal(true) } } @@ -253,70 +293,118 @@ export const ExercisePending = (props: { )} - - {pendingExercise.sets.map((set, index) => { - const textStyle = set.state !== 'pending' ? styles.completedSetText : undefined - - return ( - - - - {set.reps ?? '–'} reps × {formatWeight(set.weight)} {props.unit === 'metric' ? 'kg' : 'lbs'} - - - - handleSetFailed(set.id)} - label="Failed" - color={ - set.state === WorkingSetState.pending - ? theme.colors.primary.main - : theme.colors.text.primary - } + {showCustomInputs ? ( + <> + + + {pendingExercise.sets.length} SETS — ENTER YOUR WEIGHT AND REPS + + + + - handleSetDone(set.id)} - label="Done" - color={ - set.state === WorkingSetState.pending - ? theme.colors.primary.main - : theme.colors.text.primary - } + {props.unit === 'metric' ? 'kg' : 'lbs'} + + + + reps - ) - })} - - - - + + + + + + ) : ( + <> + + {pendingExercise.sets.map((set, index) => { + const textStyle = set.state !== 'pending' ? styles.completedSetText : undefined + + return ( + + + + {set.reps ?? '–'} reps × {formatWeight(set.weight)} {props.unit === 'metric' ? 'kg' : 'lbs'} + + + + handleSetFailed(set.id)} + label="Failed" + color={ + set.state === WorkingSetState.pending + ? theme.colors.primary.main + : theme.colors.text.primary + } + /> + handleSetDone(set.id)} + label="Done" + color={ + set.state === WorkingSetState.pending + ? theme.colors.primary.main + : theme.colors.text.primary + } + /> + + + ) + })} + + + + + + )} diff --git a/mobile/ui/workout/components/ux/adjust-exercise-overlay.tsx b/mobile/ui/workout/components/ux/adjust-exercise-overlay.tsx index fec460b..f1dd82f 100644 --- a/mobile/ui/workout/components/ux/adjust-exercise-overlay.tsx +++ b/mobile/ui/workout/components/ux/adjust-exercise-overlay.tsx @@ -357,10 +357,6 @@ export const AdjustExerciseOverlay = ({ } const renderMainContent = () => { - if (showWeightInput) { - return renderWeightAdjustment() - } - if (showReplaceExercise) { return ( diff --git a/mobile/ui/workout/hooks/use-workout-context.ts b/mobile/ui/workout/hooks/use-workout-context.ts index 2f9de41..0045ff0 100644 --- a/mobile/ui/workout/hooks/use-workout-context.ts +++ b/mobile/ui/workout/hooks/use-workout-context.ts @@ -7,6 +7,7 @@ import { MicrocycleWorkout, OnboardedUser, PendingWorkingExercise, + ProgressionMode, TestedWorkingExercise, Unit, WorkingExercise, @@ -27,14 +28,15 @@ export enum WorkoutLifestyleFeedbackModal { } interface WorkoutContextType { - workout: MicrocycleWorkout + workout: MicrocycleWorkout & { progressionMode: ProgressionMode } exerciseLoaded: (loadingSet: LoadingSet, exerciseIndex: number, reachedFailure: boolean) => void exerciseTested: (loadingSet: LoadingSet, exerciseIndex: number) => void testingSetsExerciseCompleted: (exerciseIndex: number) => void - exerciseDone: (exerciseIndex: number, exerciseAssesment: ExerciseAssesment) => void + exerciseDone: (exerciseIndex: number, exerciseAssesment: ExerciseAssesment | null) => void skipExercise: (pendingExerciseId: string, newExerciseId: string | null) => Promise finishWorkout: () => void changeWeight: (exerciseId: string, newWeight: number) => void + changeReps: (exerciseId: string, newReps: number) => void exerciseSetStateChanged: (workingExerciseId: string, setId: string, state: WorkingSetState) => void undoFinishExercise: (exerciseId: string) => void isWorkoutFinished: boolean @@ -46,13 +48,14 @@ interface WorkoutContextType { export const WorkoutContext = React.createContext(null) export const useCreateWorkoutContext = ( - workout: MicrocycleWorkout, + workout: MicrocycleWorkout & { progressionMode: ProgressionMode }, onboardingInfo: OnboardedUser, pagerRef: React.RefObject ): WorkoutContextType => { const tracking = useTracking() const { mutateAsync: exerciseSetStateChangedMutation } = trpc.workout.exerciseSetStateChanged.useMutation() const { mutateAsync: changeWeightMutation } = trpc.workout.exerciseChangeWeight.useMutation() + const { mutateAsync: changeRepsMutation } = trpc.workout.exerciseChangeReps.useMutation() const { mutateAsync: exerciseFinishedMutation } = trpc.workout.exerciseFinished.useMutation() const { mutateAsync: replaceExerciseMutation } = trpc.workout.replaceExercise.useMutation() const { mutateAsync: finishWorkoutMutation } = trpc.workout.finishWorkout.useMutation() @@ -121,6 +124,11 @@ export const useCreateWorkoutContext = ( ) const finishWorkoutAndProvideFeedbackIfNeeded = useCallback(async () => { + if (workout.progressionMode === ProgressionMode.Custom) { + await finishWorkoutInternal() + return + } + if (isAnyExerciseHard) { setLifestyleFeedbackModal(WorkoutLifestyleFeedbackModal.HardSets) } else if (isAnyFailedSets) { @@ -128,7 +136,7 @@ export const useCreateWorkoutContext = ( } else { await finishWorkoutInternal() } - }, [isAnyExerciseHard, isAnyFailedSets, finishWorkoutInternal]) + }, [workout.progressionMode, isAnyExerciseHard, isAnyFailedSets, finishWorkoutInternal]) const goToNextExercise = useCallback( async (ignoreExerciseId: string) => { @@ -194,7 +202,7 @@ export const useCreateWorkoutContext = ( ) const exerciseDone = useCallback( - async (exerciseIndex: number, exerciseAssesment: ExerciseAssesment) => { + async (exerciseIndex: number, exerciseAssesment: ExerciseAssesment | null) => { const workingExercise = workout.exercises[exerciseIndex] await exerciseFinishedMutation({ @@ -303,6 +311,48 @@ export const useCreateWorkoutContext = ( [changeWeightMutation, trpcUtils.workout.getWorkout, workout.id] ) + const changeReps = useCallback( + async (workingExerciseId: string, reps: number) => { + trpcUtils.workout.getWorkout.setData(undefined, workout => { + if (!workout) { + return workout + } + + return { + ...workout, + exercises: workout.exercises.map(exercise => { + if (exercise.id === workingExerciseId) { + if (exercise.state === WorkoutExerciseState.pending) { + return { + ...exercise, + sets: exercise.sets.map(set => { + if (set.state === WorkingSetState.pending) { + return { ...set, reps } + } + return set + }), + } + } + } + return exercise + }), + } + }) + + try { + await changeRepsMutation({ + workingExerciseId, + workoutId: workout.id, + reps, + }) + } catch (error) { + await trpcUtils.workout.getWorkout.invalidate() + throw error + } + }, + [changeRepsMutation, trpcUtils.workout.getWorkout, workout.id] + ) + const exerciseSetStateChanged = useCallback( async (workingExerciseId: string, setId: string, state: WorkingSetState) => { // Optimistically update the UI immediately @@ -386,6 +436,7 @@ export const useCreateWorkoutContext = ( exerciseSetStateChanged, undoFinishExercise, changeWeight, + changeReps, workout, lifestyleFeedbackModal, onLifestyleFeedbackConfirm, diff --git a/mobile/ui/workout/workout-stack.tsx b/mobile/ui/workout/workout-stack.tsx index 585f059..611c321 100644 --- a/mobile/ui/workout/workout-stack.tsx +++ b/mobile/ui/workout/workout-stack.tsx @@ -1,4 +1,4 @@ -import { MicrocycleWorkout, OnboardedUser } from '@/mobile/domain' +import { MicrocycleWorkout, OnboardedUser, ProgressionMode } from '@/mobile/domain' import React, { useCallback, useRef, useState } from 'react' import { Dimensions, StyleSheet, View } from 'react-native' import PagerView, { type PagerViewOnPageSelectedEvent } from 'react-native-pager-view' @@ -18,7 +18,7 @@ import { theme } from '@/mobile/theme/theme' import { trpc } from '@/mobile/trpc' import { EmptyWrapper } from '@/mobile/ui/components/empty-wrapper' -const WorkoutSwiperWithFetchedWorkout = (props: { workout: MicrocycleWorkout; onboardingInfo: OnboardedUser }) => { +const WorkoutSwiperWithFetchedWorkout = (props: { workout: MicrocycleWorkout & { progressionMode: ProgressionMode }; onboardingInfo: OnboardedUser }) => { const [activeIndex, setActiveIndex] = useState(0) const pagerRef = useRef(null) const workoutContext = useCreateWorkoutContext(props.workout, props.onboardingInfo, pagerRef)