src/features/workouts/lib. The placeholder and substitute rules live next to the session store in src/features/workouts/store/workoutSession.ts. All paths are relative to the mobile repo root.
Rep ranges
A prescription arrives as areps string with an optional repType ('fixed' | 'range' | 'seconds' | 'minutes') and repsHigh. Unicode dashes are normalised to a hyphen before parsing.
bottomRep(repsLabel, fallback)
Returns the low end of the prescription. This is what a set defaults to when the trainee checks it without typing.
"12-15"returns12(the smaller of the two numbers)."10"returns10.- A string with no positive number returns
fallback.
makeSets in data/workoutsApi.ts stores the result as ExerciseSet.targetReps.
parseRepRange(repsLabel, targetReps)
Builds the list of values for the reps picker.
- A range gives every integer from low to high.
- A single number gives a window of plus and minus 2 around it (
SINGLE_WINDOW = 2), so"10"gives 8 to 12. - Values never go below 1 and the list is capped at 40 items (
MAX_ITEMS). - No usable number gives an empty list, and the long-press picker is disabled.
repPrescriptionLabel(reps, repType, repsHigh)
Builds the display label: "8-12" for a range (joined from reps and repsHigh unless reps already contains a hyphen), the number plus the short seconds or minutes string for timed sets, and the raw string otherwise. With no repType (older servers) it returns the raw string.
Set history and placeholders
Each set carrieslastWeight and lastReps, the values the same set of the same exercise had in the last workout. The server sends them per row as lastSets (setNumber, weight, reps). makeSets matches by setNumber and falls back to the last entry in the list when a set number has no match.
computePlaceholders(exercises) decides what each empty field shows greyed out, and what it commits when the set is checked without typing:
- Reps come from the coach’s prescription for that set (
targetReps, already the low end of a range). Only when there is no prescription do they fall back to last workout’s reps. - Weight comes only from what this set lifted last workout. With no history it stays blank.
- A prescribed weight is never used as a placeholder. A placeholder is logged as if the trainee lifted it, and a target is not evidence of that. The prescribed weight still shows in the info mode preview.
- Each set stands alone. A weight typed into set 1 does not flow into sets 2 and 3. It used to, and a drop set then read back as three sets at the top weight.
SetRow shows lastReps × lastWeight kg, reps alone when there was no weight, and a dash placeholder with no history.
The same placeholder map is used by toggleSet, completeExercise, sessionStats, the Live Activity figure label and the log payload, so every surface agrees on the value of an untouched set.
Exercise substitutes
A program row can listalternatives. Each one is an ExerciseOption with its own media, lastSets, pr, techniquePrompt and coachFeedback, so swapping never needs another request.
exerciseOptions(exercise) returns the prescribed exercise first (from origin when a swap is active, otherwise the current row), then every alternative with a different exerciseId.
substitute(exerciseId, option) on the store:
- Does nothing when the option is already the current exercise.
- Replaces
exerciseId,name,image,video,instructions,prandmuscle(keeping the old muscle when the option has none). - Keeps the coach’s prescription: set count, set types,
targetReps,targetWeight, rest. - Runs
relinkSets, which clearsweight,reps,rir,rm,doneandisPron every set and relinkslastWeightandlastRepsto the substitute’s own history. - Resets
done,videoSentandvideoSkippedon the exercise. - Sets
originto the prescribed exercise. Swapping back to the prescribed exercise setsorigintonulland restores itscoachFeedback.
programId, dayId, rowId, fromExerciseId and toExerciseId to POST /v1/trainee/workouts/substitutions. On later loads the row arrives with the substitute as its default and substitutedFrom set to the coach’s original exerciseId and name. SubstituteSheet uses substitutedFrom to label the coach’s pick, and the screen uses it as fromExerciseId for later saves.
Personal record rule
The record to beat isexercise.pr, a weight and reps pair from the server. toPr in workoutsApi.ts drops a record whose reps is zero or less.
A done set is a new record when both functions in lib/personalRecord.ts return true.
countsForRecord(set, targetReps)
Decides whether the set is eligible.
- A set with no reps never counts.
- A bodyweight set (no weight) or a set with no rep target always counts.
- A weighted set counts only when it reached at least 80 percent of the required reps. With a target of 10, 8 reps count and 7 do not.
beatsRecord(set, record)
- When either side has a weight, the set wins only with a strictly heavier weight. Reps do not break a tie.
- When neither side has a weight (bodyweight), the set wins with strictly more reps.
Where it runs
recomputePrFlags(exercise) in the store sets isPr on every done set and runs after toggleSet, completeExercise and removeSet. With no exercise.pr every flag is cleared. So an exercise with no stored record never shows a new one.
Every set is compared against the record from before the workout. Two sets in one workout can both be flagged. The share summary counts one record per exercise and picks the best flagged set with outranks(set, other): heavier wins, and on equal weight more reps win.
The stored record itself (pr) is computed by the server. The app only compares against it.
Time estimate
estimateWorkoutMinutes(day) in lib/estimateWorkout.ts produces the minutes shown on cards and in the info mode header.
SECONDS_PER_REP = 3, TIME_BUFFER = 1.1.
setWorkSeconds(reps, repsHigh, repType):
rangeHigh takes the high end of a range, either packed in one string ("8-12") or split across reps and repsHigh. A single number is used as is.
rowSets(row) expands a row into sets. With setRows it uses each set’s own reps, repsHigh, rest and repType, falling back to the row values. Without setRows it repeats the row values parseInt(row.sets) times, or 3 times when that is not a positive number.
Rest is parsed by clockToSeconds, which reads m:ss or plain seconds. Rest after the last set is counted too. The 1.1 buffer applies to work and rest, not to cardio minutes.
Next workout selection
nextWorkoutId(workouts) picks the day that follows the most recently performed one.
lastIndexis the position of the day with the latest validlastPerformedAt.- With no history the first day is next.
- After the last day the selection wraps to the first.
- An empty list returns
null.
up-next card unless a workout is in progress.
Workout week
The week starts on Sunday at local midnight on the device:nextWeekStart(now)is that start plus 7 days.performedThisWeek(iso, now)is true when the timestamp is at or after the week start and before the next one.weeklyWorkoutProgress(workouts, now, plan)returnsdoneandtotal:totalisplan.workoutsPerWeekwhen it is a positive number, otherwise the number of days.doneisplan.completedThisWeekwhen the server sent a number, otherwise the count of days performed this week.doneis capped attotal.
getOverview sets workoutsPerWeek to the coach’s value when positive, to null for a file plan without one, and to the number of days otherwise.
useWorkoutWeek() in src/features/workouts/hooks/useWorkoutWeek.ts returns a Date that refreshes when the app becomes active and through a timer set for nextWeekStart(now), so the done marks and the weekly count roll over at the week boundary while the tab is open.
Supersets
groupExercises(exercises) walks the list once. An exercise whose supersetParentId matches the id of an earlier lead joins that lead’s unit and turns it into kind: 'superset'. Anything else starts a new kind: 'single' unit. A partner must come after its lead in the list to be grouped.
supersetGroup(exercises, exerciseId) returns the members of the unit that contains the exercise. The day screen uses it to share one rest timer per round.
Small formatters
elapsedSeconds(startedAt, now)returns whole seconds, or 0 whenstartedAtis 0.formatClock(totalSec)returnsmm:ss, orh:mm:ssfrom one hour.instructionSteps(text)splits instructions on line breaks and strips leading numbering and bullets.formatWorkoutMinutes(durationSec)rounds to minutes with a minimum of 1, and returnsnullfor a missing duration.