The workouts tab of the trainee app is served by eight endpoints on the main trainee router. One read returns every active training plan fully resolved. The writes record workout logs, the trainee’s own exercise swaps and technique videos for coach review. Mount: /v1/trainee, router traineeRouter in src/modules/trainee/trainee.routes.ts. Lane: trainee. Every endpoint on this page uses both guards: the trainee token and the app access check. A frozen or ended subscription returns 403 with details.reason = "APP_LOCKED", and preview tokens can only call the GET endpoints. See Authentication.

Reading plans

GET /v1/trainee/workouts

Returns all active training plans with their days and exercises, the current week strip and the movement requirement. Auth: trainee token and app access. No parameters. What the service does, in order:
  1. Loads every Program of type TRAINING with status ACTIVE for the trainee, newest updatedAt first, plus the latest 200 completed workout logs (WORKOUT_LOG_SCAN), the trainee’s technique videos, the last performed date per day label, and the trainee’s stored substitutions.
  2. Normalizes each plan’s content with normalizeTrainingContent, which migrates older content shapes on read. A file plan (a program with pdfUrl set, either a PDF or a site link) is never normalized and is returned with kind: "file" and no days.
  3. For plans with the automatic alternatives switch on (meta.autoAlts), replaces each row’s alternatives with ranked automatic picks for the prescribed exercise. The coach’s own list stays stored and comes back when the switch is turned off.
  4. Applies the trainee’s substitutions. A substitution only applies while the row still prescribes the exercise it was made against (fromExerciseId). When it applies, the coach’s original is placed first in alternatives so the trainee can switch back.
  5. Resolves each exercise id to a name, image, video, instructions and target muscle, applying the studio’s per-exercise overrides (ExerciseStudioOverride) over the shared library row.
  6. Attaches history to every row and alternative: the sets from the most recent log of that exercise, the personal record, the technique video prompt and any unseen coach feedback.
Response: 200.
week always holds seven entries, Sunday to Saturday of the current week in the trainee’s time zone. It is shortened above. With no active training plan the response is { "plans": [], "week": [...], "movement": null }.

Plan fields

string
regular or file. A file plan has pdfUrl set, days: [], and workoutsPerWeek from content.meta.workoutsPerWeek or null.
string
The plan’s display name when set, else its internal name (traineePlanName).
number
Completed logs this week that belong to the plan. A log with programId counts for that plan only. A log without one counts when its label matches one of the plan’s day labels. For a file plan the label to match is the plan name.

Day fields

string
The distinct muscles of the day’s rows joined with a middle dot.
number
(work seconds + rest seconds) x 1.1, plus the day’s cardio minutes when the plan requires cardio. Work is 3 seconds per rep using the top of a rep range, or the stated seconds or minutes for timed sets. A row with no set breakdown counts its sets value, defaulting to 3. The mobile app mirrors this formula.
string | null
The latest completed log whose label equals this day’s label.
object | null
Present when the plan’s movement requirement includes cardio. activities values are walking, running, stairs and cycling.
boolean
Whether the coach enabled the RIR column (meta.columns.rir). The app shows the RIR input only when this is true.

Row fields

string
For repType: "range" this is low-high, for example 8-12, so older app builds that only read reps still work. repType is fixed, range, seconds or minutes.
array
The per-set breakdown. type is warmup, working or drop. Empty per-set values inherit the row’s.
string | null
The id of the row this one is super-setted with.
array
The sets marked done in the most recent completed log that contains this exercise. Used to prefill weight and reps.
object | null
The trainee’s record for the exercise across the scanned logs. A set counts only when it is marked done: true, has reps above zero and, for a weighted set with a target, reached at least 80 percent of targetReps. The heaviest weight wins. Bodyweight exercises rank by reps. The rule lives in client-tracking.compute.ts and is shared with the coach views.
object | null
null when the row’s mode is none and there is no open coach request. Otherwise { mode, required, reason, completedCount }. mode is free or coachDemand. reason is FIRST_TIME, EVERY_N, COACH_REQUEST or null.
object | null
The newest reviewed video of this exercise with a coach note the trainee has not seen: { videoId, note, reviewedAt }.
object | null
Set when the trainee’s own swap is applied: { exerciseId, name } of the coach’s original.

Technique video prompts

evaluatePrompt in technique-prompts.ts decides whether a video is required:
  • A coach’s one-shot request (techniqueVideo.requestedAt on the row) is open until the trainee uploads a video of that exercise after the request time. While open, the prompt is { mode: "coachDemand", required: true, reason: "COACH_REQUEST" }.
  • In coachDemand mode the quota is 1 when firstTime is on, plus one for every everyN completed performances of the exercise. A video is required while the trainee has submitted fewer videos than the quota.
  • In free mode a video is never required.
An exercise counts as performed once per workout log, when at least one of its sets is marked done.

Next workout

The API does not return a “next” flag on days. The rule is nextTrainingDay: the day after the most recently performed one, wrapping around, or the first day when nothing was performed. The app applies the same rule, and the server uses it for GET /v1/trainee/home and for the movement block.

Movement requirement

movement is built from the newest active plan whose meta.requirement.on is true. It is null when no plan has a requirement.
mode is cardioOnly, stepsOnly, both or bothCompensated. With bothCompensated, meeting either cardio or steps is enough for todayMet. Cardio minutes come from CardioLog rows and steps from DailyMetric.steps, both for the current week. Logging them is covered on Trainee tracking. Errors: none beyond the lane errors.

Logging

POST /v1/trainee/workouts/log

Records one workout session. Auth: trainee token and app access.
string
The plan the workout belongs to. Send it. Logs without it are matched to plans by label only.
string
The plan day id. The coach’s workout history groups sessions by it, because coaches rename day labels.
string
The day label at the time of the workout, for example A. For a file plan’s mark-done button the app sends the plan name.
string
Trimmed, at most 1000 characters. An empty note is not stored.
number
Non-negative integer.
boolean
default:"true"
false records an unfinished session. Only completed logs count for history, streaks, records and weekly progress.
object[]
default:"[]"
One object per exercise. The schema accepts any object. The fields the server reads are listed below.
The shape of entries is a contract between the app and every reader of WorkoutLog.entries. The schema does not enforce it, so follow it exactly:
programId is not checked against the trainee’s own programs on this endpoint. It only has to be an existing program id. What the service does:
  1. Creates a WorkoutLog with performedOn set to the server’s current time. The client cannot backdate a workout.
  2. When completed is true, counts the trainee’s completed logs to get workoutNumber.
  3. When this is the trainee’s first completed workout, calls the subscription starter with FIRST_WORKOUT. A subscription that was waiting for the first workout starts now. Deleting the log later does not undo that.
The endpoint sends no push notification and creates no inbox item. Coach-side views read the log on demand, and scheduled jobs pick up inactivity separately. Response: 201. The created row plus workoutNumber.
workoutNumber is the trainee’s lifetime count of completed workouts, used by the share screen. It is null for an unfinished log. entries is echoed as stored and shortened here. Errors:

GET /v1/trainee/workouts/history

Lists the trainee’s completed workouts, newest first. Auth: trainee token and app access. No parameters. Returns at most 100 logs (WORKOUT_HISTORY_LIMIT), ordered by performedOn then createdAt, both descending. Unfinished logs are excluded. Response: 200.
setCount, volumeKg and exerciseCount are computed by readWorkoutLog from entries. Only performed sets count. Volume is the sum of weight times reps for sets with both above zero, rounded to a whole kilogram. label is an empty string when the log has none. Errors: none beyond the lane errors.

GET /v1/trainee/workouts/history/:id

Returns one completed workout with its exercises and sets. Auth: trainee token and app access.
string
required
The workout log id.
Response: 200.
workoutNumber is the ordinal of this workout among the trainee’s completed logs, counted up to this log’s performedOn and createdAt. Exercise names come from the name stored in each entry, not from the library, so they show what the trainee saw at the time. Exercises with no performed set are left out. Errors:

DELETE /v1/trainee/workouts/log/:id

Deletes one of the trainee’s workout logs. Auth: trainee token and app access.
string
required
The workout log id.
The delete is scoped by clientId, so a trainee can only remove their own logs. Records, last sets, streaks and weekly progress are all derived at read time, so they update on the next read. A subscription that the log started stays started. Response: 200.
Errors:

Substitutions

POST /v1/trainee/workouts/substitutions

Saves the trainee’s own swap of one prescribed exercise for an approved alternative, so it becomes their default in future workouts. Sending the original exercise as the target removes the swap. Auth: trainee token and app access.
string
required
The training plan.
string
required
The plan day that holds the row.
string
required
The strength row id from GET /v1/trainee/workouts.
string
required
The exercise the coach currently prescribes on that row. Acts as a staleness guard.
string
required
The alternative to use. Equal to fromExerciseId means undo.
The swap is stored in its own table, TraineeExerciseSubstitution (unique on programId and rowId), not inside the program content. The coach’s editor rewrites the whole content blob on save, so anything stored there would be lost. Writing the side table also avoids bumping the program version, which would push a “your coach updated your plan” notification to the trainee who just tapped swap. Checks, in order:
  1. The program must be one of the trainee’s active training programs.
  2. The row must exist in the named day of the current content.
  3. The row must still prescribe fromExerciseId.
  4. For an undo, the stored substitution is deleted and the call ends.
  5. toExerciseId must be offered for that row right now: one of the coach’s alternatives, or, when automatic alternatives are on, one of the automatic picks computed the same way the workouts read computes them.
Response: 200.
An undo returns { "saved": false }. Errors:

Technique videos

POST /v1/trainee/technique-videos

Submits a technique video for coach review. Upload the file first with Trainee uploads and send the returned URL. Auth: trainee token and app access.
string
required
The uploaded video URL. Any non-empty string is accepted.
string
The exercise the video shows. Send it. Prompts and coach requests are tracked per exercise, and videos without an exercise are ignored by them.
string
A poster image URL.
Creates a TechniqueVideo row with status PENDING. A new upload of an exercise closes any open coach request for it, because the request only stands until a newer video exists. Response: 201. The created row.
Errors: VALIDATION (422) when url is missing or empty.

POST /v1/trainee/technique-videos/:id/feedback-seen

Marks the coach’s feedback on a video as seen, so it stops appearing as coachFeedback on the exercise. Auth: trainee token and app access.
string
required
The technique video id, taken from coachFeedback.videoId.
Sets TechniqueVideo.feedbackSeenAt to now. Calling it again just refreshes the timestamp. Response: 200.
Errors: