§ Forge — hypertrophy tracker
Connected — Telegram nudges + exercise alternatives
Forge's Connected feature has two independent halves that share no state: a Telegram forge plugin that pulls live status over the loopback, and a pure exercise-alternative recommender surfaced on each exercise page. Both lean on the same discipline the rest of Forge uses — pure logic in small modules, the DB only in db.js, the server thin.
Part B — the alternatives recommender
alternatives.js is a pure module (no DB, no clock, no I/O — the caller passes the catalog in), a sibling of engine.js/coach.js.
What "primary muscle" means. In catalog.js each exercise carries muscles: { key: weight } where 1.0 is a prime mover and ~0.3–0.8 an assist. A fixed >= 0.8 cut would misfire on a lift whose hardest contribution is 0.9 (e.g. bb_row → lats 0.9), leaving it with no primary. So primaryMuscles(ex) resolves primaries relative to the lift's own top weight: the muscle(s) at (or within a hair of) the maximum. That gives bb_bench → [chest], db_bulgarian → [quads] (its glutes:0.8 is an assist, excluded), and bb_row → [lats].
How a swap is chosen. alternativesFor(key, exercises, opts):
- Filters to candidates whose
equipis in the allowed set (default barbell + dumbbell — the gym's kit). - Keeps only candidates that share ≥1 primary muscle with the source. Assist-only overlap does not qualify — that keeps the list to true swaps, not "also touches the chest a bit." This is why
bb_benchyieldsdb_benchanddb_incline_press(bothchest:1.0) but notdb_shoulder_press(no chest at all). - Ranks deterministically:
score(Σmin(srcWeight, candWeight)over shared primaries) descending, then more shared primaries, then total muscle overlap, then alphabetical by key — so the ordering is stable and unit-testable.
It accepts muscles as either a parsed object (pg returns JSONB pre-parsed; catalog.EXERCISES is objects too) or a JSON string, so it can't silently double-parse into an empty primary set.
Where it surfaces. The server's /exercise/:key route resolves the swaps, maps each shared-primary key to its display name, and renders a "Swap / alternatives" block on views/exercise.ejs — teal links inside the existing .muscletags/.mtag chips, each row a ≥44px touch target, no new colour identity. A JSON twin lives at GET /api/alternatives/:key for any external/editor use.
Part A — the Telegram forge plugin
The bot framework (/root/apps/telegram-bot) is hot-swappable: drop a dir under plugins/, it loads within ~1s. The router long-polls Telegram outbound only — no inbound port, UFW untouched. The forge plugin runs network_mode: host, so it reaches Forge on http://127.0.0.1:3600 (loopback) — the same pattern as the stats and rituals plugins.
The data contract: GET /api/status. A new read-only Forge endpoint that reuses the exact db reads + coach.js the web pages already use (no schema, no writes). It returns scalars/arrays only, so the plugin stays "dumb":
nextDay— the next rotation day + focus.recovery—coach.recoveryWarnings()for the next day's target muscles (which are still cooked).deload—coach.deloadSignal()(stalled lifts + molten muscles + over-MRV + block length).prs— best est-1RM per key lift, with its date.weekly— sessions, working sets, tonnage, PR count, and the volume-audit headline.lastTrainedDays— days since each program day was last trained (drives the stale nudge), from a newdb.lastSessionPerDay()read helper.
The plugin's pure core. render_status, weekly_summary, nudge_for, and weekly_alert_for are all pure functions of the status dict (plus an injected "today"/"now"), so they unit-test without a clock or a network round-trip (pytest plugins/forge/tests).
/forgecommand →render_status: next day, recovery, deload, latest PRs, weekly headline, in a monospace block.stalewatcher (hourly, 12h cooldown): if the next rotation day hasn't been trained in ≥STALE_DAYS(default 3), emits a "N days since you trained "Alert, keyed per (day, date).weeklywatcher (hourly): the framework has no cron primitive — only interval watchers, so the weekly summary self-gates on local weekday+hour (Monday 09:00) and keys itsAlertby ISO week, so the key + cooldown guarantee exactly one fire per week.
What's live vs scaffolded (honesty)
- Live + tested:
/api/status,/api/alternatives/:key, the exercise-page swap list, and/forgerendering — the e2e suite hits the real endpoints, andpytestcovers the renderers/decisions. - Scaffolded + unit-tested, not message-asserted: the scheduled nudge/weekly delivery. The watchers are real and the plugin registers in the router (visible in
docker compose logs), but their firing as a Telegram message is not asserted end-to-end (no chat round-trip in CI). The decision functions (nudge_for,weekly_alert_for) are unit-tested directly instead. - Out of scope: server-initiated web push (VAPID) — nudges are Telegram-only; local notifications are handled by the in-app rest-timer cue.
Files
- Forge:
alternatives.js(+alternatives.test.js),server.js(/api/status,/api/alternatives/:key,/exercise/:keypassesalts),db.js(lastSessionPerDay()),views/exercise.ejs,public/forge.css(.altlink,.mtag.alt),e2e.mjs. - Bot:
plugins/forge/{plugin.toml, main.py, requirements.txt, tests/test_render.py},.env(FORGE_URL).