Personal exercise/training tracker with an adaptive pacing coach. A sibling
of life: Rust (axum) backend + Angular frontend + its own MariaDB, served from
one image and deployed to k3s on isis. Served at coach.xinutec.org on the
WireGuard VPN only (isis's front door has no public listener for it), gated by
Nextcloud OAuth login.
There's no stored plan or program. On every request the pacing engine recomputes what to do from first principles: your logged set history (rolling muscle-group volume + recovery), your settings, your biometric readiness, and the kit at your current location — rings on a 2 m bar, adjustable weights, a mat. It picks the biggest recovered deficit, chooses an exercise you can actually do there, and progresses it off your last performance — then nudges you to spread your sets through the day instead of cramming them at night. Reminders fire from the Android app's on-device geofence (only when you're home).
coach-pacing/— the pure pacing engine, compiled#![no_std]so it cannot do IO or read the clock (see itslib.rs).src/— Rust backend (see module docs).pacing/service.rsassembles the engine's input from the DB + applies your tz;bin/holds the back-test and the athlete simulator.docs/trainer.md— the trainer model: design principles, known gaps, and the roadmap.docs/field-test.md— the findings (Rn-m) the code refers to.data/catalog/— the exercise catalog, seeded into the DB at boot whenever its content hash changes: exercises, equipment, muscles, images and loops.render/— the Blender pipeline that draws the catalog's anatomy pictures and loops; seedocs/anatomy-renders.md.migrations/— sqlx migrations, run at boot. Append-only.frontend/— Angular app (Today burn-down, log, history, balance, exercise library, locations, settings). A movement's picture and demo are one tap from the plan card: the demo plays in the sheet (muted, chrome-stripped, from the timestamp the catalog link points at) rather than throwing you out to YouTube mid-set, and fills the screen if you turn the phone sideways.android/— WebView wrapper + native geofence/notification layer.
nix develop # cargo + node toolchain
./scripts/dev-db.sh # local MariaDB on :3308 (db/user: coach/coach)
cp .env.example .env # fill in; DEV_LOGIN_USER bypasses Nextcloud locally
cargo run # API on :8080 (STATIC_DIR unset = API only)
# frontend: cd frontend && pnpm install && pnpm start # ng serve :4200, proxies /api
# to serve the built SPA from the backend (single origin):
# (cd frontend && NG_BUILD_MAX_WORKERS=1 pnpm run build) # the =1 avoids a macOS
# STATIC_DIR=frontend/dist/coach-web/browser cargo run # build-teardown abortscripts/gen-types.sh regenerates the frontend TS types from the Rust API
types; --check reports drift instead. gate.dhall is the commit gate, run by
the pre-commit hook (scripts/setup-hooks.sh installs it once per clone) or by
hand with nix run 'git+file:../dev-lint?ref=HEAD#gate' -- . gate.json: backend
fmt + clippy + tests, frontend lint/build/unit tests, the type-drift check, the
Playwright layout checks, the Android app, and dev-lint.
The backend tests include tests/db.rs, which runs the real queries against a
real MariaDB — a FromRow struct binds its columns by name at runtime, so a
SELECT that drifts from it compiles, passes every pure test, and 500s in
production. The gate runs that row through with-test-db, which brings up an
ephemeral MariaDB and tears it down again; CI gets a mariadb service. The
tests fail loudly without a server rather than skipping.
The gate enforces what it can: formatting (rustfmt, prettier), clippy, the pacing core's totality and cast lints, generated wire types, the layout harness. The rest is judgement:
- Types carry the rules. A row id is a newtype; a group of optional fields
where only some combinations are legal is a sum type (
Ask,LoggedSet,Dose); input is parsed at the boundary into a checked type, never asserted withas. Wire types are generated from Rust (scripts/gen-types.sh), never hand-written. - Comments say why, in a sentence or two. Not what the code does, and no
history, dates or counts, which rot. A long rationale belongs in
docs/, with the comment pointing at it; findings are cited by theirdocs/field-test.mdid. - Tests go through the public surface:
evaluatefor the engine, routes and real SQL for the server, components for the UI; property tests for invariants. No test that restates a constant, and a shared helper is tested once, infrontend/src/app/shared/. - Renders are measured, then looked at. See
docs/anatomy-renders.md: probe the rig instead of guessing axes, judge Blender by its output rather than its exit status, and record every shipped loop inrender/skin/loops.json.
scripts/coachctl.py does what the app does — reads the plan, logs sets, registers
kit — from a terminal, so the log can be kept by hand (or by Claude) without
opening the phone.
./scripts/coachctl.py now # today's plan, as the app shows it
./scripts/coachctl.py find pull # search the catalog
./scripts/coachctl.py log pull_up_bar --reps 3 --rpe 8 --sets 3
./scripts/coachctl.py sets # what's been logged
./scripts/coachctl.py locations # kit + registered weights
./scripts/coachctl.py weights "Office gym" kettlebell 6,8,10,12,16 --qty 2It holds no credential of its own. It borrows the session in the signed-in
ChromeDebug profile and issues the same same-origin fetch calls the web UI
issues, over the fleet's CDP bridge (xinutec-infra/mac-mini/browser/cdp.py — the
one life-todo-sync uses). So there is no API token to leak, no new endpoint, and
no path into the data that the UI doesn't already have: writing SQL into prod
would bypass the foreign keys and the repo layer's validation, and a token would
be a second, weaker way in that has to be secured forever. If the browser is
signed out, coachctl can do nothing — which is the correct blast radius.
Needs the debug Chrome up, signed in once (the profile keeps the session):
~/Code/xinutec-infra/mac-mini/chrome-debug.sh start # then sign in at coach.xinutec.org./scripts/deploy.sh # commit + push first; it refuses a dirty treeCI (.github/workflows/build.yml, on push to main) builds+pushes
xinutec/coach:latest, tagging the image with the commit it was built from.
deploy.sh waits for the CI run whose head SHA is this commit (not merely
the latest run — that returns the previous commit's, and restarting on it
ships the code before yours while reporting success), rolls out, then asks the
running server GET /version and requires it to equal HEAD. A rollout that
succeeds proves a pod came up, not which image it came up on; /version is what
proves the deploy.
The k8s manifests live in the home monorepo (xinutec/pippijn
code/kubes/coach/k8s/, generated from dhall/apps/coach.dhall). First time
only, from that checkout, on isis as root:
# NC OAuth2 client "coach" (dash admin), redirect
# https://coach.xinutec.org/auth/callback
NC_CLIENT_ID=... NC_CLIENT_SECRET=... ./k8s/secret.sh
./k8s/sync.shDNS: code/dns CNAME coach → isis.xinutec.org (tofu apply from isis).