Training Board
A gym scoreboard over 1,324 animated exercises, built to be used one-handed on a phone between sets. Filter by split, muscle or equipment, then log sets against an exercise while the animation plays. It tracks volume, week streaks, personal records, 28 milestones across three tiers, bodyweight and InBody scans, and multi-week programmes that render as today's checklist instead of a catalogue. Next.js 16 and React 19 on Appwrite, installable as a PWA, deployed to a VPS in Docker. It started on Payload and Postgres and moved to Appwrite in a day.
- Role
- Design and development
- Timeline
- 4 days
- Year
- 2026
- Status
- In Progress
Every lifting app I tried wanted a subscription, a social feed and my email address, and none of them could show me what the movement actually looks like. There is a public dataset of 1,324 exercises with an animated GIF and numbered instructions for each one. Training Board is that dataset turned into a scoreboard you log against.
The loop is short on purpose. Open the board, see today's volume and what this week has covered, tap an exercise, and log a set into a form already pre-filled from the last time you did it. The animation plays while you type. Below the logger sit today's sets, your best set ever, what you did last session, and other exercises for the same muscle for when the machine is taken.
Three constraints shaped everything. It is used on a phone, in a gym, one-handed, so navigation sits where a thumb already is and the screen has to survive being read at arm's length. Rest is part of training, so no mechanic may punish a rest day. And the catalogue is 119 MB of animation, so the network cost of a card is the whole design problem behind the grid.
It ships as a Next.js 16 app on Appwrite, installable to a home screen, deployed to a VPS with Docker Compose. Rows, files and accounts all live in Appwrite, so the container holds no state and there is nothing on the server to back up.
Four problems took most of the time, and none of them were the features.
Data ownership was the first. The app went multi-user early, and access control alone turned out not to be enough. The admin rule returned an unconstrained true, so an unfiltered read handed the admin every member's sets merged into their own scoreboard. All-time volume read 21.9 tonnes, which was 11.0 of mine plus a member's. The bug was invisible because the number was plausible. Every query now scopes to an owner by default and takes an explicit owner for the admin views, and a test fails the build if a query touching a private table is written without an owner constraint.
The phone was the second, and it broke in ways a desktop never shows. At 390px the grid fell to one column, so 1,324 cards became a 407,559px page with 12,167 DOM nodes. The content-visibility size hint claimed 288px against a real 427px, an error of 139px per card, so the browser was permanently re-estimating the scroll height and tapping a card opened nothing: the content moved under your finger between the touch and the release. Separately, my own service worker stalled every image after the first, because an installed PWA gets a much smaller cache quota than a browser tab and an unhandled cache.put rejection left a response clone abandoned, which makes the browser buffer the whole body.
The third was choosing mechanics that do not lie. A daily streak punishes correct behaviour in a sport where rest is programmed, so the headline streak counts consecutive weeks that hit your own session target. Auto progression, the feature every lifting app adds, is deliberately absent: a target weight seeds the logger and is never applied on its own, because nothing in the data knows whether the last session was hard.
The fourth was the migration. Payload gave a good admin panel and a Postgres container to run alongside the app. Moving to Appwrite meant giving up the framework-native access control the ownership work was built on, reshaping two nested data structures that flat columns cannot hold, and finding out which query shapes the API actually accepts, which is not what the docs imply.
The board is a progression view: an XP and level strip under the nav, a season grid of the last two months by training intensity, week streak, and what the week has covered by muscle. The catalogue moved to its own route, so reaching your own training no longer means scrolling past 1,324 cards. Every filter lives in the URL, so opening an exercise and pressing back returns you to the same view.
Twenty-eight milestones are written as facts about the training rather than prizes: "100 sessions", not "Century Club". Each carries its rule in plain language, and a test asserts that, because a milestone whose rule is legible can be aimed at. Crossing one takes the whole screen once, at hero scale, with a single button back to the set. A unique index on owner and key is the real guard against a double award, and the create swallows the conflict, because two logs racing is a lost race rather than an error.
The phone work is a PWA with a manifest, generated icons and twelve iOS splash sizes, since iOS uses a startup image only on an exact media query match. The service worker is thirty lines, caps its cache at 250 entries, treats every cache operation as optional, and consumes the response clone inside waitUntil. The rule it follows: a service worker must never be worse than no service worker. Verified with the network fully off, a previously viewed animation came back from cache at 118 KB and decoded. The grid is two columns on a phone, paged at 48 with a link that keeps the current filters, which took the page from 407,559px to 8,552px and made taps land.
The Appwrite migration took a day. Programme days were nested arrays two levels deep, so they are stored as JSON and the referenced exercises hydrated on read. Body scans had two nested groups of five segment numbers, now ten flat columns folded back into groups on read. Auth moved to Appwrite Account with the session secret in a first-party HttpOnly cookie, because Appwrite's own cookie is bound to its domain. Reads run through an API key that bypasses row permissions entirely, so the deleted access tests were replaced by a source guard that fails on any query against a private table with no owner constraint.
Three query shapes only a live instance tells you about: equal on an array column is rejected outright, so the muscle filter uses contains; a fulltext search inside an or group returns a server error, so free text is three contains instead; and cursor paging is strictly sequential, which put fourteen round trips in series over a five second test timeout, so the first page reports the total and the rest are fetched in parallel off it.
Deployment is a Makefile over Docker Compose. make deploy rsyncs and rebuilds and never touches a volume, make backup pulls a dump and a media tarball, and make destroy is the only target that removes data, refuses to run unless you type the word, and says what it is about to delete first.
About this project
-
Payload gave a good admin panel and cost a Postgres container running next to the app, which is one more thing to run, patch and back up. Appwrite holds the rows, the 2,648 media files and the accounts in one place, so the app container is stateless and there is nothing on the server to back up. The trade is real and worth naming: Payload's access control was framework-native and enforced on every read, while Appwrite's server SDK uses an API key that bypasses row permissions entirely. The security model changes shape rather than strength. Every query now constrains the owner itself, and a source guard test fails the build if one does not.
-
They are two different packages for two different sides. node-appwrite is the server SDK and supports setKey for an API key alongside setSession for a user session, which is what the admin and session clients need. The appwrite package is browser only and has no API key support. Every Appwrite call in this app sits in a server component, a server action or the seeder, and no client component touches it, so adding the web SDK would install a second SDK that nothing calls. It becomes worth adding the day something needs live subscriptions or a direct browser upload.
-
Rest is part of a training programme, so a daily streak punishes correct behaviour and pushes people toward a worse plan. It also tells someone taking a legitimate rest day that they broke something. The headline streak counts consecutive weeks in which your own session target was met, and the week in progress never breaks it: it counts once it reaches the target and is skipped over until then. There is a secondary day streak on the progression layer, and it runs on a two-on one-off rule so a programmed rest day cannot end it. ISO weeks are computed in UTC so a daylight saving shift cannot move a date across a boundary, checked against both year-boundary cases.
-
It is the feature every lifting app adds and every serious lifter turns off, because nothing in the logged numbers knows whether the last session was hard. A programme can carry a target weight, and that target seeds the logger as a suggestion. It is never applied on its own. The same reasoning shapes the programme day picker: the calendar rule says which day is today, but no rule can tell whether a missed session was a rest day, a deload or a busy Tuesday, so the picker lets you choose the one you meant.
-
Three things, and the first two were bug fixes. Media is served immutable for a year, which is safe because upload filenames carry a random suffix and are never rewritten in place, with an admin button that raises a cache version appended to every URL when a file is replaced. A thirty line service worker caches media cache-first, bounded at 250 entries with the oldest evicted first, because an installed PWA gets a far smaller cache quota than a browser tab. And the grid pages at 48 cards on a phone rather than rendering all 1,324, which content-visibility alone could not save.
-
An MCP server over the training data, so Claude can search the catalogue, show a movement, write a programme and log a set. The design is written up: read-only tools first to prove the transport, images as content blocks with a JPEG thumbnail by default and the GIF behind a flag, and errors that name the bad exercise id so the model can correct itself. Two things changed since the research was written, both because the app went multi-user. Every tool call needs an identity rather than just a bearer token, and the API key bypass applies inside the tools exactly as it does in the app, so each read and write has to constrain the owner itself.