Back to devlogs
Tailwindcss · Typescript · Web Application

A training board on Payload, and the scoreboard that added up two people

/ / 5 min read

Turning a 1,324-exercise dataset into a gym scoreboard. Why sets are one row each stamped with a local calendar day, why the streak counts weeks rather than days, and the ownership bug that showed the admin 21.9 tonnes of training when only 11.0 of it was theirs.

The Training Board dashboard: today's volume, sets and reps across the top, per-split bars beneath, and a filterable grid of animated exercise cards below.
On this page

There is a public dataset of 1,324 exercises. Each one carries an animated GIF, numbered instructions, a target muscle, a list of secondary movers and the equipment it needs. Every lifting app I had tried wanted a subscription and a social feed, and none of them could show me what the movement actually looks like.

So: a Payload app over that dataset. Show the movement, log the set, add up the numbers.

Sets are rows, and the day is local

One row per set. Weight, reps, exercise, timestamp. No session table, no workout table, nothing that has to be opened and closed.

That matters more than it sounds. A session in a gym is not a thing you start, it is a thing you notice afterwards, and any model that makes you press Start is a model you will forget to press Stop on. Sessions are inferred later from a gap in the clock.

The one piece of state that is not derivable is which day it was. A set logged at 00:40 after a late session belongs to the day you trained, not the next one, so each row carries the local calendar day at log time rather than being bucketed from UTC on read.

The catalogue moved into the database

The first version read the dataset from a JSON import. That works until you want to fix a name or swap an animation, at which point you are editing a seed file and rebuilding.

So the catalogue moved into Payload: an exercises collection with the movement data, and a media collection taking both assets per exercise. pnpm seed uploads 2,648 media files and creates 1,324 exercises, matching on a four digit exerciseId so re-running adds what is missing and leaves the rest alone.

One thing worth knowing if you ever seed animated GIFs into Payload: turn imageSizes off. Sharp flattens an animated GIF when it resizes it, so every generated size is a still frame. The animation is the entire point of the dataset.

The leak the local API opens

Everything went behind a login. Protected pages moved into a (board) route group whose layout calls requireUser(), and the login form posts to Payload's own endpoint so the session cookie is Payload's, with no bespoke session handling.

Then sets got an owner, taken from the session in a beforeChange hook rather than from the request body. That distinction is the whole point: a crafted create that sends owner=1 gets stored against the caller, not against user 1.

Access control was written, and reads were passed through it:

getSets({ user, overrideAccess: false })

Both arguments are load-bearing. Payload's local API bypasses access control by design, and passing user on its own is silently ignored. Server components render through the local API, so this is the gap where every server-rendered Payload app leaks.

The scoreboard that added up two people

Access control still was not enough, and the way it failed is the reason I now distrust plausible numbers.

The rule looked like this: members see their own rows, admins see everything. Written out, the admin branch returns an unconstrained true. Which is correct, and which means an unfiltered read is not an error, it is a full-table read that access control happily approves.

The admin board called that unfiltered read and then summed it. All-time volume showed 21.9 tonnes. Mine was 11.0. The rest was a member's training, merged into my scoreboard with no seam.

Nothing threw. Nothing looked wrong. 21.9 tonnes is a perfectly believable number for someone who has been lifting for a while, which is exactly why it sat there.

The fix is that ownership is a query concern, not only a permission concern:

  • Reads scope to the signed-in user by default.
  • The admin views pass an explicit owner.
  • Access control stays, and now stops a member abusing the explicit owner rather than being the only thing standing between two people's data.

The owner column went in nullable first, with a backfill that hands orphaned rows to the admin, and only then was made required.

Streaks count weeks

The first streak counted days. It came out during the build and I deleted it rather than keeping it alongside a better one, because two streak functions is how the wrong one ends up on a screen.

Rest is part of a training programme. A daily streak punishes correct behaviour, pushes people toward a worse plan, and tells someone taking a programmed rest day that they broke something. Every app whose activity carries a recovery cost counts weeks instead.

So the streak counts consecutive weeks in which your own session target was met, with a progress ring for the current week beside it. The week in progress never breaks the streak: it counts once it reaches the target and is skipped over until then.

ISO weeks are computed in UTC so a daylight saving shift cannot move a date across a boundary. Both year-boundary cases are tested, and they are not intuitive. 1 January 2027 is 2026-W53, and 30 December 2024 is 2025-W01. I checked those against Python's isocalendar rather than trusting my own implementation to be its own reference.

The ring is about forty lines of plain SVG. One circle does not justify a charting library.

Milestones written as facts

Twenty-eight of them, across sessions, tonnage, consistency, range and bodyweight-relative lifts. Definitions live in code so adding one needs no migration. Only the earning is stored.

They are phrased as facts about the training rather than as prizes. "100 sessions", not "Century Club, +500 XP". Every one carries its rule in plain language, and there is a test asserting that, because a milestone whose rule is legible can be aimed at and one that is not is a lottery.

The moment carries the mechanic, not the grid. Crossing one takes the whole screen once, at hero scale, with the rule underneath and a single button back to the set you were logging. Several landing together queue rather than pile up.

Two implementation notes that went the opposite way to how they were planned:

The big lifts match on exerciseName, copied onto the set row at log time. That looked fragile on paper and is the robust choice: renaming a catalogue entry cannot retire a milestone somebody already earned, because the name on the historical set does not move.

The unique index on (owner, key) is the real guard against a double award, not the check that runs before the insert. The create swallows the conflict, because two logs racing is a lost race rather than an error.

There is also a backfill, because awarding a season of existing training the normal way would open a dozen celebration screens in a row.

What Payload was good at

A real admin panel for free, which is where exercises get edited and programmes get written. Access control as a first-class concept, which is what made the ownership work possible to reason about at all. Migrations. Uploads.

The cost was a Postgres container running next to the app: one more thing to run, patch and back up, for a personal tool with one real user and a handful of friends.

That is the thread the next entry picks up.

FAQ

Frequently asked

A session in a gym is not something you start and stop, it is something you notice afterwards. Any model that requires pressing Start is a model people forget to press Stop on, which leaves open sessions that have to be cleaned up. One row per set with a timestamp needs no lifecycle at all, and sessions are inferred later from a gap in the clock. The only thing that cannot be derived on read is which calendar day the set belonged to, so each row is stamped with the local day at log time. Bucketing from UTC would push a set logged after midnight onto the day after the one it was trained on.

The local API bypasses access control by design, because it is meant for trusted server-side work. Server components render through it, so every page read is a trusted read unless you say otherwise. Passing overrideAccess false turns access control back on, and passing the user alongside it is required: passing the user on its own is silently ignored, which is the failure mode that looks like it works. The deeper lesson is that an access rule returning an unconstrained true for admins makes an unfiltered read legal rather than blocked, so ownership has to be enforced in the query as well as in the rule.

Rest is part of a training programme, so a daily streak punishes exactly the behaviour a good plan requires. It pushes people toward training when they should not, and it tells someone taking a programmed rest day that they have broken something. Counting consecutive weeks in which the user's own session target was met keeps the pressure on consistency without arguing with the programme. The week in progress is skipped over until it hits the target, so it can never break the run, which is the same tolerance a day streak gives an untrained morning.

With a unique index on the owner and the milestone key, and by swallowing the conflict on insert. A check-then-insert has a window between the two, and under a race both requests pass the check. The database constraint is the only thing that actually holds. Treating the conflict as a lost race rather than an error is the right call here, because the outcome the user wants, one award, is exactly what happened. Awarding runs after the set is created so that the set which crossed the line counts toward it.

Following along? Start a project.

Start a conversation →