Skip to main content

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 — UserForm reused outside UserDrawer, 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​

TabShown whenComponent
Detailsalways — the default tab; the only tab in create/draftthe UserForm body in user-drawer.tsx
Summaryedit · athleteapps/portal/src/app/dashboard/athlete-summary-panel.tsx
Goalsedit · athleteapps/portal/src/app/dashboard/muscle-goals-panel.tsx
Scheduleedit · athleteusers/upcoming-workouts.tsx, via the local ScheduleTab
Activityedit · athleteusers/user-activity-tab.tsx
Assignmentsedit · 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​

TabReadsEndpointCached
Header / formthe full user profileGET /users/{id}yes — users/profile-store.ts
Header avatarthe profile imageGET /users/profileImage/{id}yes — permanent module cache
Summaryweekly history statsPOST /history/v2/weekyes — users/week-stats-store.ts
Summarythe athlete's muscle mediathe same cached profileshared
Goalstraining goal + per-muscle goalsthe same cached profileshared
Goalsthe goal catalogue (on picker open)GET /training_goalsone-shot ref
Schedulescheduled + recurring workoutsGET /workouts/schedules, GET /workouts/recurringyes — workouts store
Activitycompleted sessions this weekPOST /workout_sessions/searchyes — users/activity-store.ts
Detailsrole catalogue, user catalogue, privilegesGET /user_roles, GET /users/all_roles, GET /users/{me}yes — module stores
Assignmentsone avatar per assigneeGET /users/profileImage/{id} × Nyes — 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.

#RequestFired byTriggerCachePayloadBlocks
1GET /users/{id}useFreshUser → loadUserProfile(id, { force: true })drawer host mountusers/profile-store.ts, keyed by id. force skips the freshness window — this is fetch-before-editfull UserReturnDto, embeds recursive accessToUsers, muscles, trainingGoalSave (freshBlocked)
2POST /history/v2/week {userIDs:[id],from,to}the drawer's warm effect, then useWeekStatsdrawer open (athlete)users/week-stats-store.ts, keyed userID:from:toone HistoryStatsReturnDtoV2the Summary cards
3POST /workout_sessions/search?per=100the drawer's warm effect, then useUserActivitydrawer open (athlete)users/activity-store.ts, keyed userID:weekStartup to 100 full SessionReturnDto treesthe Activity tab
4workouts month(s) + recurringprefetchUpcomingWorkouts()drawer open, and Users-page enterworkouts/store.tsusually freethe Schedule tab
5GET /users/profileImage/{id} × NAvatarUploadField, AthleteAvatarhero + each Assignments rowpermanent module cacheone per distinct user renderedavatars
6/user_roles, /users/all_roles, /users/{me}, /metrics, /exercises, /exercise_bases, /musclesuseRoles, useUsers, useHasPrivilege × 4, useSessionMetrics; /muscles via the Summary paneldrawer mount; /muscles on the first Summary openstore-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.

WriteSiteEffect
User save (create / update)onPersist in user-drawer.tsxrefresh profile + week stats
Training-goal changeapplyGoal in muscle-goals-panel.tsxrefresh week stats (it force-reloads the profile itself)
Archive / unarchiveonSetArchived in user-drawer.tsxrefresh profile + week stats + activity
UnassignonUnassign in user-drawer.tsxforget profile + week stats + activity; force-reload the signed-in user + the user list
DeleteonDelete in user-drawer.tsxforget 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:

  1. Two athletes sharing a name split one 100-row page between them, so either can be silently truncated.
  2. An athlete with a blank or whitespace-only name sends searchItems: [] — a search across the whole account's week.
  3. 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​

InputTransformationResultRendered by
muscleUsage.values[]weeklySetTotals + daysElapsedInWeek + weeklyGoalState (weekly-goal-banner.tsx)a WeeklyGoalState, percent, days leftWeeklyGoalBanner, WeeklyGoalBadge
muscleUsage.values[] + the /muscles cataloguebuildMuscleUsageVm → sortMusclesByCategory, bar scaleMax, completion percent, icon resolutionMuscleUsageVmMuscleUsageCard
UserReturnDto.muscles[].{id,media}muscleMediaByIdRecord<muscleId, media>fed into buildMuscleUsageVm
muscleMapVolume.values[]buildMuscleMapVm → normalizeMuscleMapValues + resolveMuscleKey (aliases backUpper → upperBack, …), then muscleUsageTier + top-3 per tierMuscleMapVmMuscleMapCard
a map part with no muscle of its ownmuscleDataKey (MUSCLE_PART_SOURCE) — the part reads its source's datae.g. sideCore → abdominalsthe map's fill, highlight, hover and click
volumeCompletion + zoneDistributionzone entries + zoneColorVar + tonnage formatting via useUnitsring segments + centre labelVolumeCompletionCard
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)tilesMetricGridCard

Goals tab transformations​

InputTransformationResultRendered by
UserReturnDto.{trainingGoal,muscles[]}hasMuscleTargets — is any target non-zero?whether the tab shows its setup CTA or its editorsMuscleGoalsPanel
UserReturnDto.muscles[]seedMuscleGoalsan editable MuscleReturnDto[] draftMuscleGoalsPanel
muscles[].volumetargetMuscleMapValues — normalize by the busiest muscle, then muscleFillFortarget heatmapTargetMuscleMap
muscles[].volumetotalTargetSetstotal target setsthe Goals header
a row editsetMuscleVolume / setMuscleZones / adjustMuscleVolumeByKey — all ID-keyed, all returning a new draftstaged MuscleUpdateDto[]the drawer Save
muscles[].{zone0,zone1,zone2}round(z × 100)percentage labelsMuscleGoalRow
muscle.nameTextContentIDuseTextContentlocalized nameMuscleGoalRow, MuscleHoverTooltip
trainingGoal.muscleGoals[]applyGoalDefaults, matched by muscleKeystaged MuscleUpdateDto[]the drawer Save
draft + trainingGoal.muscleGoals[]differsFromGoalDefaults — would applyGoalDefaults change anything?whether the "reset to goal defaults" hint showsMuscleGoalsPanel
muscles[].volumemuscleMapLegendBands — the tier bounds scaled back out by the busiest targetthe heatmap legend's per-tier set rangesMuscleMapLegend

Activity tab transformations​

InputTransformationResultRendered by
SessionReturnDtobuildActivityRows → 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 dimensionSessionMetricInfo (focus metric ids, mass vs inertia, dimensions)SessionHistoryCard props
SessionReturnDtobuildActivityRows → 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 rowsSessionSummarySessionHistoryCard
pagedEntities[]filtered by user.id in the activity store, then buildActivityRows sorts by summary.date descActivityRow[]UserActivityTab

Schedule tab transformations​

InputTransformationResultRendered by
the workouts storeweekState, utcMonths, isMine(participants), recurringDate(weekday)DayWorkout[]UpcomingWorkouts
DayWorkout[]split on past, then day headings via sameDay"Earlier this week" / "Upcoming" groupsWorkoutGroup

Details and Assignments transformations​

InputTransformationResultRendered by
accessToUsers[].map(u => u.id), cross-joined against the useUsers({includeArchived:true}) catalogue, split by role.keyper-role assignee rowsassignmentSections()
bodyHeight (m) / bodyWeight (kg)useUnits inside HeightField / WeightFieldthe admin's unit systemthe Details form
birthdate (Unix ms)msToDateInput / dateInputToMsyyyy-mm-ddDatePicker

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 useMemo over the fetched data.
  • useSessionMetrics() memoizes its resolve closure and its return object. It used to return a fresh closure per render, which would have made the Activity memo a permanent miss. Because resolve reads 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:

LayerLocationJob
Transport + cachepackages/core/src/users/{profile,week-stats,activity}-store.tsfetch 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.tsDTO → render-ready view model; no hooks, no store reads, no t()
Renderingthe panels and cards, all React.memoformatting, 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:

ConcernWhy it stays
Unit conversion and formattingmetric/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 headingsthe browser owns the admin's timezone and the Monday-start week
Interaction statehover key and tooltip rect, the usage card's collapse, the sets input's raw editing string, the active tab
Staged editseditedMuscles → onMusclesChange → form.muscles, held until Save
ColorszoneColorVar and the muscle-map tier tokens are design tokens, resolved light/dark at render
Privilege gatinguseHasPrivilege 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 and budgetMs bands live in apps/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.

RequestBytesp50p95
GET /users/{id}TODO:TODO:TODO:
POST /history/v2/weekTODO:TODO:TODO:
POST /workout_sessions/search?per=100TODO: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.