State management
The apps avoid a global state library. State falls into three categories, most
of it living in @enode/core.
1. Prefetched reference data — external stores
Caches like metrics, text-content, user-app-settings each use the same
pattern (packages/core/src/<domain>/):
- a module-level store (
store.ts:Map+ listener set +loadX/clearX), - read through
useSyncExternalStorehooks (hooks.ts), - an
<XLoader/>mounted in each app'slayout.tsx.
Rule: don't add React state for prefetched reference data — extend the store. These return DTOs stay read-only by design.
A reference catalogue is loaded once per launch and is otherwise not
revalidated: it changes on a release cadence, so refetching it whenever the app
is foregrounded would cost far more than the staleness it removes. The
exception is tracking/store.ts, which registers with registerRevalidation
(packages/core/src/store-kit/revalidation.ts) purely as a recovery
trigger — it retries on foreground/reconnect only when an earlier load came
back with no data, and skips both the idle state (never asked; the portal
shares this core and never preloads the catalogue) and any populated one.
Without it a device that booted faster than its network came up had no sensor
configuration until someone restarted the app by hand, which the tracking home
reports as a blocking dialog
(apps/tracking/src/app/workouts/today/tracking-settings-guard.tsx).
The same pattern also backs per-key caches of user-scoped reads, where the key is an id or an id + window rather than a whole catalogue:
users/profile-store.ts— one full user profile per id (GET /users/{id}). Shared by the user drawer's fetch-before-edit gate and its Summary + Goals tabs, so one open issues one read. Deliberately not registered for focus/reconnect revalidation: refetching a profile that is open in an editor risks clobbering in-progress input.users/week-stats-store.ts— weekly history stats per athlete + window.users/activity-store.ts— one athlete's completed sessions per week.
Each exports an invalidateX(userID) that REFRESHES the cached entry in place,
plus a forgetX(userID) that drops it (for a deleted entity, where a refetch
would 404). Refreshing rather than dropping is load-bearing: these caches are
read through hooks that load from an effect keyed on the id, so a dropped entry
has nothing left to re-trigger the fetch and a mounted view waits on a skeleton
forever. Same lesson as workouts/store.ts's invalidateMonth. Invalidation is not
always the resource you wrote: saving an athlete's muscle goals changes the
targets the backend computes their week stats from, so that write invalidates
the stats too. See user-drawer-data-flow.md.
2. Training write path — domain models
The session / set / rep / measurement write path uses the domain models in
packages/core/src/training/:
models.ts—WorkoutSession,WorkoutSet,WorkoutRep,Measurement.- immutable set/session mutations.
- builds reps from sensor events.
Models map to wire DTOs via core's api/mappers (toSessionCreateDto) only
at upload time, inside api/.
3. Local UI / feature state — hooks
App feature views keep transient UI state in co-located use-* hooks and React
state. State resets use the render-time prev-value pattern, not setState in
useEffect.
Read path vs. write path (summary)
| Direction | Representation | Crosses to DTO… |
|---|---|---|
| Read (reference data) | return DTOs, read-only | n/a |
| Write (training) | training/ domain models | only inside core's api/, at upload |
TODO: the workout return-DTO side becomes a domain model when workout editing (editable items/blocks) is built — deferred, not rejected.