Skip to content

Commit 3c4fa29

Browse files
authored
Merge pull request #53 from tomkis/feat/custom-progression-mode
feat: custom mode user-controlled weight/reps (PRD #51 US2)
2 parents 9c6601a + 16cf923 commit 3c4fa29

14 files changed

Lines changed: 392 additions & 132 deletions

mobile/api/contract.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface UserContext {
5151
export interface WorkoutContext {
5252
getCurrentMicrocycle: (session: Session) => Promise<Microcycle | null>
5353
getActivePlanSummary: (session: Session) => Promise<ActivePlanSummary | null>
54-
getWorkout: (session: Session) => Promise<MicrocycleWorkout | null>
54+
getWorkout: (session: Session) => Promise<(MicrocycleWorkout & { progressionMode: ProgressionMode }) | null>
5555
getWorkoutStats: (session: Session) => Promise<WorkoutStats | null>
5656
startWorkout: (session: Session) => Promise<void>
5757
confirmMesocycle: (session: Session) => Promise<void>
@@ -91,7 +91,7 @@ export interface WorkoutContext {
9191
session: Session,
9292
workoutId: string,
9393
workingExerciseId: string,
94-
exerciseAssesment: ExerciseAssesment
94+
exerciseAssesment: ExerciseAssesment | null
9595
) => Promise<void>
9696
undoExercise: (session: Session, workoutId: string, workingExerciseId: string) => Promise<void>
9797
exerciseSetStateChanged: (
@@ -107,6 +107,12 @@ export interface WorkoutContext {
107107
workingExerciseId: string,
108108
weight: number
109109
) => Promise<void>
110+
exerciseChangeReps: (
111+
session: Session,
112+
workoutId: string,
113+
workingExerciseId: string,
114+
reps: number
115+
) => Promise<void>
110116
}
111117

112118
export interface MesoPlannerContext {

mobile/api/router.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ const workout = trpcInstance.router({
120120
)
121121
}),
122122
exerciseFinished: trpcProcedureAuthProcedure
123-
.input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), exerciseAssesment }))
123+
.input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), exerciseAssesment: exerciseAssesment.nullable() }))
124124
.mutation(async ({ ctx, input }) => {
125125
return await ctx.workout.exerciseFinished(
126126
ctx.session,
@@ -157,6 +157,11 @@ const workout = trpcInstance.router({
157157
.mutation(async ({ ctx, input }) => {
158158
return await ctx.workout.exerciseChangeWeight(ctx.session, input.workoutId, input.workingExerciseId, input.weight)
159159
}),
160+
exerciseChangeReps: trpcProcedureAuthProcedure
161+
.input(z.object({ workoutId: z.string(), workingExerciseId: z.string(), reps: z.number() }))
162+
.mutation(async ({ ctx, input }) => {
163+
return await ctx.workout.exerciseChangeReps(ctx.session, input.workoutId, input.workingExerciseId, input.reps)
164+
}),
160165
changeMicrocycle: trpcProcedureAuthProcedure
161166
.input(z.object({ template: microcycleWorkoutsTemplateWithExercisesSchema, progressionMode: z.nativeEnum(ProgressionMode) }))
162167
.mutation(async ({ ctx, input }) => {

mobile/db/mesocycle.dao.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const mapWorkoutExerciseToDTO = (
7777
if (row.assesment === ExerciseAssesmentScore.Hard && row.hardAssesmentTag) {
7878
return { assesment: ExerciseAssesmentScore.Hard, assesmentTag: row.hardAssesmentTag } as const
7979
}
80+
if (row.assesment === null) return null
8081
throw new Error('Exercise is missing assesment')
8182
}
8283

@@ -519,16 +520,18 @@ export const updateMesocycle = async (events: MesocycleEvent[]) => {
519520
.where(eq(schema.workoutExercise.id, event.payload.exerciseId))
520521
})
521522
.with({ type: 'ExerciseFinished' }, async event => {
522-
const assessmentData = match(event.payload.exerciseAssesment)
523-
.with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({
524-
assesment: assessment.assesment as string,
525-
hardAssesmentTag: assessment.assesmentTag as string,
526-
}))
527-
.with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({
528-
assesment: assessment.assesment as string,
529-
hardAssesmentTag: null,
530-
}))
531-
.exhaustive()
523+
const assessmentData = event.payload.exerciseAssesment
524+
? match(event.payload.exerciseAssesment)
525+
.with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({
526+
assesment: assessment.assesment as string,
527+
hardAssesmentTag: assessment.assesmentTag as string,
528+
}))
529+
.with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({
530+
assesment: assessment.assesment as string,
531+
hardAssesmentTag: null,
532+
}))
533+
.exhaustive()
534+
: { assesment: null, hardAssesmentTag: null }
532535

533536
await db
534537
.update(schema.workoutExercise)
@@ -602,6 +605,17 @@ export const updateMesocycle = async (events: MesocycleEvent[]) => {
602605
.set({ testingWeight: event.payload.weight })
603606
.where(eq(schema.workoutExercise.id, event.payload.workoutExerciseId))
604607
})
608+
.with({ type: 'ExerciseRepsChanged' }, async event => {
609+
await db
610+
.update(schema.workoutExerciseSet)
611+
.set({ reps: event.payload.reps })
612+
.where(
613+
and(
614+
eq(schema.workoutExerciseSet.workoutExerciseId, event.payload.workoutExerciseId),
615+
eq(schema.workoutExerciseSet.state, WorkingSetState.pending)
616+
)
617+
)
618+
})
605619
.with({ type: 'ExerciseRepsChangedDueToWeightChange' }, async event => {
606620
await db
607621
.update(schema.workoutExerciseSet)

mobile/db/user.dao.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,12 +309,12 @@ export const getPastTrainingResults = async () => {
309309

310310
const workoutExercises = exercisesByWorkout.get(workout.id) ?? []
311311
const finishedExercises = workoutExercises.filter(e => e.state === WorkoutExerciseState.finished)
312+
const assessedExercises = finishedExercises.filter(e => e.assesment !== null)
312313

313314
const exercisesFeelingAvg =
314-
finishedExercises.reduce((acc, exercise) => {
315-
if (!exercise.assesment) throw new Error('Illegal State')
315+
assessedExercises.reduce((acc, exercise) => {
316316
return acc + assesmentTable[exercise.assesment as ExerciseAssesmentScore]
317-
}, 0) / finishedExercises.length
317+
}, 0) / assessedExercises.length
318318

319319
const successRate =
320320
finishedExercises.reduce((acc, exercise) => {

mobile/domain/aggregate/mesocycle.aggregate-root.ts

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export class MesocycleAggregateRoot {
148148
return this.mesocycleDTO.isConfirmed
149149
}
150150

151-
finishExercise(exerciseId: string, exerciseAssesment: ExerciseAssesment) {
151+
finishExercise(exerciseId: string, exerciseAssesment: ExerciseAssesment | null) {
152152
const activeWorkout = this.getActiveWorkout()
153153
const exercise = activeWorkout.exercises.find(exercise => exercise.id === exerciseId)
154154
if (!exercise) {
@@ -803,6 +803,21 @@ export class MesocycleAggregateRoot {
803803
targetReps: originalExercise.targetReps,
804804
}
805805

806+
if (this.mesocycleDTO.progressionMode === ProgressionMode.Custom) {
807+
return {
808+
...replacingExerciseBase,
809+
state: WorkoutExerciseState.pending,
810+
progressionType: ProgressionType.CustomUserProvided,
811+
sets: Array.from({ length: originalExercise.targetSets }).map((_, index) => ({
812+
id: v4(),
813+
state: WorkingSetState.pending,
814+
weight: null,
815+
reps: null,
816+
orderIndex: index,
817+
})),
818+
}
819+
}
820+
806821
const historicalResult = replacingExercise.historicalResult
807822
if (historicalResult === null) {
808823
return {
@@ -934,34 +949,36 @@ export class MesocycleAggregateRoot {
934949
})
935950

936951
if (exercise.state === WorkoutExerciseState.pending) {
937-
if (!historicalResults) {
938-
throw new Error('Historical result not found')
939-
}
940-
const newReps = calculateRepsFromLoadedExercise(
941-
{
942-
loadingSet: {
943-
weight: historicalResults.loadedWeight,
944-
reps: historicalResults.loadedReps,
952+
if (this.mesocycleDTO.progressionMode !== ProgressionMode.Custom) {
953+
if (!historicalResults) {
954+
throw new Error('Historical result not found')
955+
}
956+
const newReps = calculateRepsFromLoadedExercise(
957+
{
958+
loadingSet: {
959+
weight: historicalResults.loadedWeight,
960+
reps: historicalResults.loadedReps,
961+
},
962+
targetWeight: snappedWeight,
945963
},
946-
targetWeight: snappedWeight,
947-
},
948-
this.getCurrentRpe()
949-
)
964+
this.getCurrentRpe()
965+
)
950966

951-
if (newReps === null) {
952-
throw new Error("Couldn't resolve new rep count.")
953-
}
967+
if (newReps === null) {
968+
throw new Error("Couldn't resolve new rep count.")
969+
}
954970

955-
if (newReps !== exercise.sets[0].reps) {
956-
this.apply({
957-
type: 'ExerciseRepsChangedDueToWeightChange',
958-
payload: {
959-
workoutExerciseId,
960-
newReps,
961-
workoutId: activeWorkout.id,
962-
microcycleId: activeWorkout.microcycleId,
963-
},
964-
})
971+
if (newReps !== exercise.sets[0].reps) {
972+
this.apply({
973+
type: 'ExerciseRepsChangedDueToWeightChange',
974+
payload: {
975+
workoutExerciseId,
976+
newReps,
977+
workoutId: activeWorkout.id,
978+
microcycleId: activeWorkout.microcycleId,
979+
},
980+
})
981+
}
965982
}
966983

967984
this.apply({
@@ -1028,6 +1045,27 @@ export class MesocycleAggregateRoot {
10281045
})
10291046
}
10301047

1048+
changeReps(workoutExerciseId: string, reps: number) {
1049+
const activeWorkout = this.getActiveWorkout()
1050+
const exercise = activeWorkout.exercises.find(exercise => exercise.id === workoutExerciseId)
1051+
if (!exercise) {
1052+
throw new Error('Exercise not found in the workout')
1053+
}
1054+
1055+
if (exercise.state !== WorkoutExerciseState.pending) {
1056+
throw new Error('Cant change reps of non-pending exercise')
1057+
}
1058+
1059+
this.apply({
1060+
type: 'ExerciseUpdated',
1061+
payload: { workoutExerciseId, workoutId: activeWorkout.id, microcycleId: activeWorkout.microcycleId },
1062+
})
1063+
this.apply({
1064+
type: 'ExerciseRepsChanged',
1065+
payload: { workoutExerciseId, reps, workoutId: activeWorkout.id, microcycleId: activeWorkout.microcycleId },
1066+
})
1067+
}
1068+
10311069
setStateHasChanged(workoutExerciseId: string, setId: string, state: WorkingSetState) {
10321070
const activeWorkout = this.getActiveWorkout()
10331071

@@ -1463,16 +1501,18 @@ export class MesocycleAggregateRoot {
14631501
...workout,
14641502
exercises: workout.exercises.map(exercise => {
14651503
if (exercise.id === event.payload.exerciseId) {
1466-
const assessmentData = match(event.payload.exerciseAssesment)
1467-
.with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({
1468-
assesment: assessment.assesment,
1469-
hardAssesmentTag: assessment.assesmentTag,
1470-
}))
1471-
.with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({
1472-
assesment: assessment.assesment,
1473-
hardAssesmentTag: null,
1474-
}))
1475-
.exhaustive()
1504+
const assessmentData = event.payload.exerciseAssesment
1505+
? match(event.payload.exerciseAssesment)
1506+
.with({ assesment: ExerciseAssesmentScore.Hard }, assessment => ({
1507+
assesment: assessment.assesment,
1508+
hardAssesmentTag: assessment.assesmentTag,
1509+
}))
1510+
.with({ assesment: ExerciseAssesmentScore.Ideal }, assessment => ({
1511+
assesment: assessment.assesment,
1512+
hardAssesmentTag: null,
1513+
}))
1514+
.exhaustive()
1515+
: { assesment: null, hardAssesmentTag: null }
14761516

14771517
return {
14781518
...exercise,
@@ -1521,6 +1561,27 @@ export class MesocycleAggregateRoot {
15211561
} as MicrocycleWorkout
15221562
})
15231563
})
1564+
.with({ type: 'ExerciseRepsChanged' }, event => {
1565+
updateMicrocycleWorkout(event.payload.microcycleId, event.payload.workoutId, workout => {
1566+
return {
1567+
...workout,
1568+
exercises: workout.exercises.map(exercise => {
1569+
if (exercise.id === event.payload.workoutExerciseId) {
1570+
return {
1571+
...exercise,
1572+
sets: exercise.sets.map(set => {
1573+
if (set.state === WorkingSetState.pending) {
1574+
return { ...set, reps: event.payload.reps }
1575+
}
1576+
return set
1577+
}),
1578+
}
1579+
}
1580+
return exercise
1581+
}),
1582+
} as MicrocycleWorkout
1583+
})
1584+
})
15241585
.with({ type: 'ExerciseRepsChangedDueToWeightChange' }, event => {
15251586
updateMicrocycleWorkout(event.payload.microcycleId, event.payload.workoutId, workout => {
15261587
return {

mobile/domain/aggregate/mesocycle.events.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export interface ExerciseFinished {
9696
workoutId: string
9797
exerciseId: string
9898
when: string
99-
exerciseAssesment: ExerciseAssesment
99+
exerciseAssesment: ExerciseAssesment | null
100100
}
101101
}
102102

@@ -150,6 +150,16 @@ export interface ExerciseWeightChangedCalibration {
150150
}
151151
}
152152

153+
export interface ExerciseRepsChanged {
154+
type: 'ExerciseRepsChanged'
155+
payload: {
156+
workoutExerciseId: string
157+
reps: number
158+
workoutId: string
159+
microcycleId: string
160+
}
161+
}
162+
153163
export interface ExerciseRepsChangedDueToWeightChange {
154164
type: 'ExerciseRepsChangedDueToWeightChange'
155165
payload: {
@@ -237,6 +247,7 @@ export type MesocycleEvent =
237247
| ExerciseWeightChangedTesting
238248
| ExerciseWeightChangedPending
239249
| ExerciseWeightChangedCalibration
250+
| ExerciseRepsChanged
240251
| ExerciseRepsChangedDueToWeightChange
241252
| WorkoutStarted
242253
| ExerciseUpdated

mobile/domain/progression/get-progression-type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const getProgressionType = (
3939
}
4040

4141
if (
42+
lastPastExercise.exerciseAssesment &&
4243
lastPastExercise.exerciseAssesment.assesment === ExerciseAssesmentScore.Hard &&
4344
lastPastExercise.exerciseAssesment.assesmentTag === HardAssesmentTag.TooHeavy
4445
) {

mobile/domain/working-exercise.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export type ExerciseAssesment = z.infer<typeof exerciseAssesment>
134134
export const finishedWorkingExerciseSchema = workingExerciseBaseSchema.extend({
135135
state: z.literal(WorkoutExerciseState.finished),
136136
sets: z.array(workingSetSchema),
137-
exerciseAssesment,
137+
exerciseAssesment: exerciseAssesment.nullable(),
138138
})
139139
export type FinishedWorkingExercise = z.infer<typeof finishedWorkingExerciseSchema>
140140

mobile/local-context/workout-context.local.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const createLocalWorkoutContext = (): WorkoutContext => {
5757
const mesocycle = new MesocycleAggregateRoot(mesocycleDto, getUserCoefficient(session))
5858

5959
if (mesocycle.hasActiveWorkout()) {
60-
return mesocycle.getActiveWorkout()
60+
return { ...mesocycle.getActiveWorkout(), progressionMode: mesocycleDto.progressionMode }
6161
}
6262
return null
6363
}
@@ -234,6 +234,19 @@ export const createLocalWorkoutContext = (): WorkoutContext => {
234234
await mesocycleDao.updateMesocycle(mesocycle.events)
235235
}
236236

237+
const exerciseChangeReps: WorkoutContext['exerciseChangeReps'] = async (
238+
session,
239+
workoutId,
240+
workoutExerciseId,
241+
reps
242+
) => {
243+
const mesocycleId = await mesocycleDao.getMesocycleIdByWorkoutId(workoutId)
244+
const mesocycleDto = await mesocycleDao.getMesocycleById(mesocycleId)
245+
const mesocycle = new MesocycleAggregateRoot(mesocycleDto, getUserCoefficient(session))
246+
mesocycle.changeReps(workoutExerciseId, reps)
247+
await mesocycleDao.updateMesocycle(mesocycle.events)
248+
}
249+
237250
const exerciseFinished: WorkoutContext['exerciseFinished'] = async (
238251
session,
239252
workoutId,
@@ -376,6 +389,7 @@ export const createLocalWorkoutContext = (): WorkoutContext => {
376389
replaceExercise,
377390
exerciseSetStateChanged,
378391
exerciseChangeWeight,
392+
exerciseChangeReps,
379393
exerciseFinished,
380394
undoExercise,
381395
exerciseLoaded,

0 commit comments

Comments
 (0)