Building Trainr: testing a coaching app end to end and recording its demo with Claude Code
How I took Trainr, a Next.js and Supabase app for personal trainers and CrossFit boxes, from two half-finished branches to a tested, bilingual product: the RLS bugs a verification script found, Spanish and English in one pass, and a Playwright + FFmpeg demo video for promotion.
Filed under: next.js, typescript, supabase, postgres, rls, i18n, playwright, ffmpeg, ai-development, claude-code, side-project
Most freelance personal trainers I know run their business on WhatsApp. The plan is a screenshot of a spreadsheet, "how did it go?" is a text they have to chase, and payments live in a notes app. I started Trainr to replace that stack: one place where the trainer builds the week, the client trains from their phone and says how it went, and the trainer sees who needs attention.
The app had been sitting on two branches for a while: a main branch with work in progress, and a v2 branch with public trainer profiles, CrossFit WODs and an AI coach. This week I sat down with Claude Code to finish it properly: test every flow, fix what was broken, translate it, and record a demo I could actually use to promote it.
In short:
- Trainr is a Next.js 15 + Supabase app for personal trainers and CrossFit boxes: weekly plans, per-exercise feedback, progress charts, payments, a public "find a trainer" page and a WOD of the day.
- A verification script that signs in as every kind of user found close to thirty bugs, most of them in row-level security: a client could mark themselves as paid, any trainer could write onto any client, and accepting a new client silently did nothing.
- The whole UI went bilingual (Spanish by default, English toggle) in one pass, with inline
t(en, es)pairs instead of translation files. - Claude Code recorded the demo below by driving the real app with Playwright, then edited it with FFmpeg into a horizontal video, a vertical one and a short reel.
Here it is, in Spanish, the way I will share it:
Trainer on a laptop, client on a phone: the morning dashboard, replying to a session, building and publishing a plan, logging a workout, progress, marking a payment, a new client finding a trainer in Quito, accepting her, and a CrossFit WOD. Demo data is fictional. Music built from GarageBand Apple Loops.
What Trainr does
There are two sides, and the product is the loop between them.
- The trainer keeps a roster, builds each client's week from their own exercise library (sets, reps, weight, notes, a video link), and publishes it. The dashboard answers the morning questions: who trains today, who already logged, whose feedback is waiting, who hasn't paid.
- The client opens the app, sees today's session with the trainer's cues, and after training taps through each exercise: was the weight too light, just right or too heavy, did they finish every set, what did they actually lift. It takes under a minute. Their progress (weight, muscle, fat, working loads) is charted automatically.
- CrossFit boxes publish one WOD of the day for everyone. Each athlete logs Rx or scaled and their score.
- New clients find trainers on a public page filtered by sport, see their location and rates, and send a request. The trainer accepts it with one tap.
Each sport asks different questions. A lifter rates the weight and logs load and reps; a swimmer rates effort and logs distance, stroke and pace.
Step one: figure out what actually exists
The first surprise was that the hosted Supabase database did not match the repository. Someone (me, months ago) had applied the v2 schema directly to the hosted project, while the main branch carried a migration reconstructed from an older state. The two branches had also drifted.
So before testing anything, we merged main's work in progress and the v2 branch into a single integration branch, made the migrations describe the database that really exists, and wrote a seed script with a cast of demo users: two public trainers in Quito, clients in each sport, a client with a pending request, and one who has no trainer yet. That cast is what you see in the video, and it doubles as the test fixture.
Testing row-level security by signing in as everyone
Trainr leans on Postgres row-level security. The UI queries tables directly and trusts the database to say no. That makes the rules easy to write and easy to get wrong, because a policy that is too strict usually fails silently: the query returns zero rows and no error.
The verification script signs in as each demo user and tries things, around 75 checks in all. Can a client read another client's sessions? Can a trainer comment on a client who isn't theirs? Can a logged-out visitor see the public trainers but not their clients? For writes, it attempts the change as the real user, then reads the row back with the service role to see whether it actually changed, and restores it if it did. Asserting on the outcome instead of on the error is how it caught the quiet failures:
async function probe(label, client, patch, { shouldChange }) {
await client.from('students').update(patch).eq('id', anaStudent.id)
const after = await readAna() // service role: the truth
const changed = Object.keys(patch).some((k) => String(after[k]) !== String(anaOriginal[k]))
check(label, changed === shouldChange)
if (changed) await admin.from('students').update(anaOriginal).eq('id', anaStudent.id)
}
await probe('Student cannot mark herself as paid', ana,
{ payment_last_paid_at: new Date().toISOString() }, { shouldChange: false })Some of what it found:
- Clients could edit their own client record, including the payment date, their status and which trainer they belong to. Clients are now read-only on that table, and the one field the app needs to update (the last session date) is maintained by a trigger.
- Any trainer could write onto any client. The policies on comments, plan templates and weekly plans only checked that the trainer column was the current user, not that the client belonged to them. They now also require
is_my_student(student_id). - Accepting a new client never worked. A client who signs up on their own has no trainer yet, so the trainer's policies cannot see their row, and the update matched zero rows without complaint. Accepting is now a small
SECURITY DEFINERfunction that checks the request belongs to the caller. - Every new client existed twice. The sign-up trigger created a client row, and "Add client" created another one. Client pages read a single row, so they rendered empty. The trigger now skips accounts created by a trainer, and a partial unique index makes a duplicate impossible.
- The leaderboard never rendered, because a function returning a
student_idcolumn also compared against a barestudent_idin its body (Postgres error 42702, ambiguous column). Once fixed, it turned out anyone could call it with a public trainer's id and list their clients. It now only answers the trainer or their own clients. - The public "find a trainer" page was always empty, and CrossFit athletes never saw their WOD, both because a table had no policy that allowed those reads.
The rest were application bugs. The payment status had no grace period, so a client who paid three days early showed as overdue on the due date. It now accepts payments up to ten days early, treats day 31 as the last day of short months, and never bills a client for a month before they joined. The login page's ?next= parameter could redirect to another site. Evening sessions counted for the next day on the leaderboard because dates were computed in UTC instead of the trainer's timezone.
I then went through the app screen by screen against a 126-item checklist for the UI side: a navigation item that had disappeared, a CrossFit day showing the plan above the WOD, an exercise picker offering swimming drills to a lifter.
Spanish first, English one tap away
The people I want to reach coach in Spanish, but the app was written in English. Rather than introduce translation key files, every string became an inline pair:
// Server components
const t = await getT()
<h1>{t('Find a trainer', 'Encuentra un entrenador')}</h1>
// Client components
const { t, lang, setLang } = useT()The language lives in a cookie (trainr_lang), Spanish by default, and a small ES | EN toggle sits on the login page, the trainer sidebar and the client's profile. The pair format keeps both languages next to each other in the code, so a missing translation is visible in review. Sixty-nine files had to change, which Claude Code split between four agents working in parallel, each on its own set of screens, followed by a pass to fix the places they missed: sport names, difficulty labels, day names, and the seed data itself.
How Claude Code recorded and edited the demo
I wanted a video I could post on Instagram and LinkedIn and send to trainers on WhatsApp, showing both sides of the app. I pointed Claude Code at a demo-video skill I had used for Audiobooks: drive the product with a script, assemble with FFmpeg. For a web app that meant:
Playwright drives the real app. A script logs in as Martín (trainer, laptop viewport), Ana (client, phone viewport), Renata (looking for a trainer) and the others, and plays nine scenes: the dashboard, replying to Sofía, building Bruno's plan, Ana's workout, her progress, a payment, Renata finding Camila, Camila accepting her, and Mateo's WOD. It records against a production build, because the development server's badge shows up in the frames. Every scene writes to the database, so the data is reseeded before each take.
Screenshots instead of screen recording. The first attempt used Chrome's built-in screencast, which ignored the device pixel ratio and returned cropped 1440×723 frames. Claude switched to a loop that takes about 25 screenshots per second at 1920×1080 and logs the real timestamp of each one, so the video plays at true speed. A fake cursor (a dot that shrinks on tap) is injected into the page, because headless Chrome does not draw one.
Captions as images. The FFmpeg build on my Mac has no text rendering, so captions, the device frame and the intro and outro cards are HTML pages that Playwright screenshots to transparent PNGs, then FFmpeg overlays and fades them:
[2]format=rgba,fade=t=in:st=0.4:d=0.35:alpha=1,fade=t=out:st=6.1:d=0.3:alpha=1[c0];
[app][c0]overlay=0:0:format=auto[k0]One recording, three cuts. Long scenes play 1.15 to 1.5 times faster so taps stay readable without dragging. The vertical version crops the trainer's screen to its content column, since a sidebar wastes a narrow frame. The scenes are joined with crossfades, and the music is a bed of GarageBand Apple Loops normalized to a normal web loudness. The output is a 95-second horizontal video, the same in 9:16, and a 54-second reel.
Then I watched it, and found two things only a person would notice. The sport filter pills on the "find a trainer" page had their text sitting at the top instead of centered, which turned out to be a fixed-height link without flex centering, and the same bug was in the inbox filters. And the demo trainers lived in Santiago and Lima, charging in Chilean pesos and soles, when the first people I will show this to are in Quito and pay in dollars. Both were fixed, the trainers moved to Cumbayá and La Carolina, and the whole video was recorded again in a few minutes.
Here is the vertical cut:
What I took from it
The most valuable artifact of this round was not a feature. It was the seed and the verification script. A realistic cast of users made it possible to test every role, made the bugs reproducible, and then became the set for the demo. Every bug fix and every re-recording started from the same known state.
The second lesson is about where AI-assisted work needs a human. Claude Code found and fixed bugs I would not have looked for, like the request that silently matched zero rows, and it handled a production pipeline I would normally have skipped. The two things I caught were judgment, not code: what looks off at a glance, and who the video is for. Same as with Mundial Pool and Life OS, my job was deciding what to build and checking that it was right.
Trainr isn't public yet. If you coach in Quito, or know someone who does, and want to try it for free in exchange for honest feedback, get in touch. More about the app is on the Trainr project page.
Frequently asked questions
- What is Trainr?
- A web app for freelance personal trainers and CrossFit boxes. The trainer builds each client's weekly plan from their own exercise library, the client trains from their phone and rates every exercise in under a minute, and the trainer sees the feedback, the progress charts and who has paid. Clients can also find trainers by sport and location and send them a request. It supports gym, running, swimming and CrossFit, in Spanish and English.
- What is it built with?
- Next.js 15 with the App Router and React 19, Tailwind, and Supabase for Postgres, authentication and row-level security. Most of the access rules live in the database as RLS policies and a few SECURITY DEFINER functions, so the UI can query tables directly.
- How do you test row-level security in Supabase?
- By signing in as each kind of user and trying things they should and should not be able to do. Trainr has a verification script that seeds demo data, then logs in as trainers, clients and anonymous visitors and runs about 75 checks: reading another trainer's clients, writing a comment onto someone else's client, a client marking themselves as paid, accepting a request twice. A query that returns zero rows without an error is the dangerous case, so the script asserts on the result, not just on the absence of errors.
- How did you add Spanish and English to an existing Next.js app?
- With a cookie (trainr_lang, Spanish by default) and inline pairs: every UI string became t("English", "Español") in place, with a server helper for server components and a hook for client components. There are no translation key files to keep in sync. Sixty-nine files were converted in one pass, split between four parallel Claude Code agents.
- How was the demo video made?
- Claude Code wrote a Playwright script that logs in as each demo user and drives the real app in system Chrome, capturing about 25 screenshots per second at 1080p with an injected cursor. FFmpeg then speeds up the long scenes, places the phone scenes in a device frame, overlays Spanish captions rendered from HTML, crossfades the scenes and adds a music bed from GarageBand Apple Loops. The same recordings produce a 16:9 video, a 9:16 video and a 54-second reel.
- Can I use Trainr?
- Not publicly yet. I am looking for a few trainers in Quito to try it for free and tell me what is missing. Reach out through the contact section of this site.