User drawer — data flow and cost
The user drawer is the portal's per-user detail surface, opened from a row of the Users table. It hosts six tabs over four independent data sources, and each tab fetches and derives on its own. This page records what one open costs, what the client computes from what, and which layer each computation belongs in.
Companion reading: athlete-summary-backend-response.md
describes the HistoryStatsReturnDtoV2 aggregate the Summary tab renders;
state-management.md describes the store pattern the drawer's
caches follow.
What the user drawer is
UserDrawer in apps/portal/src/app/dashboard/users/user-drawer.tsx renders an
AvatarHeroDrawer inside a shared DrawerStack. It is rendered unconditionally with a
nullable target; useDrawerHost (apps/portal/src/components/use-drawer-host.ts) keeps
the panel mounted through its slide-out and hands back a sessionKey that remounts the
form per open.
It has three modes:
create— the form only, no tabs.edit— tabbed; the mode this page is about.draft—UserFormreused outsideUserDrawer, persisting to local state instead of the API. Used by the roster review (users/roster/draft-user-drawer.tsx) and the migration flow (migration/migration-user-drawer.tsx).
Call sites: the Users page (users/page.tsx, from the toolbar, a table row, the mobile
card, and the ?edit=<userId> deep link) and the planner's athlete picker
(planner/schedule-drawer.tsx, create-only).
Requirement: POR-USERS-02 in requirements/portal.md. System tests: POR-ST-5.2 and POR-ST-5.6 in testing/portal-system-tests.md.
Tabs and when they exist
| Tab | Shown when | Component |
|---|---|---|
| Details | always — the default tab; the only tab in create/draft | the UserForm body in user-drawer.tsx |
| Summary | edit · athlete | apps/portal/src/app/dashboard/athlete-summary-panel.tsx |
| Goals | edit · athlete | apps/portal/src/app/dashboard/muscle-goals-panel.tsx |
| Schedule | edit · athlete | users/upcoming-workouts.tsx, via the local ScheduleTab |
| Activity | edit · athlete | users/user-activity-tab.tsx |
| Assignments | edit · manager (facility → coaches + athletes; coach → athletes) | assignmentSections() in user-drawer.tsx |
A seventh tab, Overview, is present but commented out.
The Goals tab has two shapes. Until an athlete has either a training goal or a non-zero per-muscle target, it renders a single centred call to action to choose a goal — no goal card, no muscle map, no editors. The goal is what produces the targets, so there is nothing to fine-tune before one exists. Everything below is the populated shape.
Activity pushes a stacked sub-view, users/profile-drawer-view.tsx, when a session card's
"Inspect Profile" action is used. It renders the Profiles page's own pairing header and
analysis column (PairHeader + PairChartsColumn) at the reps drawer's width, so the
profile reads the same there as it does on the page.
Tab panels mount on first activation
A tab's panel mounts the first time that tab is opened, and stays mounted after
— hidden with className="hidden" rather than unmounted — so switching tabs
costs nothing and in-progress edits (the Goals tab's draft targets) survive.
The panels used to mount all at once on open, to warm their data in the
background. UserForm now does the warming directly, without the DOM: one
effect calls loadUserProfile, loadWeekStats, loadUserActivity and
prefetchUpcomingWorkouts when an athlete's drawer opens. The requests still
start immediately; opening a drawer to fix an email no longer also builds two
muscle-map SVGs and a LottieCircle player per muscle row.
One consequence of the old approach survives on purpose: muscle-goals-panel.tsx
scopes its muscle-rect lookup to a local mapRef, because the Summary tab's map
can still be mounted-and-hidden once both tabs have been visited, and a global
[data-muscle] query would match its 0×0 rect and pin the hover tooltip to the
corner.
Data sources per tab
| Tab | Reads | Endpoint | Cached |
|---|---|---|---|
| Header / form | the full user profile | GET /users/{id} | yes — users/profile-store.ts |
| Header avatar | the profile image | GET /users/profileImage/{id} | yes — permanent module cache |
| Summary | weekly history stats | POST /history/v2/week | yes — users/week-stats-store.ts |
| Summary | the athlete's muscle media | the same cached profile | shared |
| Goals | training goal + per-muscle goals | the same cached profile | shared |
| Goals | the goal catalogue (on picker open) | GET /training_goals | one-shot ref |
| Schedule | scheduled + recurring workouts | GET /workouts/schedules, GET /workouts/recurring | yes — workouts store |
| Activity | completed sessions this week | POST /workout_sessions/search | yes — users/activity-store.ts |
| Details | role catalogue, user catalogue, privileges | GET /user_roles, GET /users/all_roles, GET /users/{me} | yes — module stores |
| Assignments | one avatar per assignee | GET /users/profileImage/{id} × N | yes — permanent module cache |
Request timeline on opening an athlete
Every row is deduped by id/key and cached, so a reopen inside the freshness
window (REVALIDATE_MIN_AGE_MS, 15 s) issues nothing.
| # | Request | Fired by | Trigger | Cache | Payload | Blocks |
|---|---|---|---|---|---|---|
| 1 | GET /users/{id} | useFreshUser → loadUserProfile(id, { force: true }) | drawer host mount | users/profile-store.ts, keyed by id. force skips the freshness window — this is fetch-before-edit | full UserReturnDto, embeds recursive accessToUsers, muscles, trainingGoal | Save (freshBlocked) |
| 2 | POST /history/v2/week {userIDs:[id],from,to} | the drawer's warm effect, then useWeekStats | drawer open (athlete) | users/week-stats-store.ts, keyed userID:from:to | one HistoryStatsReturnDtoV2 | the Summary cards |
| 3 | POST /workout_sessions/search?per=100 | the drawer's warm effect, then useUserActivity | drawer open (athlete) | users/activity-store.ts, keyed userID:weekStart | up to 100 full SessionReturnDto trees | the Activity tab |
| 4 | workouts month(s) + recurring | prefetchUpcomingWorkouts() | drawer open, and Users-page enter | workouts/store.ts | usually free | the Schedule tab |
| 5 | GET /users/profileImage/{id} × N | AvatarUploadField, AthleteAvatar | hero + each Assignments row | permanent module cache | one per distinct user rendered | avatars |
| 6 | /user_roles, /users/all_roles, /users/{me}, /metrics, /exercises, /exercise_bases, /muscles | useRoles, useUsers, useHasPrivilege × 4, useSessionMetrics; /muscles via the Summary panel | drawer mount; /muscles on the first Summary open | store-cached (15 s min-age or load-once) | — | cold-cache first open only |
The Summary and Goals tabs read the same cached profile as row 1 rather than
fetching their own. Before the profile store they each issued a raw getUser,
so one open cost three identical reads of the heaviest user payload in the API —
and a training-goal change made it four.
Rows 2 and 3 are registered for focus / visibilitychange / reconnect
revalidation, so a drawer left open catches up when the admin returns. The
profile store deliberately is not: refetching the record currently being
edited risks clobbering in-progress input.
Invalidation on write
Caching only holds while nothing rewrites the data behind it, and one dependency is easy to miss: an athlete's per-muscle goals are what the backend computes the Summary tab's per-muscle targets from. A goals save, or a training-goal change, therefore makes the cached week stats wrong without ever writing to them.
| Write | Site | Effect |
|---|---|---|
| User save (create / update) | onPersist in user-drawer.tsx | refresh profile + week stats |
| Training-goal change | applyGoal in muscle-goals-panel.tsx | refresh week stats (it force-reloads the profile itself) |
| Archive / unarchive | onSetArchived in user-drawer.tsx | refresh profile + week stats + activity |
| Unassign | onUnassign in user-drawer.tsx | forget profile + week stats + activity; force-reload the signed-in user + the user list |
| Delete | onDelete in user-drawer.tsx | forget profile + week stats + activity |
Unassign is the odd one out: it writes the signed-in user's roster
(DELETE /users/{myID}/assigned_users), not the user in the drawer, so it also
force-reloads the current-user profile — its accessToUsers is what gates the
button — and re-reads /users/all_roles rather than dropping the row locally,
because whether the user stays visible depends on the manager's tier.
It forgets rather than invalidates, for a reason worth stating: a successful
GET /users/{id} folds the row back into the users catalogue via upsertUser
(see loadUserProfile). invalidateUserProfile refetches, so on a write that
takes the user out of your scope it races the list refresh and re-inserts the
row you just removed. Refresh is for a user you still manage; forget is for one
who has left — deleted, or unassigned.
invalidateX refreshes the cached entry in place; it does not drop it. That
distinction is load-bearing: every consumer loads from an effect keyed on the id
(and window), so a dropped entry has nothing left to re-trigger its fetch and the
view waits on a skeleton forever — which is exactly what a training-goal change
used to do to the Summary tab. The cached numbers keep rendering until the fresh
ones land. forgetX is the drop, used only where the entity is gone and a
refetch would 404.
Session completions are not invalidated: the portal has no signal for them. The focus revalidation above covers the realistic case — a coach tabbing back after an athlete trains.
The activity search is keyed by name, then re-filtered by id
user-activity-tab.tsx searches { searchItems: [{ tableId: "user", searchTerm: name }] }
and then keeps only s.user?.id === userId. This is a correctness fact, not only a cost
one. Three consequences:
- Two athletes sharing a name split one 100-row page between them, so either can be silently truncated.
- An athlete with a blank or whitespace-only name sends
searchItems: []— a search across the whole account's week. - The client pays to download every row it then discards.
The per: 100 page size cannot be reduced while the filter is name-based: a smaller page
would truncate the target athlete's rows in exactly the collision case above.
Transformation inventory
What the client computes, from what, for whom.
Summary tab transformations
| Input | Transformation | Result | Rendered by |
|---|---|---|---|
muscleUsage.values[] | weeklySetTotals + daysElapsedInWeek + weeklyGoalState (weekly-goal-banner.tsx) | a WeeklyGoalState, percent, days left | WeeklyGoalBanner, WeeklyGoalBadge |
muscleUsage.values[] + the /muscles catalogue | buildMuscleUsageVm → sortMusclesByCategory, bar scaleMax, completion percent, icon resolution | MuscleUsageVm | MuscleUsageCard |
UserReturnDto.muscles[].{id,media} | muscleMediaById | Record<muscleId, media> | fed into buildMuscleUsageVm |
muscleMapVolume.values[] | buildMuscleMapVm → normalizeMuscleMapValues + resolveMuscleKey (aliases backUpper → upperBack, …), then muscleUsageTier + top-3 per tier | MuscleMapVm | MuscleMapCard |
| a map part with no muscle of its own | muscleDataKey (MUSCLE_PART_SOURCE) — the part reads its source's data | e.g. sideCore → abdominals | the map's fill, highlight, hover and click |
volumeCompletion + zoneDistribution | zone entries + zoneColorVar + tonnage formatting via useUnits | ring segments + centre label | VolumeCompletionCard |
gridMetrics[] | useMetricCategory(metric.metricCategoryID) for the icon; units.formatAggregate(value, metric) (by dimension — a weekly roll-up is never a "6+" overflow measurement); round(trend × 100) | tiles | MetricGridCard |
Goals tab transformations
| Input | Transformation | Result | Rendered by |
|---|---|---|---|
UserReturnDto.{trainingGoal,muscles[]} | hasMuscleTargets — is any target non-zero? | whether the tab shows its setup CTA or its editors | MuscleGoalsPanel |
UserReturnDto.muscles[] | seedMuscleGoals | an editable MuscleReturnDto[] draft | MuscleGoalsPanel |
muscles[].volume | targetMuscleMapValues — normalize by the busiest muscle, then muscleFillFor | target heatmap | TargetMuscleMap |
muscles[].volume | totalTargetSets | total target sets | the Goals header |
| a row edit | setMuscleVolume / setMuscleZones / adjustMuscleVolumeByKey — all ID-keyed, all returning a new draft | staged MuscleUpdateDto[] | the drawer Save |
muscles[].{zone0,zone1,zone2} | round(z × 100) | percentage labels | MuscleGoalRow |
muscle.nameTextContentID | useTextContent | localized name | MuscleGoalRow, MuscleHoverTooltip |
trainingGoal.muscleGoals[] | applyGoalDefaults, matched by muscleKey | staged MuscleUpdateDto[] | the drawer Save |
draft + trainingGoal.muscleGoals[] | differsFromGoalDefaults — would applyGoalDefaults change anything? | whether the "reset to goal defaults" hint shows | MuscleGoalsPanel |
muscles[].volume | muscleMapLegendBands — the tier bounds scaled back out by the busiest target | the heatmap legend's per-tier set ranges | MuscleMapLegend |
Activity tab transformations
| Input | Transformation | Result | Rendered by |
|---|---|---|---|
SessionReturnDto | buildActivityRows → useSessionMetrics().resolve — definitionID → catalogue exercise → exerciseBaseID → displayBaseExerciseGroupID, plus the equipment group, plus a scan of every set's measurements to find the loading metric's dimension | SessionMetricInfo (focus metric ids, mass vs inertia, dimensions) | SessionHistoryCard props |
SessionReturnDto | buildActivityRows → summarizeSession (packages/core/src/training/exercise-overview.ts) — sort sets, Σ load × reps over working sets, highest load, Σ reps, mean set-start gap, first/last rep timestamps, per-set rows | SessionSummary | SessionHistoryCard |
pagedEntities[] | filtered by user.id in the activity store, then buildActivityRows sorts by summary.date desc | ActivityRow[] | UserActivityTab |
Schedule tab transformations
| Input | Transformation | Result | Rendered by |
|---|---|---|---|
| the workouts store | weekState, utcMonths, isMine(participants), recurringDate(weekday) | DayWorkout[] | UpcomingWorkouts |
DayWorkout[] | split on past, then day headings via sameDay | "Earlier this week" / "Upcoming" groups | WorkoutGroup |
Details and Assignments transformations
| Input | Transformation | Result | Rendered by |
|---|---|---|---|
accessToUsers[] | .map(u => u.id), cross-joined against the useUsers({includeArchived:true}) catalogue, split by role.key | per-role assignee rows | assignmentSections() |
bodyHeight (m) / bodyWeight (kg) | useUnits inside HeightField / WeightField | the admin's unit system | the Details form |
birthdate (Unix ms) | msToDateInput / dateInputToMs | yyyy-mm-dd | DatePicker |
Cost hot spots, and what they cost now
The by-id user read embeds a recursive user tree
UserReturnDto (packages/core/src/api/dtos/users.ts) carries
accessToUsers?: UserReturnDto[] — each element itself a full UserReturnDto.
For a facility user this is their whole hierarchy, on a request whose only use in
the drawer is to derive a list of ids. Nothing client-side can trim the response,
so the mitigation is to read it once per open instead of three times, and to
serve reopens from cache.
The session search returns full session trees to render summary cards
per: 100 sessions, each with its sets and each set with its measurements (reps
are not included), to display roughly eight numbers per card. Now cached per
athlete-week, so it is paid once rather than on every open — but the payload
itself is unchanged, and the page size can't shrink while the filter is by name
(see the section above).
Derivation is per data change, not per render
The tab panels are children of UserForm, which owns the form state, so every
keystroke in the Details tab re-renders them. That used to re-run, in the hidden
tabs, the whole Activity reduction over up to 100 sessions, the muscle-usage
sort, both heatmap normalizations and two tier passes — none of it memoized.
Three things fixed that, and all three are load-bearing together:
- Every tab panel is wrapped in
React.memo, so a keystroke that doesn't change its props doesn't reach it at all. - Each derivation runs inside
useMemoover the fetched data. useSessionMetrics()memoizes itsresolveclosure and its return object. It used to return a fresh closure per render, which would have made the Activity memo a permanent miss. Becauseresolvereads the exercise-base and metric catalogues synchronously,useExerciseBasesVersion()/useMetricsVersion()are explicit dependencies — otherwise it would keep serving results derived from an empty catalogue.
MuscleGoalRow is memoized too, which is what stops N useTextContent
subscriptions and N Lottie players re-rendering. That required moving its edit
handlers from array-index to muscle-ID keyed — an index-keyed callback is a fresh
closure per row per render, and is also wrong once the list re-sorts.
The muscles catalogue is fetched to order rows that already carry an order
The Summary panel loads /muscles and subscribes to useMusclesVersion() purely
to resolve groupNameTextContentID and order for the usage-row sort. The DTO's
own order is only a fallback for muscles missing from the catalogue.
One avatar request per assignee
The Assignments tab renders an AthleteAvatar per row, each resolving through
GET /users/profileImage/{id}. The profile-image store caches permanently and
reconciles against the user list, so this is N once per session, not N per open.
Where each transformation belongs
The drawer's data flows through three layers, each with one job:
| Layer | Location | Job |
|---|---|---|
| Transport + cache | packages/core/src/users/{profile,week-stats,activity}-store.ts | fetch each DTO once, cache it, dedupe concurrent callers |
| Preparation (pure) | athlete-summary-view-model.ts, muscle-goals-view-model.ts, users/user-activity-view-model.ts | DTO → render-ready view model; no hooks, no store reads, no t() |
| Rendering | the panels and cards, all React.memo | formatting, translation, interaction |
A computation belongs in the preparation layer when it is a pure function of fetched data: sorting, normalizing, tiering, summing, ranking, bar arithmetic, session reduction. These are the rows in the transformation inventory above that name a function.
A computation stays in the component when it depends on something the data doesn't carry:
| Concern | Why it stays |
|---|---|
| Unit conversion and formatting | metric/imperial is a per-admin preference read through useUnits at render time |
App-static copy via t(...) | only literal first arguments are extractable; a builder passing strings through would break extraction |
Server-entity names via useTextContent(uuid) | resolved by id against the text-content store so an over-the-air translation applies without a refetch |
| Local week bounds, "today", day headings | the browser owns the admin's timezone and the Monday-start week |
| Interaction state | hover key and tooltip rect, the usage card's collapse, the sets input's raw editing string, the active tab |
| Staged edits | editedMuscles → onMusclesChange → form.muscles, held until Save |
| Colors | zoneColorVar and the muscle-map tier tokens are design tokens, resolved light/dark at render |
| Privilege gating | useHasPrivilege decides which affordances render |
Backend-supplied display copy (title, detail on the history-stats blocks) passes
through the preparation layer raw. The component applies its own translated fallback —
muscleUsage?.title?.trim() || t("Muscle usage") — so an absent or empty server string
never reaches the screen untranslated.
An editable surface derives from its draft, not from the fetched DTO. The Goals tab's
heatmap and total-sets figure recompute from editedMuscles as a slider moves, so the map
reacts without a round trip while the underlying profile stays untouched until Save.
How these numbers were measured
Reproduce before re-dating this section — do not carry the figures forward on trust.
- Request counts and payload sizes: DevTools Network, filtered to the API base, opening one athlete cold (hard reload) and warm (second open).
- Endpoint latency: the portal's benchmark harness at
/dashboard/debug/benchmark, whose catalogue andbudgetMsbands live inapps/portal/src/app/dashboard/debug/benchmark/endpoints.ts. - Payload shape: the "Log data model" buttons on the Summary and Goals tabs.
- Render cost: React Profiler, recording while typing in the Details tab.
TODO: record Measured on: <date>, <environment>, <athlete with N sessions> and fill the
table below. Left empty deliberately — figures are only useful if they were observed.
| Request | Bytes | p50 | p95 |
|---|---|---|---|
GET /users/{id} | TODO: | TODO: | TODO: |
POST /history/v2/week | TODO: | TODO: | TODO: |
POST /workout_sessions/search?per=100 | TODO: | TODO: | TODO: |
Known limitations of the current API
Facts about what the client has to live with, not requests.
The session search has no id filter
POST /workout_sessions/search filters by search term against a table id, so the Activity
tab searches by the athlete's name and re-filters by id client-side. The client
contains the leak by storing only the filtered list, and keeps per: 100 because a smaller
page would truncate a name collision.
The by-id user read carries the caller's user hierarchy
accessToUsers is a recursive array of full users. The drawer reduces it to ids
immediately. Nothing client-side can trim the response, so the mitigation is to read it
once per open rather than three times.
Two analysis calls are unbounded from this drawer
POST /analysis/volume_over_time/data and POST /analysis/intensity_over_time/data are
sent from apps/portal/src/app/dashboard/data/performance/pair-charts.tsx. They start on
ALL_TIME, which sends from: null, to: null — the athlete's entire history. The drawer
now carries the Profiles page's own timeframe picker (PairChartsColumn), so a coach can
narrow them from here; pass a chartRange to ProfileDrawerView to start somewhere else.
They fire only behind the Activity tab's explicit "Inspect Profile" action, so they are
outside the per-open budget.
One drawn shape is not always one muscle
The map draws the obliques (sideCore) as their own shape, but the backend
models the whole core as a single abdominals muscle. MUSCLE_PART_SOURCE in
packages/core/src/muscle-map/keys.ts declares that relationship, and anything
turning a map part into data goes through muscleDataKey. Skip it and the part
goes inert silently — an absent usage value tints exactly like a zero one, so
the shape just sits grey. coloring.test.ts guards against a new part arriving
with no muscle behind it.
Workout completion is not readable
Sessions carry no workout id and workouts no per-participant state, so the Schedule tab dims past workouts and says nothing about whether they happened. An earlier day-level inference from the session search was removed for claiming more than the data supports.
Related documents
- athlete-summary-backend-response.md — the
HistoryStatsReturnDtoV2aggregate and its batch endpoints - athlete-summary-backend-handoff.md — the request that produced them
- state-management.md — the external-store pattern the caches follow
- error-handling.md — dispositions, presenters,
RequireData - requirements/portal.md — POR-USERS-02
- testing/portal-system-tests.md — POR-ST-5.2, POR-ST-5.6