# @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...


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](./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.

```js
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

```bash
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

```bash
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.


---
Source: shared/packages/gym/README.md
Canonical: https://docs.leumas.tech/p/packages/gym
