Skip to main content

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:

  1. POST /api/users/coaching_figures — every figure of the coaching columns for the visible rows, in one request.
  2. POST /api/users/training/read — goal and per-muscle targets of the selected athletes, for the bulk editor.
  3. PUT /api/users/training/batch — write goal and targets for many athletes.
  4. 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​

ColumnShowsFigures needed
Statusthe most pressing reason something is off, more on hoverall below + account state (already on the roster read)
Readinesslatest daily score, a 14-day line, the athlete's usual rangereadiness
Trainingwhich of the last 28 days had training, "2 days ago"training
This weeksets done of the weekly target, pace verdictweek
Loadtraining load per week, 4 weeks; warns on spike / dropload
Sets per week (optional column)sets done vs target, 8 weeksweeks

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)​

ColumnToday's sourceRequestsTime
This week, StatusPOST /history/v2/week, current week, all ids11.3–1.6 s
Sets per weekthe same, once per week81.3–2.4 s each, queued
TrainingPOST /workout_sessions/search, 28 days, full sessions with sets1–6 pages2.4 s
ReadinessGET /measurements/user/{id}/metric/{wellnessScore}1 per athlete0.3–0.7 s
LoadGET /measurements/user/{id}/metric/{sessionRpe} + …/{trainingLoad}2 per athlete0.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 muscleUsage of POST /history/v2/week, summed over muscles that have a target (apps/portal/src/app/dashboard/weekly-goal-banner.tsx, weeklySetTotals).
  • Muscle qualities — zoneDistribution of 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). acuteChronicRatio as HistoryServiceWeekSummary already 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_id and day — no per-user fan-out (UserScopeBatch.fanOut is what makes /history/v2/week/summary cost one week computation per athlete).
  • training: SELECT user_id, date(created at tz), count(distinct workout) over workout_sessions for 28 days, plus max(created) per user; no sets loaded. Index workout_sessions(user_id, created).
  • readiness / load: one query on measurements per metric for all users and the window (42 days for readiness: 14 shown + 28 baseline). Index measurements(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 with weeks.

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 trainingGoalID the 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 dedicated PUT /users/{id}/training_goal/{goalID} scales existing sets instead; the batch must not.
  • Re-planning: the same schedule re-planning updateV2 triggers 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_figures for 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, include without weeks: p95 ≤ 400 ms on backdev.
  • training/batch with a goal for 6 users, then training/read: every user on the goal's template; with volumeDelta: 2 on glutes: 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 invitationPending on both responses.

(An invitationSentAt on the roster rows was considered and dropped, 14 Oct.)

Acceptance​

  • plan_capacity returns limit and counts for owner, facility and coach of the fixture account.
  • Archive → unarchive of a blocked athlete leaves at most activeUserLimit active.
  • 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: true when 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/batch answers per user, conflict writes nothing.

  • Speed (9 users): coaching_figures without weeks 110–350 ms, weeks alone ~160 ms; training/read ~60 ms; training/batch 130–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 (one coaching_figures per set of shown blocks, cached 3 minutes, refreshed after a bulk training save; the "Plan" view's Goal, Targets and qualities-goal read training/read for every row — 3 requests instead of one full profile per athlete, cells filled ~0.5 s after the table) and users/bulk-training-drawer.tsx (training/read, one training/batch with ifUnmodifiedSince = the newest updatedAt read).

  • Plan capacity (section 4): GET /users/plan_capacity answers {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 in users/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, invitationPending on create/update responses — the portal uses the latter when present.

Open points for the backend​

  1. The week's target does not follow a target change. After training/batch raises a target, training/read shows it, but coaching_figures week.setsTarget — and POST /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.
  2. 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.
  3. Load ratio and the running week: acuteChronicRatio compares 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.