Backend handoff: fast coaching columns and training edits for many athletes
Audience: backend developer. Status: live on backdev since 14 Oct 2026 and used by the portal (see "State on backdev" at the end). On a backend without the routes (404) the portal falls back to the sources in "Today's cost".
The portal's Users table shows, per athlete, what a coach needs to see whether something is off (the "Coaching" view of the column menu, components.md). Coaches also need to set the training goal and the weekly targets for many athletes at once. This document defines three endpoints that make both fast:
POST /api/users/coaching_figures— every figure of the coaching columns for the visible rows, in one request.POST /api/users/training/read— goal and per-muscle targets of the selected athletes, for the bulk editor.PUT /api/users/training/batch— write goal and targets for many athletes.- The plan limit: capacity readable by coaches, the reconcile on archive / unarchive / delete, the blocked login message (section 4, audit P2-03).
All three take a list of user ids and authorise it with
UserScopeBatch.authorisedUserIDs (as POST /history/v2/week/summary does): an
id the caller may not see is left out of the response, never an error.
The columns these figures feed
| Column | Shows | Figures needed |
|---|---|---|
| Status | the most pressing reason something is off, more on hover | all below + account state (already on the roster read) |
| Readiness | latest daily score, a 14-day line, the athlete's usual range | readiness |
| Training | which of the last 28 days had training, "2 days ago" | training |
| This week | sets done of the weekly target, pace verdict | week |
| Load | training load per week, 4 weeks; warns on spike / drop | load |
| Sets per week (optional column) | sets done vs target, 8 weeks | weeks |
The Status reasons are computed on the client from these figures (the rules
are in apps/portal/src/app/dashboard/users/roster-coaching-cells.tsx). The
server only delivers figures; thresholds may later become a team setting.
Today's cost (measured on backdev, 9 athletes)
| Column | Today's source | Requests | Time |
|---|---|---|---|
| This week, Status | POST /history/v2/week, current week, all ids | 1 | 1.3–1.6 s |
| Sets per week | the same, once per week | 8 | 1.3–2.4 s each, queued |
| Training | POST /workout_sessions/search, 28 days, full sessions with sets | 1–6 pages | 2.4 s |
| Readiness | GET /measurements/user/{id}/metric/{wellnessScore} | 1 per athlete | 0.3–0.7 s |
| Load | GET /measurements/user/{id}/metric/{sessionRpe} + …/{trainingLoad} | 2 per athlete | 0.3–0.7 s |
25 rows of the Coaching view: about 80 requests, several seconds until the table is complete. Target with the new endpoint: one request, p95 ≤ 400 ms for 25 athletes with every block.
1. POST /api/users/coaching_figures
Request
{
"userIDs": ["<uuid>", "..."], // the visible rows, ≤ 100
"now": 1760400000000, // Unix ms; "today" and "this week" derive from it, per athlete time zone
"include": ["week", "training", "readiness", "load"] // only what shown columns need; "weeks" adds 8 weeks
}
Response — one entry per authorised user
[
{
"userID": "<uuid>",
"week": { // "week": the current calendar week (Mon 00:00 → next Mon, athlete tz)
"from": 1760306400000,
"setsDone": 62, // Σ over muscles WITH a target of sets done (as weeklySetTotals on the client)
"setsTarget": 170, // Σ of the athlete's per-muscle weekly targets; 0 without targets
"qualities": { "zone0": 0.24, "zone1": 0.48, "zone2": 0.28 } // muscle qualities of the sets done; null without sets
},
"weeks": [ // "weeks": the last 8 weeks, oldest first, current last — same shape as "week"
{ "from": 1755468000000, "setsDone": 140, "setsTarget": 170, "qualities": { … } }
],
"training": { // "training"
"lastSessionAt": 1760200000000, // newest completed session ever (not only in the window); null if none
"days": [0, 2, 0, 1, …] // 28 numbers, oldest first, today last: completed workouts per calendar day (athlete tz)
},
"readiness": { // "readiness": the wellness score (metric WELLNESS_SCORE), 0–25
"days": [ // 14 entries, oldest first, today last; null = no check-in that day
{ "day": "2026-10-01", "value": 19 }, { "day": "2026-10-02", "value": null }, …
],
"latest": { "day": "2026-10-13", "value": 21 }, // null when none in the 14 days
"band": { "low": 16.8, "mean": 19.5, "high": 22.2 } // usual range: mean ± 1 SD of the 28 days before `latest`; null below 7 check-ins
},
"load": { // "load": session-RPE training load
"weeks": [1820, 2100, 1650, 2900], // Σ load per calendar week, 4 weeks, oldest first, current last
"acuteChronicRatio": 1.43, // current week ÷ mean of the 4 PRECEDING weeks; null without a baseline
"ratedWorkouts": 11 // workouts with a load in the 4 weeks (0 → the cell says "No rated workouts")
}
}
]
Definitions (match what the client computes today)
- Days and weeks in the athlete's time zone, as the history endpoints do. Weeks start Monday 00:00.
- Sets done / target — the same numbers as
muscleUsageofPOST /history/v2/week, summed over muscles that have a target (apps/portal/src/app/dashboard/weekly-goal-banner.tsx,weeklySetTotals). - Muscle qualities —
zoneDistributionof the same week. - Readiness band — as
buildWellnessTrend(packages/core/src/wellness/trend.ts): several check-ins on a day → the last one; mean and SD over the 28 days before the day judged; at least 7 check-ins for a band, with the series' SD floor. - Load — the training-load metric per workout, summed per calendar week
(
packages/core/src/training-load/trend.ts).acuteChronicRatioasHistoryServiceWeekSummaryalready computes it for the week summary. - Workouts per day — completed workouts, not exercise sessions: several sessions of one workout on a day count once.
Performance
- One query per block for all users, grouped by
user_idand day — no per-user fan-out (UserScopeBatch.fanOutis what makes/history/v2/week/summarycost one week computation per athlete). training:SELECT user_id, date(created at tz), count(distinct workout)overworkout_sessionsfor 28 days, plusmax(created)per user; no sets loaded. Indexworkout_sessions(user_id, created).readiness/load: one query onmeasurementsper metric for all users and the window (42 days for readiness: 14 shown + 28 baseline). Indexmeasurements(user_id, metric_id, created).week/weeks: the per-week statistics cost 1–2 s per call today. Either one grouped query for the set counts and qualities, or a cache of finished weeks per user (a past week does not change unless sessions are edited — invalidate on session write).- Budget: p95 ≤ 400 ms for 25 users with all blocks except
weeks; ≤ 800 ms withweeks.
Client switch
The portal will call coaching_figures and fall back to today's sources on a
404, so the endpoint can ship before or after the client.
2. POST /api/users/training/read
The bulk editor shows, per muscle, the range across the selected athletes ("8–12 sets") and which goals are mixed.
// request
{ "userIDs": ["<uuid>", "..."] }
// response
[
{
"userID": "<uuid>",
"trainingGoalID": "<uuid>", // null without a goal
"updatedAt": 1760300000000, // for the stale-edit guard below
"muscles": [
{ "muscleKey": "glutes", "volume": 10, "focused": false,
"zones": { "zone0": 0.3, "zone1": 0.4, "zone2": 0.3 } }
]
}
]
3. PUT /api/users/training/batch
One write for the whole selection. Today it is one GET /users/{id} and one
whole-user PUT /users/v2/{id} per athlete — 40 requests for 20 athletes, and a
failure halfway leaves the group half done.
// request
{
"userIDs": ["<uuid>", "..."],
"trainingGoalID": "<uuid>", // optional: set this goal AND reset every muscle to its template first
"muscles": [ // optional, applied after the goal, in order
{
"muscleKey": "glutes", // catalogue key; the server maps it to each athlete's muscle row
"volume": 12, // set to … — OR —
"volumeDelta": 2, // change by … (result clamped to 0…30); exactly one of the two, or neither
"focused": true, // optional
"zones": { "zone0": 0.2, "zone1": 0.4, "zone2": 0.4 }, // optional, sums to 1
"resetToGoal": false // optional: this muscle back to the goal's template (other fields ignored)
}
],
"ifUnmodifiedSince": 1760300000000 // optional: skip users changed after the editor read them
}
// response: one entry per requested user
[
{ "userID": "<uuid>", "status": "ok", "updatedAt": 1760400100000 },
{ "userID": "<uuid>", "status": "conflict" }, // changed since ifUnmodifiedSince — not written
{ "userID": "<uuid>", "status": "forbidden" }, // no write privilege for this user
{ "userID": "<uuid>", "status": "error", "reason": "…" }
]
- Per user, never all-or-nothing. Each user in its own transaction; the portal reports "Saved for 5 of 6" and keeps only the failed ones selected.
- Goal semantics: with
trainingGoalIDthe muscles are first set to the goal's template, as the single-athlete save does (apps/portal/src/app/dashboard/users/athlete-drawer.tsx). The dedicatedPUT /users/{id}/training_goal/{goalID}scales existing sets instead; the batch must not. - Re-planning: the same schedule re-planning
updateV2triggers today, once per athlete, after its write. - Privileges: the same rules as the whole-user update (
writeTrainingGoal, profile write per role). - Budget: p95 ≤ 1 s for 25 users excluding re-planning; re-planning may run after the response.
Acceptance
coaching_figuresfor the fixture account (9 users, all blocks) returns the same numbers the portal computes today from the old sources (the portal's cells can be compared side by side).- 25 users,
includewithoutweeks: p95 ≤ 400 ms on backdev. training/batchwith a goal for 6 users, thentraining/read: every user on the goal's template; withvolumeDelta: 2onglutes: every user +2, clamped.- An id outside the caller's scope is absent from every response.
4. Plan limit: athletes "Not in plan" (audit package P2-03)
Status: live on backdev since 14 Oct 2026 and used by the portal — see "State on backdev". Who moves in when a place frees up: the longest-registered user who is not in the plan (the reconcile keeps the first N sub-users by creation date active), so a coach never picks.
The backend blocks sub-users beyond the licence tier's active quota
(UserAvailabilityService.reconcileSubUsers, quota from
UserLicenseTier.activeQuota, the owner taking one slot). The portal now shows
such an athlete as "Not in plan" (warning tone) and explains it with "See
plans" / "Contact support" (apps/portal/src/app/dashboard/users/plan-limit.tsx).
What it cannot say yet is how full the plan is, and the server's own rule has
gaps. State checked in the backend source on 14 Oct 2026.
4a. The plan's capacity, readable by every coach of the account
The licence DTO (UserLicenseReturnDto) has deviceLimit but no user limit, and
it is nested only on the owner's own GET /users/{id}. Proposed:
// GET /api/users/plan_capacity — any manager of the account (owner, facility, coach)
{
"activeUserLimit": 2, // sub-users the tier allows to train (owner's slot already taken off); null = unlimited
"activeUserCount": 2, // sub-users currently active (not blocked, not archived)
"blockedUserCount": 6 // sub-users blocked by the limit
}
Also add activeUserLimit / activeUserCount to UserLicenseReturnDto.
The portal will use it in three places:
- "Not in plan" says "2 of 2 athletes in your plan".
- Create user and roster upload warn before the new athlete would be blocked.
- The Users page shows a quiet capacity line.
4b. Run the reconcile wherever a slot opens or closes
reconcileSubUsers runs on insert and assignment, and in a job. It does not
run on unarchive (UserController.unarchiveUserHandler), archive or delete.
- Measured (fixture, 12 Oct): archive → unarchive of a blocked athlete left her active beside the two already active, so 3 trained on a quota of 2.
- Proposed: reconcile after unarchive, archive and delete.
- Decide first: a freed slot activates whom? The audit proposed the oldest blocked athlete. Silently re-activating someone may surprise a coach, so the alternative is that nobody moves until a coach picks.
4c. Uploads for a blocked athlete
Session and set uploads for a blocked user are accepted today; only the tracking app keeps blocked athletes out of its picker. Either:
- reject them with a dedicated, localised reason, or
- state that the server accepts them on purpose (e.g. sessions recorded offline before the block).
4d. The blocked athlete's login
AuthServiceLogin.swift:34 answers a blocked user with
Translatable.Error.Forbidden.badCredentials, so the athlete thinks the code
was wrong. Give it its own userMessage, e.g. "Your team's plan has no free
athlete place right now. Ask your coach."
4e. invitationPending on create and update
POST /users/{creatorID} and PUT /users/v2/{id} don't return
invitationPending. The portal now assumes true after a create with an email
(user-persistence.ts), but an update that adds an email (which invites, see
UserServiceUpdate) still needs the next roster read. Proposed:
- return
invitationPendingon both responses.
(An invitationSentAt on the roster rows was considered and dropped, 14 Oct.)
Acceptance
plan_capacityreturns limit and counts for owner, facility and coach of the fixture account.- Archive → unarchive of a blocked athlete leaves at most
activeUserLimitactive. - Deleting an active athlete frees the slot as decided in 4b.
- A blocked athlete's login shows the specific message.
- The create response carries
invitationPending: truewhen an email was given.
State on backdev (checked 14 Oct 2026)
-
Shapes: all three routes answer as specified above; an id out of scope is left out (
[]for a foreign id);training/batchanswers per user,conflictwrites nothing. -
Speed (9 users):
coaching_figureswithoutweeks110–350 ms,weeksalone ~160 ms;training/read~60 ms;training/batch130–280 ms for 2–3 users. -
Same numbers as before: current week, training days, readiness and load match the old sources for all 8 fixture athletes; 8 weeks × 8 athletes have the same sets and targets. Muscle qualities differ in 22 of 64 weeks — the server is right there: the old client path hid the qualities of athletes without targets (fixed in the client).
-
Client:
apps/portal/src/app/dashboard/users/roster-figures.ts(onecoaching_figuresper set of shown blocks, cached 3 minutes, refreshed after a bulk training save; the "Plan" view's Goal, Targets and qualities-goal readtraining/readfor every row — 3 requests instead of one full profile per athlete, cells filled ~0.5 s after the table) andusers/bulk-training-drawer.tsx(training/read, onetraining/batchwithifUnmodifiedSince= the newestupdatedAtread). -
Plan capacity (section 4):
GET /users/plan_capacityanswers{activeUserLimit: 2, activeUserCount: 3, blockedUserCount: 5}for the fixture (3 active on a limit of 2 is the archive test of 12 Oct; the reconcile runs at the next archive, restore or delete). The portal reads it inusers/plan-limit.tsx: the Users subtitle ("plan full · 5 not in plan"), the "Not in plan" explainer, and a warning in Create user and the roster review; it reads it again, with the roster, after create, archive, restore and delete. Not verified live (would need writes): the reconcile on archive / restore / delete, the blocked login message,invitationPendingon create/update responses — the portal uses the latter when present.
Open points for the backend
- The week's target does not follow a target change. After
training/batchraises a target,training/readshows it, butcoaching_figuresweek.setsTarget— andPOST /history/v2/week, the old source — stay at the old sum (Lena Hartmann: target 1 set, week target 0). The table then shows "Targets 1 sets" next to "This week: No targets". The current week should read the athlete's current targets. - Sets done counts targeted muscles only (as specified). An athlete
without targets reads "0 sets" in a week with training. A second figure
setsAll— the real number of sets in the week, each set counted once (not the sum of the per-muscle counts, which counts a squat set for every muscle it trains) — would let the table say "12 sets, no targets" instead. The week stats (POST /history/v2/week) lack that number too, so the drawer's week card can only say "trained", not how much. - Load ratio and the running week:
acuteChronicRatiocompares the running calendar week with full weeks, so early in the week it is low. The portal judges a drop on the week's pace (ratio × 7 ÷ days elapsed); a rolling 7-day figure from the server would be simpler.