{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/gym",
  "slug": "packages/gym",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/gym",
  "name": "@leumas/gym",
  "eyebrow": null,
  "chip": null,
  "summary": "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...",
  "keywords": [
    "gym",
    "deloads",
    "streak",
    "324-exercise",
    "fitnotes",
    "gym api",
    "leumas gym",
    "how to use gym"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# @leumas/gym\n\nThe gym engine: everything a self-hosted gym needs that is not storage, transport or pixels.\n\nZero dependencies, pure isomorphic ESM. The Studio session runner imports it in the browser;\n`/api/gym`, the `gym` app's actions and MCP import it on the server. There is exactly one copy of\nthis arithmetic, which is the point — a training rule that lives in two places is a rule that will\ndisagree with itself the first time one copy is fixed.\n\n**SPEC-ONLY provenance: no openGym source was copied.** See [ATTRIBUTION.md](./ATTRIBUTION.md),\nwhich also carries the licence boundary around exercise media. Read it before adding data.\n\n## What it absorbs\n\n| Area | Entry point | What it answers |\n|---|---|---|\n| Progression | `@leumas/gym/progression` | Given everything logged, what should the next session prescribe — and why? |\n| Estimated 1RM | `estimate1RM`, `e1rmSeries`, `is1RMRecord` | How strong is this lift, and did today beat it? |\n| Effort | `effortSummary`, `effortHistogram` | How hard was the training, and how much of it was even rated? |\n| Volume | `muscleVolume`, `weeklyVolume`, `sessionTotals` | How much work, and which muscles got it? |\n| History | `activityHeatmap`, `streaks`, `personalRecords`, `bodyweightSeries` | What has happened over a year? |\n| Catalog | `@leumas/gym/catalog` | 1,324 exercises: normalise, search, resolve a free-text name. |\n| Muscles | `@leumas/gym/muscles` | Which muscles an exercise trains, and how to shade a body map. |\n| Import | `@leumas/gym/import` | FitNotes, Strong, Hevy, Apple Health — as a proposal, never a write. |\n| Planning | `buildStarter`, `weekOf`, `reschedule`, `expandSession` | Starter programs, the week, and turning a plan day into sets. |\n\n## The four progression policies\n\nAll four share one signature and one judgement.\n\n```js\nimport { next } from '@leumas/gym/progression';\n\nconst prescription = next({ history, config: routineItem, routine, unit: 'kg' });\n// { policy, kind, weight, reps, sets, seconds, deloaded, reason }\n```\n\n- **linear** — every rep of every set and the weight goes up; three misses deload 10%.\n- **greyskull** — two straight sets and a last set to failure; double the target there and the jump\n  doubles; **one** failure resets 10%.\n- **double** — climb a rep range at one weight, then add load and drop back to the floor.\n- **timed** — no load to add, so the duration climbs; deloads in whole 5s steps.\n\n`kind` is `off | first | up | hold | deload`. A field the policy has no opinion on comes back\n`undefined` — not `0` — so the caller keeps what the plan said. `reason` is `{ code, text, values }`:\na stable i18n key plus an already-interpolated English sentence, because a suggestion you cannot\naudit is one you stop trusting.\n\n## Three things that are easy to get wrong\n\n**Reading a session honestly is the whole game.** A completed set with fewer reps than its target is\na miss. A set never ticked off is a miss. Fewer sets than prescribed is a miss. Soften any of those\nand a session that fell apart advances the load as though it had succeeded. See\n`src/progression/judge.js`.\n\n**A set carries the target it was prescribed.** `expandSession` stamps `targetWeight`/`targetReps`/\n`targetSeconds`/`targetSets` onto every generated row. A set with no stamped target cannot be scored\nagainst anything, and history full of them makes every past session read as a miss — which greets a\nlong-standing member with \"missed reps eleven sessions running, deload\".\n\n**Bodyweight is decided by the LOGGED weight, not by a flag.** A dip done with a belt has a load to\nprogress and belongs on the normal policies; a barbell lift logged at 0 has nothing to add to.\n\n## Day boundaries\n\nEvery function that buckets by day takes `utcOffsetMinutes` and defaults it to **0 (UTC), not the\nserver offset**. Passing the member's offset is what makes \"today\" mean their today; a session\nlogged at 22:30 local is the next day in UTC, which draws the heatmap one square off and breaks a\nstreak nobody broke. The default is UTC rather than the server's because a wrong answer that moves\nwhen you redeploy is worse than one that is wrong the same way twice.\n\n## Regenerating the catalog\n\n```bash\ngit clone --depth 1 --filter=blob:none --sparse https://github.com/hasaneyldrm/exercises-dataset\ngit -C exercises-dataset sparse-checkout set data      # data/ ONLY — never images/ or videos/\nnode ops/tools/gym-catalog/index.mjs --source exercises-dataset/data/exercises.json\nnode ops/tools/gym-catalog/index.mjs --source exercises-dataset/data/exercises.json --check\n```\n\nOutput is deterministic, so a dataset refresh is a reviewable diff rather than a blob nobody reads.\n\n## Tests\n\n```bash\npnpm --filter @leumas/gym test\n```\n\n137 tests, including every starter program resolving against the real 1,324-exercise catalog and\nevery muscle spelling in the shipped data resolving through `ALIAS` — so a dataset refresh that\nintroduces a new word fails the build instead of quietly dropping an exercise off the body map.\n",
  "source": {
    "path": "shared/packages/gym/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 5167,
    "hash": "ae069769dadeaafd64a748b7a096535d5678ef97"
  },
  "urls": {
    "html": "/p/packages/gym",
    "json": "/docs/packages/gym.json",
    "md": "/docs/packages/gym.md"
  },
  "links": {
    "composes": [],
    "usedBy": [
      "pkg:@leumas/studio"
    ],
    "product": [
      "pkg:@leumas/studio"
    ],
    "howTo": [],
    "skills": []
  },
  "exports": {
    "total": 132,
    "component": 37,
    "hook": 0,
    "helper": 95,
    "names": [
      {
        "n": "ALIAS",
        "k": "component"
      },
      {
        "n": "ALIAS",
        "k": "component"
      },
      {
        "n": "BARS",
        "k": "component"
      },
      {
        "n": "BUCKETS",
        "k": "component"
      },
      {
        "n": "BY_BODY_PART",
        "k": "component"
      },
      {
        "n": "BY_BODY_PART",
        "k": "component"
      },
      {
        "n": "DAYS",
        "k": "component"
      },
      {
        "n": "DEFAULT_FORMULA",
        "k": "component"
      },
      {
        "n": "DEFAULT_SECONDS_INCREMENT",
        "k": "component"
      },
      {
        "n": "DEFAULT_SECONDS_INCREMENT",
        "k": "component"
      },
      {
        "n": "DELOAD_AFTER",
        "k": "component"
      },
      {
        "n": "DELOAD_AFTER",
        "k": "component"
      },
      {
        "n": "DELOAD_FACTOR",
        "k": "component"
      },
      {
        "n": "DELOAD_FACTOR",
        "k": "component"
      },
      {
        "n": "FORMULAS",
        "k": "component"
      },
      {
        "n": "HARD_RIR",
        "k": "component"
      },
      {
        "n": "HEAVY_BODY_PARTS",
        "k": "component"
      },
      {
        "n": "HEAVY_BODY_PARTS",
        "k": "component"
      },
      {
        "n": "INERT",
        "k": "component"
      },
      {
        "n": "INERT",
        "k": "component"
      },
      {
        "n": "KG_PLATES",
        "k": "component"
      },
      {
        "n": "LB_PLATES",
        "k": "component"
      },
      {
        "n": "LB_TO_KG",
        "k": "component"
      },
      {
        "n": "LB_TO_KG",
        "k": "component"
      },
      {
        "n": "MAX_BODYWEIGHT_SETS",
        "k": "component"
      },
      {
        "n": "MAX_BODYWEIGHT_SETS",
        "k": "component"
      },
      {
        "n": "MIN_RATED",
        "k": "component"
      },
      {
        "n": "MUSCLE_LABEL",
        "k": "component"
      },
      {
        "n": "MUSCLE_LABEL",
        "k": "component"
      },
      {
        "n": "MUSCLES",
        "k": "component"
      },
      {
        "n": "MUSCLES",
        "k": "component"
      },
      {
        "n": "REP_CAP",
        "k": "component"
      },
      {
        "n": "SECONDARY_WEIGHT",
        "k": "component"
      },
      {
        "n": "SECONDARY_WEIGHT",
        "k": "component"
      },
      {
        "n": "SECONDS_STEP",
        "k": "component"
      },
      {
        "n": "SECONDS_STEP",
        "k": "component"
      },
      {
        "n": "STARTER_PROGRAMS",
        "k": "component"
      },
      {
        "n": "activityHeatmap",
        "k": "helper"
      },
      {
        "n": "applyPrescription",
        "k": "helper"
      },
      {
        "n": "applyPrescription",
        "k": "helper"
      },
      {
        "n": "atRisk",
        "k": "helper"
      },
      {
        "n": "best1RM",
        "k": "helper"
      },
      {
        "n": "bestSetOf",
        "k": "helper"
      },
      {
        "n": "bodyweightSeries",
        "k": "helper"
      },
      {
        "n": "buildStarter",
        "k": "helper"
      },
      {
        "n": "dayKey",
        "k": "helper"
      },
      {
        "n": "defaultIncrement",
        "k": "helper"
      },
      {
        "n": "defaultIncrement",
        "k": "helper"
      },
      {
        "n": "deloadTo",
        "k": "helper"
      },
      {
        "n": "deloadTo",
        "k": "helper"
      },
      {
        "n": "describeLoading",
        "k": "helper"
      },
      {
        "n": "detectSource",
        "k": "helper"
      },
      {
        "n": "detectSource",
        "k": "helper"
      },
      {
        "n": "displayScale",
        "k": "helper"
      },
      {
        "n": "e1rmSeries",
        "k": "helper"
      },
      {
        "n": "effortHistogram",
        "k": "helper"
      },
      {
        "n": "effortSummary",
        "k": "helper"
      },
      {
        "n": "estimate1RM",
        "k": "helper"
      },
      {
        "n": "expandSession",
        "k": "helper"
      },
      {
        "n": "facetsOf",
        "k": "helper"
      },
      {
        "n": "facetsOf",
        "k": "helper"
      },
      {
        "n": "fill",
        "k": "helper"
      },
      {
        "n": "filterExercises",
        "k": "helper"
      },
      {
        "n": "filterExercises",
        "k": "helper"
      },
      {
        "n": "goalProgress",
        "k": "helper"
      },
      {
        "n": "goalsWithProgress",
        "k": "helper"
      },
      {
        "n": "hasEffort",
        "k": "helper"
      },
      {
        "n": "importAppleHealthBodyweight",
        "k": "helper"
      },
      {
        "n": "importAppleHealthBodyweight",
        "k": "helper"
      },
      {
        "n": "importWorkouts",
        "k": "helper"
      },
      {
        "n": "importWorkouts",
        "k": "helper"
      },
      {
        "n": "instructionsFor",
        "k": "helper"
      },
      {
        "n": "instructionsFor",
        "k": "helper"
      },
      {
        "n": "is1RMRecord",
        "k": "helper"
      },
      {
        "n": "isBodyweight",
        "k": "helper"
      },
      {
        "n": "isBodyweight",
        "k": "helper"
      },
      {
        "n": "isHardSet",
        "k": "helper"
      },
      {
        "n": "levelsOf",
        "k": "helper"
      },
      {
        "n": "levelsOf",
        "k": "helper"
      },
      {
        "n": "loadBar",
        "k": "helper"
      },
      {
        "n": "loadOf",
        "k": "helper"
      },
      {
        "n": "loadOf",
        "k": "helper"
      },
      {
        "n": "mapHeader",
        "k": "helper"
      },
      {
        "n": "mapHeader",
        "k": "helper"
      },
      {
        "n": "mediaUrl",
        "k": "helper"
      },
      {
        "n": "mediaUrl",
        "k": "helper"
      },
      {
        "n": "missedThisWeek",
        "k": "helper"
      },
      {
        "n": "modeOf",
        "k": "helper"
      },
      {
        "n": "modeOf",
        "k": "helper"
      },
      {
        "n": "musclesOf",
        "k": "helper"
      },
      {
        "n": "musclesOf",
        "k": "helper"
      },
      {
        "n": "muscleVolume",
        "k": "helper"
      },
      {
        "n": "nearestLoadable",
        "k": "helper"
      },
      {
        "n": "next",
        "k": "helper"
      },
      {
        "n": "next",
        "k": "helper"
      },
      {
        "n": "normalizeCatalog",
        "k": "helper"
      },
      {
        "n": "normalizeCatalog",
        "k": "helper"
      },
      {
        "n": "normalizeExercise",
        "k": "helper"
      },
      {
        "n": "normalizeExercise",
        "k": "helper"
      },
      {
        "n": "parseCsv",
        "k": "helper"
      },
      {
        "n": "parseCsv",
        "k": "helper"
      },
      {
        "n": "personalRecords",
        "k": "helper"
      },
      {
        "n": "plannedMuscleVolume",
        "k": "helper"
      },
      {
        "n": "policyFor",
        "k": "helper"
      },
      {
        "n": "policyFor",
        "k": "helper"
      },
      {
        "n": "rankOf",
        "k": "helper"
      },
      {
        "n": "rankOf",
        "k": "helper"
      },
      {
        "n": "readSession",
        "k": "helper"
      },
      {
        "n": "readSession",
        "k": "helper"
      },
      {
        "n": "repStep",
        "k": "helper"
      },
      {
        "n": "repStep",
        "k": "helper"
      },
      {
        "n": "reschedule",
        "k": "helper"
      },
      {
        "n": "resolveExercise",
        "k": "helper"
      },
      {
        "n": "resolveExercise",
        "k": "helper"
      },
      {
        "n": "restAfter",
        "k": "helper"
      },
      {
        "n": "rirOf",
        "k": "helper"
      },
      {
        "n": "sessionsFor",
        "k": "helper"
      },
      {
        "n": "sessionsFor",
        "k": "helper"
      },
      {
        "n": "sessionTotals",
        "k": "helper"
      },
      {
        "n": "snap",
        "k": "helper"
      },
      {
        "n": "snap",
        "k": "helper"
      },
      {
        "n": "stallCount",
        "k": "helper"
      },
      {
        "n": "stallCount",
        "k": "helper"
      },
      {
        "n": "stepEffort",
        "k": "helper"
      },
      {
        "n": "streaks",
        "k": "helper"
      },
      {
        "n": "targetOf",
        "k": "helper"
      },
      {
        "n": "targetOf",
        "k": "helper"
      },
      {
        "n": "toKg",
        "k": "helper"
      },
      {
        "n": "toKg",
        "k": "helper"
      },
      {
        "n": "toScale",
        "k": "helper"
      },
      {
        "n": "weeklyVolume",
        "k": "helper"
      },
      {
        "n": "weekOf",
        "k": "helper"
      }
    ]
  }
}
