Skip to content

Repository files navigation

coach

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).

Layout

  • coach-pacing/ — the pure pacing engine, compiled #![no_std] so it cannot do IO or read the clock (see its lib.rs).
  • src/ — Rust backend (see module docs). pacing/service.rs assembles 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; see docs/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.

Develop

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 abort

scripts/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.

Conventions

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 with as. 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 their docs/field-test.md id.
  • Tests go through the public surface: evaluate for 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, in frontend/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 in render/skin/loops.json.

Train from the command line

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 2

It 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

Deploy

./scripts/deploy.sh          # commit + push first; it refuses a dirty tree

CI (.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.sh

DNS: code/dns CNAME coach → isis.xinutec.org (tofu apply from isis).

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages