I wanted one page between sets: the movements for today, a timer, and one tap when a set was complete. Every fitness app I tried wanted to add a subscription, a feed, a leaderboard, or all three.

So fit. began as a deliberately small tool for a phased plan with a few firm movement limits. It has no accounts, no streak guilt, no leaderboard, and no ambition to become a platform. It generates a workout each morning, stays out of the way while we train, and keeps enough history to show whether the work changes over time.

The fit. today page: the daily circuit, the phase and focus lines, and one big start-workout button.

The thing every workout app gets wrong about phones

If I built a gym app for somebody else, I would copy this part first.

You tap “start workout”, and the app enters a REC mode. A timer sits in the bottom chrome, next to a done 3/6 counter. The current lift is highlighted. You tap a row to check it off, and the highlight moves to the next lift. Escape exits the whole mode.

And the app takes a wake lock.

await navigator.wakeLock.request('screen');

That one line separates a usable gym app from a bad one. Without it, your phone sleeps between sets. Then every set starts with an unlock, performed with sweaty hands. Sweat and failed face unlocks are directly correlated. The app re-acquires the lock on visibilitychange, because the browser silently drops the lock when you tab away. The release call sits in a try/catch, because a failed release must never block your exit. On a browser without support, the feature silently does nothing.

Nobody notices this feature. That is its purpose.

You cannot rate a session before you do it

The first version asked a question up front, as part of the start: how hard will this workout be? That is nonsense. You guess a number about an event that has not happened.

So the commit flow has two stages. You hit stop, and the app does not submit. It shows a small inline picker — “How’d that feel?” — with eleven buttons, 0 through 10, then a confirm. Only then does it write the entry.

I moved that question from the start to the end. That was the highest-value change in the app, and it is a UI change, not an engineering change. Data collected at the wrong moment is not noisy. It is fictional, and it poisons every trend built on top of it.

The prompt has a table that explains 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 sometimes answers in prose instead of a tool call. Then your parser explodes on a perfectly friendly paragraph.

But the habit that paid for itself is the documentation. Every line of that system prompt has a row in a table. The row says what the line is for, and if the line is safe to delete.

  • The equipment list. Without it, the model prescribes cable machines and barbell benches that my garage does not contain. Not removable.
  • “curls, lateral raises, tricep work all welcome”. The model has a puritan attitude toward isolation work, and this line counters it. One draft came back with zero arm work, so the line is permanent. Not removable.
  • The paragraph that ties the workout to the day’s slot. Without it, the model ignores the weekly split and improvises a generically good workout. Marked load-bearing for split fidelity.

Six months later, every line of a system prompt looks arbitrary and deletable. Half of the lines exist because of one bad output you will not remember. To write down which is which costs ten minutes. It saves you a full re-learning.

Two more details live there. Rest days short-circuit completely. No API call occurs; the code builds a deterministic object. That makes about 30% of days cost nothing. The whole feature costs about thirteen cents per year.

Then it generated fiction

This failure taught me the most.

I built the app around a periodized program: a fourteen-week arc, phases with different emphases, and a weekly split. Each day’s slot description feeds the prompt, so the generated session fits the plan. Elegant. It worked exactly as designed.

Around the tenth of June, I stopped that program. My wife and I started to train together — 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 generated beautiful, well-structured, correctly periodized workouts for a program that no longer existed. I did not do them. For about three weeks, the site was a confident, attractive work of fiction.

The fix made the plan match reality. The actual circuit became the slot description, so the generated checklist is the circuit. I pushed the circuit into the next phase too, so the plan could not revert at the next boundary.

The lesson is bigger than fitness. A tracker that models your aspiration instead of your behavior does not fail loudly. It fails politely. It produces correct-looking output while it quietly becomes irrelevant. The only symptom: you stop opening it.

Logging by typing a sentence

The other half of the friction problem: sometimes you do not 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 and returns a structured entry. “4 rounds of curls, squats and pushups at 10 each” becomes four sets of ten across three exercises. “yesterday we did” back-dates the entry.

The important part is canonical name mapping. “curls” becomes Bicep curls. “lat pulls” becomes Lat pulldowns. “pushups” becomes Push-ups. This is not for tidiness. 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.

An unknown name passes through in title case. The parser does not force it into a wrong match. And when a value is not in your sentence, the parser applies a default and says so. An unstated effort rating lands as 6, with “RPE assumed 6” written into the notes. A silent default is a lie that you will read as data six months later.

Then the part 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 interprets first. One parser, one interpretation, one place to fix a bug. When two components both “understand” natural language, you own two subtly different grammars. Then you cannot tell which grammar mangled your data.

Small rules, large effects

Hide the visualization until the data earns it. The activity heatmap does not render below five sessions on file. Sparse data renders worse than no data. A nearly empty grid reads as “this app is broken”, not as “you are new here”.

Normalize 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 before the current date. So a lift you just committed can earn its own badge. Its own existence does not disqualify it.

Parse the leading digits and move on. "30 lb" → 30, "8-10" → 8. Users type units. A parser that demands clean input means nobody logs weight.

Two bugs and a backup

The good bug: after a committed workout, a page refresh still showed the pre-workout state. The server knew — it had the entry. But only a small status pill changed, and the rest of the page still read as “ready to start”. A half-rendered state is worse than no detection, because people trust the loud part of a page over the quiet part. The fix also needed cache-control: no-store, because the browser cache served the pre-workout version anyway.

The log backup has two legs. A nightly rotation runs on the storage pool. A copy also goes into a directory that auto-commits to a private repo overnight. The second leg exists because a rotation on the same machine is not a backup. It is a copy. That git history is now a full versioned trail of every workout I logged — off-site, free, and a side effect of a cron job that already ran.

Today’s session also rides along in the morning brief. The endpoint that serves it is also a cache warmer. The email’s request triggers the generation before I open anything.

So most mornings, I have read my workout before I decide if I will do it.