Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 9 additions & 4 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ test:api:
# change that forgets to regenerate it publishes a reference for the previous version.
- npm ci
- node scripts/build-api-docs.mjs --check
# api/coach/core is imported by the server under bare node AND by the phone under Vite.
# api/coach/core, api/engine and api/migration are imported by the server under bare node AND
# by the phone under Vite.
# vitest forgives what node does not (?raw, import.meta.glob); this runs outside vitest.
- node api/scripts/check-core-loadable.mjs
coverage: '/all files\s*\|\s*(\d+\.?\d*)/'
Expand Down Expand Up @@ -467,9 +468,9 @@ build:api-check:
when: manual
allow_failure: true

# The web image is built from the repository root, and the frontend imports the Coach core from
# api/coach/core — a path the Dockerfile has to copy by hand. Nothing in test:frontend or the
# Pages build goes through that Dockerfile, so an MR could pass everything and still ship an
# The web image is built from the repository root, and the frontend imports api/coach/core,
# api/engine and api/migration — paths the Dockerfile has to copy by hand. Nothing in
# test:frontend or the Pages build goes through that Dockerfile, so an MR could pass everything and still ship an
# image that does not build (v1.2.15 did). Built here, never pushed.
build:web-check:
extends: .docker
Expand All @@ -495,6 +496,8 @@ build:web-check:
- "frontend/**/*"
- "web/**/*"
- "api/coach/core/**/*"
- "api/engine/**/*"
- "api/migration/**/*"
- ".gitlab-ci.yml"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: manual
Expand Down Expand Up @@ -822,6 +825,8 @@ pages:
paths:
- "frontend/**/*"
- "api/coach/core/**/*"
- "api/engine/**/*"
- "api/migration/**/*"
- ".gitlab-ci.yml"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased

**Configurable progression programs.** A plan rule is now a program: phases of set groups, the
steps that move them, when a phase ends and what happens at the end of a cycle. The progressions you
know are templates of it and behave as before (manual, autoregulated and duration are now one
**Autoregulated** rule you can switch to seconds; pyramid and reverse pyramid are one **Pyramid** with a
direction), and three new ones join them:

- **Top set and back-off** — one heavy set, then lighter back-off sets at a percentage, each with its
own rest; the top set (or every set, if you prefer) decides the next load.
- **Accumulation then intensification** — reps climb at a lighter share of your training max, then a
heavier block of fewer reps; start over with a heavier training max, or finish.
- **Density** — the same work with a little less rest after every clean session, down to a floor.

Fixed on the way: triple progression and a ladder with named variations could never reach their next
step; a bodyweight exercise that gained weight went back to the rep climb every other session (and
after migrating, a bodyweight double climbed the wrong rep range); a ladder session that fell short was
judged against the plan's minimum rather than the target it asked for, as v1 did. Steps are now
earned when a session finishes, and a set's own rest drives the timer.

## v1.3.10 (2026-10-07)

This one grew. The plan was a queue and a rotation; along the way the whole app got a calmer look,
Expand Down
97 changes: 79 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ frontend/ React 19 + Vite app (src/views, src/components, src/store, src/lib).
android/ + ios/ are the Capacitor shells for the standalone mobile app (docs/MOBILE.md).
api/ backend — server.js (Node, no framework), deps: @simplewebauthn/server, web-push.
coach/ is the optional AI coach; openapi.yaml documents every route.
api/engine (the v2 training engine) and api/migration (the v1 → v2 profile conversion)
are runtime-neutral: the server imports them under bare node, the web and Capacitor
builds import the same files under Vite. So does api/coach/core (the Coach).
web/ multi-stage Dockerfile (builds frontend → nginx) + nginx.conf.template (serves app, proxies /api).
mcp/ optional MCP server — read-only stdio bridge exposing a user's workouts/1RM/muscle
balance to LLM clients (Claude Desktop, Cursor…). Not part of the Docker build; only
runs when an LLM client spawns it.
media/ exercise img/gif, gitignored, fetched at runtime by the `media` compose service.
website/ static project site (plain HTML/CSS/JS), deployed separately.
kubernetes/ example manifests (docs/SELF_HOSTING_KUBERNETES.md).
docs/ guides indexed in docs/README.md (FAQ, SELF_HOSTING*, MOBILE, AI_COACH, DATA_IMPORTS, API);
docs/ guides indexed in docs/README.md (FAQ, SELF_HOSTING*, MOBILE, AI_COACH, DATA_IMPORTS, API,
MIGRATION_TO_ENGINE_NOTE — the v1 and v2 data models, field by field);
docs/dev/ holds feature design notes (SET_TYPES: drop sets/rest-pause, LIST_VIEW, COMBINE_ROUTINES).
```

Expand All @@ -37,16 +39,20 @@ docker compose up -d --build
# Frontend dev server (hot reload), proxies /api to :3000
cd frontend && npm install && npm run dev

# Frontend tests (training logic: progression, 1RM, session read-back)
# Frontend tests (engine, prescriptions, 1RM, session read-back)
cd frontend && npm test # vitest run
cd frontend && npm run test:watch
npx vitest run src/lib/progression.test.js # single file
npx vitest run src/lib/prescription/advance.test.js # single file
npx vitest run -t "some test name" # single test by name

# API and MCP server tests
cd api && npm test
cd mcp && npm test

# API tests (node:test; the engine and migration suites live here and in frontend/src/lib/prescription)
cd api && npm test
node api/scripts/check-core-loadable.mjs # api/coach/core, api/engine, api/migration must load under bare node

# Production build
cd frontend && npm run build
cd frontend && npm run build:mobile # + cap sync, points media at the CDN dataset
Expand All @@ -69,22 +75,36 @@ directly; the mirror is fast-forward only.

- **`store/useStore.js`** — single Zustand store holding the entire client-side app state (`S`),
persisted to `localStorage` (`gym_state_v1`) and debounce-pushed to the server when signed in
(`pushState`, see `lib/api.js`). On the Capacitor mobile build it's also mirrored to a file via
`lib/mobile.js` (`nativeSave`), since WebView storage can be evicted. `store/useUI.js` holds
ephemeral UI state (modals, active sheet, etc.) separately from persisted data.
(`pushState`, see `lib/api.js`, which sends `X-OpenGym-Engine-Schema: 2`). On the Capacitor mobile
build it's also mirrored to a file via `lib/mobile.js` (`nativeSave`), since WebView storage can
be evicted. The workout in progress is **not** part of `S`: it has its own key (`gym_active_v1`,
mirrored by `nativeActiveSave`) and is never synced. `store/useUI.js` holds ephemeral UI state
(modals, active sheet, etc.) separately from persisted data.
The store also runs the one-off v1 → v2 conversion (`openMigration` / `confirmMigration`, behind
the blocking `views/MigrationGate.jsx` screen) and refuses to merge a v1 and a v2 profile.
- **`lib/`** — pure, framework-free helpers, each paired with a same-directory `*.test.js`. This
is where the domain logic lives, most importantly:
- `progression.js` — the progression-rule engine (linear, Greyskull LP, double progression,
time-based). Rules implement a shared policy interface; adding a new one plugs in here.
- `onerm.js` — estimated 1RM from logged sets.
- `finish-workout.js` — reduces a completed session back into state (weights advance, PRs, etc).
- `prescription/` — the client's import path for the training engine (`export * from
../../../../api/engine/index.js`); the engine itself lives in `api/engine` (see below). The
engine's unit tests sit beside this re-export: `prescription/*.test.js`.
- `session-start.js` — gathers a routine occurrence's inputs (rule, progression state, newest
log, current 1RM) and stores the engine's frozen prescription; `session-ui-adapter.js` turns a
prescription into the rows the workout screen edits and the rows back into persisted performance.
- `finish-session.js` — reduces a completed session back into state (each exposure becomes a log
with its audit, each track advances once, PRs and 1RMs are recorded).
- `onerm.js` — estimated 1RM from logged sets (the formulas themselves live in the engine).
- `recovery.js` / `recovery-view.js` — fatigue/muscle-recovery model.
- `workout-model.js`, `supersetFlow.js` — in-session workout state machine, incl. supersets.
- `exercises.js` / `exercises-data.js` — the exercise library (1,324 built-ins + user-defined).
- `api.js` — the only place that talks to the backend (`fetch` wrapper, session cookie flows).
- CONTRIBUTING.md is explicit: **anything that decides what you lift next, or reads a logged
session back, is a pure helper here with a unit test beside it** — not verifiable by
clicking, and the progression engine has already had two bugs that only a test caught.
session back, is a pure helper with a unit test beside it** — not verifiable by clicking, and
the progression engine has already had two bugs that only a test caught. That now means
`api/engine` (tests in `lib/prescription/`) or a helper here. There is no v1 progression
code left: `lib/no-legacy-progression.test.js` fails if `progression.js` / `finish-workout.js`
come back, if the v1 `POLICIES` list is used outside `lib/prescription/vocabulary.js` (the
boundary with the Coach, which still speaks v1 policies), or if a second v1 → v2 migration
appears next to the shared one in `api/migration`.
- **`views/`** — one file per screen (Home, Workout, Plan, Library, Stats, History, Settings,
Admin, Login, RoutineEdit), routed by `react-router-dom` from `App.jsx`.
- **`components/`** — shared UI (charts, modals, timers); `instr/` holds per-language exercise
Expand All @@ -93,13 +113,44 @@ directly; the mirror is fast-forward only.
`frontend/ios` (see `docs/MOBILE.md`); `mobile.js` in `lib/` gates native-only behavior (file
persistence, local notifications, wake lock) behind a `MOBILE` flag.

### Training engine and data model v2 (`api/engine`, `api/migration`)

A profile carries `engineSchemaVersion: 2`. Unlike v1, which re-derived the next load from history
on every read, v2 **stores what the engine decided**:

- a routine slot is an *occurrence* `{ occurrenceId, exerciseId, rule, … }` whose `PlanRule` carries a
`program` — phases of set groups, operators that step load/reps/sets/seconds/rest/rung, a back-off,
exits — built by one of 13 templates (`preset`: the 10 v1-era ones plus `top_set_backoff`,
`accumulation_intensification`, `density`); the engine reads the program, never the name;
- starting a session generates one **frozen, content-hashed `Prescription`** per occurrence
(`prescriptions{}`); a logged exercise is an *exposure* `{ exposureId, prescriptionId,
performance.sets[], actual, audit }` in `workout.exposures[]`, so history never changes when a
rule is edited;
- a `ProgressionState` per track (`progression{}`) is advanced by `advanceProgression` when a
session finishes, and rebuilt by replaying history when the plan changes;
- 1RMs are an append-only dictionary (`oneRepMaxes{}`).

`api/engine` is pure and catalogue-free (no storage, no exercise library, no Coach imports): same
inputs, same prescription. `api/migration` (`migrateProfileV1ToV2(state, catalogue)`) is the one-way,
deterministic v1 → v2 conversion shared by the API, the browser and the Capacitor shells; it takes
the exercise catalogue (`LIB_BY_ID` from `api/coach/core/library.js`) as an argument, so every caller
must pass the same one. Both folders must load under bare node — no `?raw`, `import.meta.glob` or
frontend imports — which `api/scripts/check-core-loadable.mjs` checks outside vitest.
`docs/MIGRATION_TO_ENGINE_NOTE.md` is the reference for both data models and for what the migration
can and cannot carry over; when it and the code disagree, the code wins.

### API (`api/server.js`)

Single file, no framework, plain `node:http`. Requests are dispatched through a `routes` object
keyed by `'METHOD /path'` (e.g. `routes['GET /api/health']`) matched against `req.method + ' ' +
url.pathname` — add a new endpoint by adding a key here. State is two flat JSON files under
`DATA_DIR` (`db.json`: users/credentials/subscriptions/invites; `state-<uid>.json`: per-user
workout data), written with a write-temp-then-rename atomic pattern (`atomicWrite`). Auth is
workout data), written with a write-temp-then-rename atomic pattern (`atomicWrite`).
`engineGate` guards `GET /api/data`, `GET /api/data/rev` and `PUT /api/data` on the client's
`X-OpenGym-Engine-Schema` header: a v1 file answers an engine-aware client `409 migration-required`,
a v2 file answers an old client `409 upgrade-required`. The conversion itself is
`POST /api/data/migrate-engine-v2`, which the app calls by itself at launch once its own v1 copies are backed up; it
keeps the untouched v1 file once as `state-<uid>.pre-engine-v1.json` and never replaces it. Auth is
WebAuthn passkeys (`@simplewebauthn/server`) plus a signed session cookie (HMAC'd with a
`DATA_DIR/secret` generated on first boot) — no JWT/session-store dependency. Optional pieces
gated by env vars: `ADMIN_UIDS` (admin dashboard), `INVITE_ONLY` (signup needs a code),
Expand All @@ -113,7 +164,9 @@ Read-only stdio MCP bridge (`@modelcontextprotocol/sdk`) that lets an LLM client
user's routines/workouts/body-weight/1RM/muscle-balance directly from the same `DATA_DIR` the API
writes to — no network call, no extra container. `state.js` loads/derives the data, `tools.js`
defines the exposed MCP tools (zod-validated schemas), `labels.js` maps internal keys to
human-readable labels, `index.js` wires it together. See `mcp/README.md` for the client-config
human-readable labels, `index.js` wires it together. It reads the v2 shape (exposures and their
frozen prescriptions); it reads straight off disk, outside `engineGate`, so a profile still on v1
is refused with an "upgrade from the app first" message instead of being reported empty. See `mcp/README.md` for the client-config
side (Claude Desktop / Cursor).

### Passkeys and self-hosting constraints
Expand All @@ -127,6 +180,11 @@ notification code; it documents the exact env-var contract (`RP_ID`, `ORIGIN`, `

### Docker / deploy

The web image is built from the repository root because the frontend imports `api/coach/core`,
`api/engine` and `api/migration`; `web/Dockerfile` copies each by hand (and `api/Dockerfile` copies
`engine/` and `migration/` into the API image), so a new shared folder under `api/` has to be added
to both, to `build:web-check` and to `check-core-loadable.mjs`.

`docker-compose.yml` has three services: `media` (one-shot exercise-asset downloader, gitignored
output), `api`, `web` (multi-stage build of `frontend/` served by nginx, which also proxies
`/api` → `api` and serves the shared media volume — single origin, required for passkeys).
Expand All @@ -138,5 +196,8 @@ output), `api`, `web` (multi-stage build of `frontend/` served by nginx, which a
- **Dependency-light is a hard constraint, not a preference.** Frontend: React + Router + Zustand
and nothing else. `api/`: two dependencies total. New dependencies are a hard sell either side.
- Don't commit `media/` or `data/` (gitignored).
- Training-logic changes (progression, 1RM, session read-back) need a unit test in `src/lib`
beside the code, not just manual clicking-through.
- Training-logic changes (progression, 1RM, session read-back) need a unit test beside the code —
for `api/engine` / `api/migration` that is `frontend/src/lib/prescription/*.test.js` or
`api/test/*.test.js` — not just manual clicking-through.
- Keep `api/engine` and `api/migration` runtime-neutral and dependency-free; anything that needs the
exercise catalogue, the clock or a random source takes it as an argument.
22 changes: 16 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ frontend/ React + Vite app (src/views, src/components, src/store, src/lib). Bui
android/ and ios/ are the Capacitor shells for the standalone app (docs/MOBILE.md).
api/ Backend: server.js on plain node:http, two dependencies (@simplewebauthn/server, web-push).
coach/ is the optional AI coach; openapi.yaml documents every route.
api/engine is the training engine (presets, prescriptions, progression, 1RM, warm-up)
and api/migration the one-way v1 → v2 profile conversion; both are pure and run
unchanged in the server and in the web/phone build.
web/ Multi-stage Dockerfile (builds the frontend, serves it with nginx) and the nginx template.
mcp/ Optional read-only MCP server for LLM clients (Claude Desktop, Cursor, ...). Not in the
Docker build; it only runs when a client spawns it. See mcp/README.md.
Expand All @@ -27,7 +30,7 @@ docker compose up -d --build # api + web + media on :8080

cd frontend && npm install && npm run dev # hot reload, proxies /api to :3000
cd frontend && npm test # training logic, locales, components
cd api && npm test
cd api && npm test # engine-gate and profile-migration tests live here
cd mcp && npm test
```

Expand All @@ -42,9 +45,10 @@ cd mcp && npm test
- **Click through what you touched**, including the workout flow, in a browser before opening a
pull request.
- **Training logic gets a unit test.** Anything that decides what you lift next, or reads a logged
session back, belongs in a pure helper in `src/lib` with a test beside it. These rules are easy
to get subtly wrong and nearly impossible to check by clicking; the progression engine has had
real bugs that only a test caught.
session back, belongs in the engine (`api/engine`) or a pure helper in `src/lib`, with tests
beside it (`frontend/src/lib/prescription/*.test.js`, `api/test/`; run `npm test`). These rules
are easy to get subtly wrong and nearly impossible to check by clicking; the progression engine
has had real bugs that only a test caught.
- **New UI strings go into every locale** in `frontend/src/locales/`. English is the source
language and has no file. `node scripts/check-locales.mjs` (run in CI) flags a key that is
missing, blank or has lost a `{n}` placeholder. Portuguese (Brazil) inherits from Portuguese
Expand Down Expand Up @@ -80,8 +84,14 @@ should come as a GitHub pull request.

- More starter plans
- More languages for the exercise instructions (the dataset ships several)
- Percentage or training-max programming (5/3/1 style) on top of the progression engine in
`src/lib/progression.js`; the policy interface is already there
- New progression templates. A template is a function in `api/engine/rules.js` that builds a
program (phases of set groups, operators, exits) from a few numbers, plus its editor metadata in
`PRESETS` and its read-back in `planOptions` — the engine never branches on a template's name. Add
the builder, its defaults (`templateDefaults`), its name and hint in `components/RuleEditor.jsx` and
the strings in every locale (CI runs `scripts/check-locales.mjs`), and tests in
`frontend/src/lib/prescription/`: `rules.test.js` pins the list and checks every template reads back
exactly, `golden.test.js` records how it plays out session by session. `docs/MIGRATION_TO_ENGINE_NOTE.md`
describes the rule, program, prescription and progression-state shapes.
- Accessibility passes on the workout and chart screens

## Where to ask what
Expand Down
Loading
Loading