Case Study
TrainerOS
A coaching platform for personal trainers and their clients. Trainers write programs; clients log their sets from the gym floor.
- Year
- 2026
- Role
- Solo full-stack developer
- .NET 8
- ASP.NET Core
- C#
- React 19
- TypeScript
- Tailwind CSS
- Vite
- PostgreSQL
- EF Core
- Azure Functions
- Azure App Service
- Neon
- Resend
- xUnit
- Vitest
- GitHub Actions
An ASP.NET Core API serving a React SPA from a single App Service, with a separate Function App running the reminder pipeline off a Storage queue, and Postgres on Neon. Sessions are server-side and revocable, data access is scoped so a query for another trainer's client won't compile, and reminder delivery is idempotent through a unique index rather than application logic.
01 · Problem
Programs went out over Google Sheets and WhatsApp. What came back was inconsistent: a notes app, a photo of a page, memory, or nothing. There was no record of what a client actually lifted, and no way to notice someone had stopped training until they said so. In-person clients lived in the trainer's iPhone Notes.
02 · Solution
Clients sign in from an emailed link, pick the day they're training, and log each set next to what they lifted at that position last time. Scheduled reminders go out per client, with a one-tap pause. The trainer sees every session: the date, the client's note, and each set in that client's own unit.
03 · Features
- Magic-link auth with revocable server-side sessions
- Per-client programs with days and prescriptions in real coaching language (8-10, AMRAP, RPE 8)
- Inline last-time numbers on the logging screen, positioned beside the set being entered
- Same-day set editing and deletion
- Session notes recorded against the workout
- Per-client kg/lb display with canonical storage
- Scheduled reminder emails with a one-tap pause link
- Trainer views of client history, plus an exercise library with soft-delete
04 · Screenshots

The client opens straight into the day they're training. 
Progression without remembering, or scrolling back through history. 
Goes out on each client's own schedule rather than a global one. 
Who trained today and who has gone quiet, at a glance. 
Send time is set in the client's own timezone, not the trainer's. 
Prescriptions stay free text, so 6-8, AMRAP and RPE 8 survive as typed.
05 · Challenges
The hard part was making cross-tenant data leaks structurally impossible rather than a thing I had to remember. Getting there meant data-access paths that make a cross-tenant query fail to compile, and uniform 404s so one trainer can't probe whether another's record exists. The reminder pipeline was its own problem: it runs on a timer in a separate Function App, reads schedules from the same Postgres the API uses, and signs pause links with a key the API validates, so a key mismatch makes every pause link look forged.
06 · What I Improved
- Built on a five-document spec frozen before any code, then amended in place whenever implementation contradicted it, so the docs describe what exists rather than what was planned
- Made security structural instead of a matter of discipline: data-access paths where cross-tenant queries fail to compile, and uniform 404s so records can't be probed
- Adopted mutation testing as standard practice: break the mechanism, confirm the test fails, revert
- Split the reminder pipeline into a queue-backed Function App so a slow or failing send never blocks an API request
- Stored weights canonically and converted at the edge, so a client switching between kg and lb never rewrites their own history
- Gated deploys on EF migrations, so a failed migration stops the pipeline instead of shipping code that assumes it applied
07 · What I Learned
- Green tests prove nothing on their own. Mutation testing caught seven tests that passed while asserting nothing real, one of which was pinning a shipped bug as correct behaviour.
- Specs go stale against reality. The trainer screens were specified desktop-first, and it took using them in a gym to notice that a personal trainer doesn't work at a desk.
- Writing the spec first only pays off if you amend it when the code disagrees, otherwise you end up with documentation that quietly describes a different product.
- Designing the data layer so the wrong query won't compile beats reviewing every query for the right tenant filter.
- Showing last session's numbers next to the input is the entire product for a client mid-set, and it changed how I prioritised everything else.