Backend handoff: per-athlete summary endpoints
Audience: backend developer. Status: ✅ delivered — see the shipped contract in athlete-summary-backend-response.md.
The portal is building a coach-facing athlete summary — an athlete's muscle usage (body-map tint, per-muscle bars, strength-quality zones) and their training goal / skill. The old iOS app has all of this, but every endpoint it uses is self-scoped (the logged-in athlete). A coach viewing athlete X needs the same data for a chosen athlete (or several). This document requests those variants.
The frontend already types and calls the self-scoped endpoints (see
packages/core/src/api/history-stats.ts, docs/athlete-summary client work),
so the ask is purely additive: same data, scoped by a userID list.
What exists today (self-scoped, unchanged)
| Endpoint | Returns |
|---|---|
GET /history/v2/week?from=&to= | HistoryStatsReturnDto for the signed-in user |
GET /history/day?from=&to= | HistoryStatsReturnDto for the signed-in user |
GET /history/session/{id} | HistoryStatsReturnDto for one session (already athlete-agnostic — a session belongs to a user) |
from/to are Unix ms. HistoryStatsReturnDto = { created, volumeCompletion?, muscleMapVolume?, muscleUsage?, gridMetrics?, zoneDistribution? } (the shape the client already decodes).
What we need: batch, userID-scoped variants
Same names, POST, request body carries a list of userIDs, response is a
list of the same per-user DTO — one entry per requested user. No change to
the per-user HistoryStatsReturnDto itself.
POST /history/v2/week
// request
{ "userIDs": ["<uuid>", "<uuid>"], "from": 1730000000000, "to": 1730604800000 }
// response — keyed by userID preferred (see note)
[
{ "userID": "<uuid>", "stats": { /* HistoryStatsReturnDto */ } },
{ "userID": "<uuid>", "stats": { /* HistoryStatsReturnDto */ } }
]
POST /history/day
Same request/response shape, day window.
POST /history/session (optional, lower priority)
The per-session GET is already id-scoped, so a batch is only worth it if a
coach view needs many sessions at once: { "sessionIDs": ["<uuid>", …] } →
[{ "sessionID", "stats" }].
Response shape note: prefer the keyed-by-userID array above over a
bare HistoryStatsReturnDto[] aligned by request index — it's robust to the
backend reordering, deduping, or omitting a user with no data. If a requested
user has no sessions in the window, return them with an empty/zeroed stats
(or omit them — the client tolerates a missing entry).
Auth/scoping: the caller is a coach; the backend should enforce that each
requested userID is an athlete the caller may view (same access rule as
GET /users/{id}), and 403 / drop any they may not.
Also confirm (read-side): does the user read embed goal & skill?
The coach summary shows the athlete's current training goal and skill. iOS
gets these embedded on the user read (ENUser.CreateDto carries skill and
trainingGoal objects). We've added optional skill? / trainingGoal? to
UserReturnDto on the client — please confirm GET /users/{id} returns them
embedded (or tell us the correct source). The selectable reference lists
(GET /training_goals, GET /user_skills) and the goal set
(PUT /users/{id}/training_goal/{goalId}) are already wired and unchanged.
Client swap plan (for context — no backend action)
When the batch endpoints land, the frontend adds one function per range in the same module:
getUsersWeekHistoryStats(userIDs, from, to): Promise<Record<UUID, HistoryStatsReturnDtoV2>>
getUsersDayHistoryStats(userIDs, from, to): Promise<Record<UUID, HistoryStatsReturnDtoV2>>
beside the existing self-scoped getOwnWeekHistoryStats / getOwnDayHistoryStats.
The per-user DTO is unchanged, so the portal card simply reads the selected
athlete's entry — no DTO or UI churn. This is why we ask for the same
response DTO per user, just wrapped in a per-user list.