The pure rules live in 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 a reps 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" returns 12 (the smaller of the two numbers).
  • "10" returns 10.
  • 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 carries lastWeight 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.
Two decisions behind this are worth knowing:
  • 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.
The “previous” column in 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 list alternatives. 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, pr and muscle (keeping the old muscle when the option has none).
  • Keeps the coach’s prescription: set count, set types, targetReps, targetWeight, rest.
  • Runs relinkSets, which clears weight, reps, rir, rm, done and isPr on every set and relinks lastWeight and lastReps to the substitute’s own history.
  • Resets done, videoSent and videoSkipped on the exercise.
  • Sets origin to the prescribed exercise. Swapping back to the prescribed exercise sets origin to null and restores its coachFeedback.
A swap lasts for the session unless the trainee saves it. Saving posts 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 is exercise.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.
Constants: 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.
  • lastIndex is the position of the day with the latest valid lastPerformedAt.
  • With no history the first day is next.
  • After the last day the selection wraps to the first.
  • An empty list returns null.
The order is the order of days in the plan. The workouts tab uses the result for the 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) returns done and total:
    • total is plan.workoutsPerWeek when it is a positive number, otherwise the number of days.
    • done is plan.completedThisWeek when the server sent a number, otherwise the count of days performed this week.
    • done is capped at total.
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 when startedAt is 0.
  • formatClock(totalSec) returns mm:ss, or h:mm:ss from 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 returns null for a missing duration.