A gym scoreboard over 1,324 animated exercises. Pick a split, pick a muscle, decide whether you have weights, then log sets against the exercise while the animation plays.
You need Node 20 or later with pnpm, and an Appwrite instance to point at: either Appwrite Cloud or a self-hosted one. There is no local database, so the app cannot start without one.
pnpm install
cp .env.example .env # fill in the Appwrite block, see DEPLOY.md
pnpm setup:appwrite # create the database, tables, indexes and bucket
node scripts/build-dataset.mjs # slim the sibling dataset, copy media into public/
pnpm seed:user # the admin account, from .env
pnpm seed # 2,648 media files and 1,324 exercises
pnpm dev # http://localhost:3000DEPLOY.md is the long version, and worth reading the first time: creating the Appwrite project, which API key scopes it needs, what to do if a step fails, and putting the whole thing on a VPS behind a reverse proxy.
To use it on your phone in the gym, hit the Network URL that pnpm dev prints (same wifi), or
pnpm build && pnpm start.
src/seed/schema.ts is the schema. It is the only written-down copy of the tables, columns and
indexes, so read it rather than clicking through the console when you want to know the shape of
something. pnpm setup:appwrite applies it.
It is idempotent: it creates what is missing, reports what is already there, and changes nothing
else. There are no migrations and nothing runs at boot, so adding a column means adding it to
schema.ts, re-running the script, and updating the row mappers in src/app/(frontend)/lib.ts in
the same commit.
There is no admin panel in this app. Rows, files and accounts live in Appwrite, so the Appwrite console for your project is where you read and edit them. It is a separate application behind its own login, and nothing here links to it on purpose.
- Rows: Databases → training → the table
- Animations and thumbnails: Storage → media
- Accounts, and the
rolein an account's prefs: Auth
From inside the app, /progress is your own history and /people is the admin overview of
everyone's training.
Board (/) — today's volume, sets, reps and per-split bars across the top, filters underneath,
exercise grid below. A red count on a card means you already worked it today.
Exercise (/exercise/[id]) — animation, numbered steps, muscles worked, and the set logger. It
pre-fills weight and reps from your last set of that exercise, shows today's sets, your best set
ever, and what you did last session. Below that, other exercises for the same muscle, for when the
machine is taken.
Progress (/progress) — sessions, streaks, weekly and all-time volume, heaviest set per exercise.
Editing the underlying rows, swapping an animation or managing accounts happens in the Appwrite console. See Seeing the data.
Sign in (/login) — everything above is behind it. Signed-out visitors get redirected here.
Every page except /login sits in a (board) route group whose layout calls requireUser(), so a
signed-out visitor is redirected. The login form posts to a server action that creates an Appwrite
session and puts its secret in a first-party HttpOnly cookie; Appwrite's own cookie is bound to the
Appwrite domain, which is a different host.
Reads run through an Appwrite API key, which bypasses row permissions entirely, so every query
against private data constrains owner itself. The catalogue and its media are public: the
exercises table and the media bucket both allow anonymous reads, because they are dataset
content rather than anyone's training. tests/int/ownership.int.spec.ts fails the build if a query
touching a private table is written without an owner constraint.
.env is the source of truth for the login. Change SEED_USER_PASSWORD and run pnpm seed:user:
it resets the password to match and clears any lockout from failed attempts.
| Filter | What it does |
|---|---|
| Search | Matches exercise name or equipment |
| Split | push, pull, legs, core, cardio — derived from the target muscle |
| Equipment | Weights (barbell, dumbbell, cable, machines) vs bodyweight (body weight, bands, balls) |
| Muscle | All 50 muscles in the database, split into ones trained directly and ones worked as support |
The muscle filter matches secondary movers too, so filtering on triceps surfaces bench press alongside the dedicated triceps work. Every filter lives in the URL, so opening an exercise and pressing back returns you to the same view.
src/data/exercises.json is the seed input, generated from ../exercises-dataset by
scripts/build-dataset.mjs. The seeder reads it and writes into Appwrite, which is the source of
truth from then on. Editing an exercise in the console changes what the board shows; editing the
JSON does nothing until you reseed.
pnpm setup:appwrite # the schema. Run this first, once
pnpm seed # everything: 2,648 files, 1,324 rows
pnpm seed:rows # just the rows, seconds
pnpm seed:media # just the files, minutes
pnpm seed:user # create or repair the admin from .env
pnpm add-user # MEMBER_EMAIL=sam@example.com MEMBER_NAME=SamRe-running is safe and resumable, which matters because the media upload is the slow part and a
dropped connection halfway through should cost the remaining files rather than all of them.
Exercises are matched on exerciseId, media on the id in the filename, the user on email. Options
are env vars rather than CLI flags (SEED_CONCURRENCY=8), with a pnpm script for the common ones.
A programme is a saved multi-week plan. With one active, the board shows that day's session as a checklist instead of the catalogue, and the catalogue stays one tap away behind "Browse all exercises".
Days are a list that repeats, so a four day split is four entries and not twenty-eight. Rest days
are entries with rest ticked and no exercises, which is what makes them render as rows rather than
as gaps in the plan.
Programmes are written in the Appwrite console, in the programs table. days is a JSON
column holding the whole nested structure, because Appwrite columns are flat and two child tables
would buy ordering and referential integrity the app does not use. Items reference the catalogue's
four digit exerciseId, and the app hydrates the full exercise on read.
Which day is today is the calendar rule: days elapsed since startedOn, modulo the cycle length.
Miss a session and the programme moves on without you, so the day picker on the board lets you pick
the one you meant. That is deliberate: no rule can tell whether a missed session was a rest day, a
deload or a busy Tuesday.
targetWeight is a suggestion that seeds the logger and is never applied on its own. Auto
progression is the feature every lifting app adds and every serious lifter turns off, because it
does not know whether the last session was hard.
| Piece | Where |
|---|---|
| Exercise catalogue | exercises collection, with gif/image uploads to media |
| Logged sets | sets collection, one row per set |
| Programmes | programs collection, days as a repeating array |
| Bodyweight and scans | weights and body-scans collections |
| Seeder | src/seed/ |
| Auth gate | src/app/(frontend)/auth.ts and the (board) layout |
| Queries | src/app/(frontend)/lib.ts |
| Pure scoring logic | src/app/(frontend)/data.ts |
| Design tokens | src/app/(frontend)/tailwind.css, shadcn preset b3eywfGajw |
| Generated media | public/media/{gif,img} and media/, both gitignored |
Sets are stamped with the local calendar day rather than UTC, so a late session lands on the day you trained rather than the next one. The storage bucket has image transformations disabled: Appwrite's preview endpoint returns a still frame for an animated GIF, and the animation is the point.
Rows, files and accounts all live in Appwrite, so the app container holds no state and there is nothing here to back up. DEPLOY.md covers the VPS setup: Docker Compose, the environment, and the ports.
Follows DESIGN.md: a single red accent (#e60000), ink and canvas bands alternating for depth,
60px pill CTAs, 6px card chrome, no drop shadows. Outfit stands in for the proprietary display sans.
The unfiltered 1,324-card grid uses content-visibility so offscreen cards skip layout, rather than
truncating results or virtualising.
assets/plan/ is the build plan: fifteen files, one per feature, each a todo list
with data model changes and acceptance criteria. It covers turning this into a small multi-user
platform where friends see only their own training and you can see everyone's, the five gamification
mechanics the research recommends, and the UI pieces that are currently missing.
Start with assets/plan/README.md. The data-ownership task it opens with
is done: every query scopes to an owner, and a test enforces it.
assets/research/ holds the gamification research: what the evidence says about
streaks and badges for training apps, teardowns of what Tonal, Nike Run Club, Apple Fitness and
others ship, the screen patterns mapped onto this design system, a phased build plan, and a setup
guide for an MCP server so Claude can read and write training data.
Two findings shape the plan. A daily streak is the wrong mechanic for lifting, because rest is part of the programme, so the streak has to count weeks. And gamification features stop helping past a point, so the plan totals five mechanics and stops.
pnpm test:int # dataset integrity, exercise queries, scoring and streaks
pnpm test:e2e # board, filter persistence, exercise pagepnpm lint fails on a missing @eslint/eslintrc inherited from the original template.
