Every fitness app I’ve tried is a subscription that wants to be a social network. I don’t want a feed. I want to open something between sets, tap a row, and put my phone back down.
So fit. has no accounts, no streak guilt, no leaderboard, and no ambition to become a platform. It generates a workout each morning, gets out of the way while I do it, and keeps enough history to tell me whether the weight is going up. It’s one of the family’s nine little apps, and the one where logging friction turned out to be the entire ballgame.
The thing every workout app gets wrong about phones
If I were building someone else’s gym app, I’d copy this first.
When you tap “start workout,” the app enters a REC mode: a timer in the bottom chrome, a done 3/6 counter, the current lift highlighted, tap a row to check it off and the highlight auto-advances to the next one. Escape bails out of the whole thing.
And it takes a wake lock.
await navigator.wakeLock.request('screen');
That one line is the difference between a usable gym app and a bad one. Without it your phone sleeps between sets, and every single set begins with unlocking your phone with sweaty hands. The lock gets re-acquired on visibilitychange, because the browser silently drops it whenever you tab away, and the release call is wrapped in a try/catch since a failed release should never prevent you from exiting. On browsers that don’t support it, the whole thing silently no-ops.
Nobody notices this feature. That’s the point of it.
You cannot rate a session you haven’t done yet
The first version asked how hard the workout was going to be, up front, as part of starting it. Which is nonsense — you’re guessing at a number about a thing that hasn’t happened.
So the commit flow is two-stage. When you hit stop, the app doesn’t submit; it swaps in a small inline picker — “How’d that feel?” — eleven buttons, 0 through 10, then a confirm. Only then does it write the entry.
Moving that question from the beginning to the end was the highest-value change in the app, and it’s a UI change, not an engineering one. Data collected at the wrong moment isn’t noisy, it’s fictional, and it poisons every trend built on top of it.
The prompt has a table explaining itself
The daily workout comes from one Haiku call per non-rest day, with structured output forced through a tool schema:
tool_choice: { type: 'tool', name: 'record_workout' }
That tool_choice is not optional. Without it the model will occasionally answer in prose instead of calling the tool, and your parser explodes on a perfectly friendly paragraph.
But the habit that has paid for itself is the documentation. Every line of that system prompt has a row in a table saying what it’s for and whether it’s safe to delete:
- The equipment list — without it you get prescriptions for cable machines and barbell benches that don’t exist in my garage. Not removable.
- “curls, lateral raises, tricep work all welcome” — counters the model’s tendency to be puritanical about isolation work. Kept permanently after a draft came back with zero arm work. Not removable.
- The paragraph insisting the workout respect the day’s slot — without it the model ignores the weekly split and freestyles a generically good workout. Marked load-bearing for split fidelity.
Six months later, every line of a system prompt you wrote in an afternoon looks arbitrary and deletable. Half of them exist because of one specific bad output you’ll never remember. Writing down which is which costs ten minutes and saves you re-learning it.
Two more details there. Rest days short-circuit entirely — no API call, a deterministic object built in code, about 30% of days costing nothing. And the whole feature runs on roughly thirteen cents a year.
Then it started generating fiction
This is the failure that taught me the most.
The app was built around a periodized program: a fourteen-week arc, phases with different emphases, a weekly split, each day’s slot description feeding the prompt so the generated session fits the plan. Elegant. It worked exactly as designed.
Around the tenth of June, I stopped doing that program. My wife and I started training together instead — five days a week, the same circuit: curls, air squats, lat pulldowns, push-ups, sit-ups, ten reps, three or four rounds.
The app did not know. It kept generating beautiful, well-structured, correctly-periodized workouts for a program that no longer existed, and I kept not doing them. For about three weeks the site was a confident, attractive work of fiction.
The fix was to make the plan match reality: the actual circuit became the slot description, so the generated checklist is the circuit. I pushed it into the next phase too, so it couldn’t revert at the next boundary.
The lesson is bigger than fitness. A tracker that models your aspiration instead of your behavior doesn’t fail loudly, it fails politely — producing correct-looking output while quietly becoming irrelevant, and the only symptom is that you stop opening it.
Logging by typing a sentence
The other half of the friction problem: sometimes you don’t want to fill in a table at all. You want to say what you did.
So POST /api/quicklog takes plain text through a second Haiku call into a structured entry. “4 rounds of curls, squats and pushups at 10 each” becomes four sets of ten across three exercises, and “yesterday we did” back-dates it.
The important part is canonical name mapping. “curls” becomes Bicep curls, “lat pulls” becomes Lat pulldowns, “pushups” becomes Push-ups. Not for tidiness — because history, progression charts, and personal-record detection all group by exercise name. Let free text into that field and every session becomes its own orphan exercise with no history. Unknown names pass through title-cased rather than being forced into something wrong.
And when a value isn’t in your sentence, it defaults and says so — an unstated effort rating lands as 6 with “RPE assumed 6” written into the notes. A silent default is a lie you’ll read as data six months later.
Then the bit that made it stick: I can text a workout to my home assistant, and it POSTs my message verbatim to that same endpoint. It never rewords, never summarizes, never helpfully interprets first. One parser, one interpretation, one place to fix a bug. The moment two components both “understand” natural language, you own two subtly different grammars and no way to tell which one mangled your data.
Small rules that punch above their weight
Hide the visualization until the data earns it. The activity heatmap doesn’t render below five sessions on file. Sparse data renders worse than no data — a nearly-empty grid reads as “this app is broken,” not “you’re new here.”
Normalize aggressively for matching, never for display. “Goblet Squats” and “Goblet Squats (light)” collapse to one slug so their histories merge, but the name you typed is never rewritten on screen.
Compare strictly before today. Personal-record detection checks a new lift against history earlier than the current date, so a lift you just committed can earn its own badge instead of being disqualified by its own existence.
Parse leading digits and move on. "30 lb" → 30, "8-10" → 8. Users type units; a parser demanding clean input just means nobody logs weight.
Two bugs and a backup
The good bug: after committing a workout, refreshing the page still looked like you hadn’t done it. The server knew — it had the entry — but only a small status pill changed while the rest of the page still read as “ready to start.” Half-rendering a state is worse than not detecting it, because people trust the loud part of a page over the quiet part. The fix also needed cache-control: no-store, since browser caches were serving the pre-workout version anyway.
The log backup has two legs: a nightly rotation on the storage pool, plus a copy into a directory that gets auto-committed to a private repo overnight. The second exists because a rotation on the same machine isn’t a backup, it’s a copy. That git history is now a full versioned trail of every workout I’ve logged, off-site, for free, as a side effect of a cron job that was already running.
Today’s session also rides along in the morning brief — the endpoint that serves it doubles as a cache warmer, so the email’s request is what triggers generation before I’ve opened anything.
Which means most mornings I’ve already read my workout before I’ve decided whether I’m doing it.