The load a workout plan is designed to place on each muscle group — whole-plan totals and a per-session breakdown — so the intended distribution can be drawn before a single set is logged.
Route name: api.v1.training.workout-plans.muscle-load.
| Permission | Description |
|---|---|
workout_plans.view | View your own workout plans, or those you authored for a trainee |
The audience is exactly whoever may already view the plan: its owner, and the trainer of the
trainee it was authored for. There is no tracker share involved. This endpoint reads the plan,
never tracker_workout_sets, so being admitted here says nothing about access to the athlete's
actual logged load — that stays behind the share's include_workout flag on
GET /api/v1/tracker/data/muscle-load.
| Name | Type | Required | Description |
|---|---|---|---|
workout-plan-id | integer | Yes | workout_plans.id. A plan you may not view returns 403. |
There are no query parameters and no request body.
Per prescribed exercise, per muscle-group credit:
credit = set_count × rep_count × type_factor[the group's role in that exercise]
planned_rep_load(group) = Σ credit over every prescribed exercise in scope
type_factors are returned in every response so a client never has to hard-code them:
| Role | Factor |
|---|---|
target | 1.0 |
primary | 0.6 |
secondary | 0.3 |
A secondary mover must not be credited the same load as the target, which is what the factors are
for. They live on App\Repositories\Tracker\Workouts\TrackerMuscleLoadRepository::TYPE_FACTORS —
the same constant the tracker report uses — so the response, this page and the arithmetic cannot
drift apart.
planned_rep_load is not volume_loadworkout_plan_exercises carries no weight column: a plan prescribes sets and reps, not load.
The tracker report's reps × effective weight therefore cannot carry over, and this endpoint's unit
is deliberately named planned_rep_load instead. The two figures differ by two to three orders of
magnitude and must never be added, diffed or plotted on one axis. The response says so on the
wire with "unit": "planned_rep_load" and "comparable_to_volume_load": false.
Only the normalized 0–1 shapes of the two endpoints may be laid over one another — that is what
lets "what the plan intends" and
what was actually trained be drawn on the same body map.
Every workout_plan_exercises row is summed, including a super-set parent, with no de-duplication.
A parent is not an empty container: set_count and rep_count are NOT NULL, every write path
validates them required|integer|min:1, and the first row of a super-set group is itself a real
prescribed exercise that its siblings point at through super_set_id.
set_count and rep_count are string columns that predate the integer|min:1 validation every
current write path applies, so a legacy '', 'abc', '0' or '-3' is possible. An exercise
counts only when both read as at least 1. A row that credits no muscle group at all — its catalog
entry has no muscle-group mapping, or every mapping carries a role outside type_factors — cannot
be counted either. In every one of those cases the row contributes nothing and is surfaced
rather than silently dropped:
unparsed_exercise_count at the top level — distinct exercise rows in the whole plan that
contribute to no figure, whether because their counts could not be read or because they
credit no muscle group;A non-zero count is a client's signal that the figure beside it is short.
muscle_groups is a flat list. Back sits beside Lats, Upper Back and Rhomboids, and an
exercise mapped to a region and to a muscle inside it credits both. Summing the groups therefore
overstates the prescription, and the response carries "additive": false to say so.
For the same reason normalized is each group's share of the largest group, never of the sum.
The largest group is always exactly 1; when a scope has no credits at all, every normalized
is 0.
Number formatting. Every numeric field is a JSON number, and a whole value is emitted without a fractional part —
1, not1.0, and96, not96.0. A client in a strictly-typed language should read these as a floating-point type that accepts an integer literal (in Dart,numor(value as num).toDouble(), never a bareas double).
This is the one subtlety worth reading twice:
normalized is against the largest group in the plan;normalized is against the largest group in that session.So a light training day's shape still reads on its own, which is the point of a per-session figure.
Cross-session comparison is served by the absolute planned_rep_load, which is on one scale
everywhere in the response.
Each scope is rounded independently, and the plan total is folded from the unrounded session
figures. The plan's planned_rep_load is therefore the authoritative one: summing the displayed
per-session values for a group can land up to a cent per session away from it, which is worth
knowing before building a stacked bar out of them.
Results are cached for ten hours under the workout_plan_muscle_load tag, keyed on the plan ID
alone — there is no window, no share and no viewer-dependent substitution here, so every viewer of
a plan gets the same numbers. Any write that can change a prescribed exercise — creating, updating
or deleting a plan, a session or an exercise itself — flushes the tag after the write, so an
edit is reflected on the next request. The flush is coarse on purpose: deleting a plan or a session
cascades its exercises away without any per-row repository method running, so a narrower
invalidation would miss the case that changes the most rows.
Two writes deliberately do not flush, because neither can change a figure: creating a bare plan
(it has no sessions, so no prescribed exercises) and deactivating a user's plans (it writes
is_active, which is not an input to this report). Since the flush is tag-wide, an inert one on
either would drop every other user's cached report as well.
{
"data": {
"workout_plan_id": 12,
"unit": "planned_rep_load",
"additive": false,
"comparable_to_volume_load": false,
"type_factors": { "target": 1, "primary": 0.6, "secondary": 0.3 },
"unparsed_exercise_count": 0,
"groups": [
{
"muscle_group_id": 14,
"title": "Lats",
"translated_title": "عضلات پشتی",
"planned_rep_load": 96,
"planned_set_count": 8,
"planned_rep_count": 96,
"unparsed_exercise_count": 0,
"normalized": 1
},
{
"muscle_group_id": 20,
"title": "Biceps",
"translated_title": "عضله دوسر بازو",
"planned_rep_load": 28.8,
"planned_set_count": 4,
"planned_rep_count": 48,
"unparsed_exercise_count": 0,
"normalized": 0.3
}
],
"sessions": [
{
"workout_plan_session_id": 31,
"title": "Pull",
"day": "saturday",
"unparsed_exercise_count": 0,
"groups": [
{
"muscle_group_id": 14,
"title": "Lats",
"translated_title": "عضلات پشتی",
"planned_rep_load": 96,
"planned_set_count": 8,
"planned_rep_count": 96,
"unparsed_exercise_count": 0,
"normalized": 1
}
]
},
{
"workout_plan_session_id": 32,
"title": "Rest day",
"day": "sunday",
"unparsed_exercise_count": 0,
"groups": []
}
]
}
}
| Field | Type | Description |
|---|---|---|
workout_plan_id | integer | workout_plans.id the report was built for. |
unit | string | Always "planned_rep_load". Names the unit so it cannot be mistaken for volume_load. |
additive | boolean | Always false. The per-group figures overlap and must not be summed. |
comparable_to_volume_load | boolean | Always false. Only the normalized shapes compare with the tracker report. |
type_factors | object | The weighting applied per role, so the client need not hard-code it. |
unparsed_exercise_count | integer | Prescribed exercise rows in the whole plan whose counts could not be read. |
groups[].muscle_group_id | integer | muscle_groups.id. |
groups[].title | string | The catalog title, in English. |
groups[].translated_title | string | The title in the request's Accept-Language locale. |
groups[].planned_rep_load | number | Summed credit for this group in this scope, rounded to two decimals. |
groups[].planned_set_count | number | Σ set_count over the exercises crediting this group, unweighted by type factor. Whole in practice; a legacy fractional count is summed as-is rather than truncated. |
groups[].planned_rep_count | number | Σ set_count × rep_count over those exercises, unweighted by type factor. |
groups[].unparsed_exercise_count | integer | Credits to this group skipped because the exercise's counts could not be read. |
groups[].normalized | number | planned_rep_load ÷ the largest group's, in this scope, in [0, 1]. |
sessions[].workout_plan_session_id | integer | workout_plan_sessions.id. |
sessions[].title | string|null | The session's title. |
sessions[].day | string|null | The session's Weekday value (e.g. "saturday"). |
sessions[].unparsed_exercise_count | integer | Exercise rows in this session whose counts could not be read. |
sessions[].groups | array | The same group row shape, normalized against this session's largest group. |
Both groups arrays are ordered by planned_rep_load descending, ties broken by
muscle_group_id — matching the tracker endpoint's order. sessions is ordered by
workout_plan_session_id, so it reads in the order the sessions were authored.
Every session appears, including one with no exercises ("groups": []) — an empty training day
is information, not an omission. A plan with no sessions returns "groups": [] and
"sessions": [] with status 200, not an error.
This endpoint has no equivalent on the MCP server, and that is a deliberate deferral rather than
an oversight. Neither this report nor the pre-existing
GET /api/v1/tracker/data/muscle-load is exposed as a tool
today, so adding one here alone would leave the pair inconsistent. The tool would be a read-only
mirror of can:view,workoutPlan with no premium gate, in the shape of
compareWorkoutPlanToGymEquipment; it belongs in a change that covers both reports at once.
| Status | Error Type | Reference |
|---|---|---|
| 401 | Unauthorized | Authentication error |
| 403 | Forbidden | Permission error |
| 404 | Not Found | Not found error |
403 covers both a caller without workout_plans.view and a caller who has the permission but no
relation to the plan. 404 is a plan ID that does not exist.