@leumas/gym
Gym engine — a clean-room implementation of self-hosted strength training: four progression policies (linear, Greyskull LP, double, timed) with stall detection and deloads, estimated 1RM, RIR/RPE...
@leumas/gym
The gym engine: everything a self-hosted gym needs that is not storage, transport or pixels.
Zero dependencies, pure isomorphic ESM. The Studio session runner imports it in the browser; /api/gym, the gym app's actions and MCP import it on the server. There is exactly one copy of this arithmetic, which is the point — a training rule that lives in two places is a rule that will disagree with itself the first time one copy is fixed.
SPEC-ONLY provenance: no openGym source was copied. See ATTRIBUTION.md, which also carries the licence boundary around exercise media. Read it before adding data.
What it absorbs
| Area | Entry point | What it answers |
|---|---|---|
| Progression | @leumas/gym/progression | Given everything logged, what should the next session prescribe — and why? |
| Estimated 1RM | estimate1RM, e1rmSeries, is1RMRecord | How strong is this lift, and did today beat it? |
| Effort | effortSummary, effortHistogram | How hard was the training, and how much of it was even rated? |
| Volume | muscleVolume, weeklyVolume, sessionTotals | How much work, and which muscles got it? |
| History | activityHeatmap, streaks, personalRecords, bodyweightSeries | What has happened over a year? |
| Catalog | @leumas/gym/catalog | 1,324 exercises: normalise, search, resolve a free-text name. |
| Muscles | @leumas/gym/muscles | Which muscles an exercise trains, and how to shade a body map. |
| Import | @leumas/gym/import | FitNotes, Strong, Hevy, Apple Health — as a proposal, never a write. |
| Planning | buildStarter, weekOf, reschedule, expandSession | Starter programs, the week, and turning a plan day into sets. |
The four progression policies
All four share one signature and one judgement.
import { next } from '@leumas/gym/progression';
const prescription = next({ history, config: routineItem, routine, unit: 'kg' });
// { policy, kind, weight, reps, sets, seconds, deloaded, reason }
- linear — every rep of every set and the weight goes up; three misses deload 10%.
- greyskull — two straight sets and a last set to failure; double the target there and the jump
doubles; one failure resets 10%.
- double — climb a rep range at one weight, then add load and drop back to the floor.
- timed — no load to add, so the duration climbs; deloads in whole 5s steps.
kind is off | first | up | hold | deload. A field the policy has no opinion on comes back undefined — not 0 — so the caller keeps what the plan said. reason is { code, text, values }: a stable i18n key plus an already-interpolated English sentence, because a suggestion you cannot audit is one you stop trusting.
Three things that are easy to get wrong
Reading a session honestly is the whole game. A completed set with fewer reps than its target is a miss. A set never ticked off is a miss. Fewer sets than prescribed is a miss. Soften any of those and a session that fell apart advances the load as though it had succeeded. See src/progression/judge.js.
A set carries the target it was prescribed. expandSession stamps targetWeight/targetReps/ targetSeconds/targetSets onto every generated row. A set with no stamped target cannot be scored against anything, and history full of them makes every past session read as a miss — which greets a long-standing member with "missed reps eleven sessions running, deload".
Bodyweight is decided by the LOGGED weight, not by a flag. A dip done with a belt has a load to progress and belongs on the normal policies; a barbell lift logged at 0 has nothing to add to.
Day boundaries
Every function that buckets by day takes utcOffsetMinutes and defaults it to 0 (UTC), not the server offset. Passing the member's offset is what makes "today" mean their today; a session logged at 22:30 local is the next day in UTC, which draws the heatmap one square off and breaks a streak nobody broke. The default is UTC rather than the server's because a wrong answer that moves when you redeploy is worse than one that is wrong the same way twice.
Regenerating the catalog
git clone --depth 1 --filter=blob:none --sparse https://github.com/hasaneyldrm/exercises-dataset
git -C exercises-dataset sparse-checkout set data # data/ ONLY — never images/ or videos/
node ops/tools/gym-catalog/index.mjs --source exercises-dataset/data/exercises.json
node ops/tools/gym-catalog/index.mjs --source exercises-dataset/data/exercises.json --check
Output is deterministic, so a dataset refresh is a reviewable diff rather than a blob nobody reads.
Tests
pnpm --filter @leumas/gym test
137 tests, including every starter program resolving against the real 1,324-exercise catalog and every muscle spelling in the shipped data resolving through ALIAS — so a dataset refresh that introduces a new word fails the build instead of quietly dropping an exercise off the body map.
What it exports
132 exports: 37 components, 0 hooks, 95 helpers. Import from @leumas/gym.
ALIASALIASBARSBUCKETSBY_BODY_PARTBY_BODY_PARTDAYSDEFAULT_FORMULADEFAULT_SECONDS_INCREMENTDEFAULT_SECONDS_INCREMENTDELOAD_AFTERDELOAD_AFTERDELOAD_FACTORDELOAD_FACTORFORMULASHARD_RIRHEAVY_BODY_PARTSHEAVY_BODY_PARTSINERTINERTKG_PLATESLB_PLATESLB_TO_KGLB_TO_KGMAX_BODYWEIGHT_SETSMAX_BODYWEIGHT_SETSMIN_RATEDMUSCLE_LABELMUSCLE_LABELMUSCLESMUSCLESREP_CAPSECONDARY_WEIGHTSECONDARY_WEIGHTSECONDS_STEPSECONDS_STEPSTARTER_PROGRAMSactivityHeatmapapplyPrescriptionapplyPrescriptionatRiskbest1RMbestSetOfbodyweightSeriesbuildStarterdayKeydefaultIncrementdefaultIncrementdeloadTodeloadTodescribeLoadingdetectSourcedetectSourcedisplayScalee1rmSerieseffortHistogrameffortSummaryestimate1RMexpandSessionfacetsOffacetsOffillfilterExercisesfilterExercisesgoalProgressgoalsWithProgresshasEffortimportAppleHealthBodyweightimportAppleHealthBodyweightimportWorkoutsimportWorkoutsinstructionsForinstructionsForis1RMRecordisBodyweightisBodyweightisHardSetlevelsOflevelsOfloadBarloadOfloadOfmapHeadermapHeadermediaUrlmediaUrlmissedThisWeekmodeOfmodeOfmusclesOfmusclesOfmuscleVolumenearestLoadablenextnextnormalizeCatalognormalizeCatalognormalizeExercisenormalizeExerciseparseCsvparseCsvpersonalRecordsplannedMuscleVolumepolicyForpolicyForrankOfrankOfreadSessionreadSessionrepSteprepSteprescheduleresolveExerciseresolveExerciserestAfterrirOfsessionsForsessionsForsessionTotalssnapsnapstallCountstallCountstepEffortstreakstargetOftargetOftoKgtoKgtoScaleweeklyVolumeweekOf