vibescoder

No Bench, No Rack, No Excuse: Adding a Significant Feature to the Fitness Tracker

·9 min read

My fitness tracker — closing in on a year old now — was built over several coding sessions and a few different agents. It’s a simple app. Among several things, it pulls real weight-lifting data from Tonal. But Tonal is a wall-mounted machine, and plenty of the places I end up working out don’t have one. So I asked my agent to code a feature around three dumbbell-only routines to rotate between, accommodating the weights most commonly found in a hotel gym, a friend’s spare room, or an Airbnb: 5, 10, 15, and 20 lb pairs. Include things like a a timer, a live running tally of weight lifted, and a way to log the finished session back into the same table Tonal and Peloton already write to. We then got to planning, coding, and testing.

Below is the journey. I asked the agent to summarize our session. As happens sometimes, the agent wrote this from its perspective. This always amuses me and I choose to keep but not it here. My vibes, his thoughts (I think it’s a he). So, he gets credit.

The Plan Before the Code

The first version of the split was Legs / Back-and-Shoulders / Arms-and-Chest. I pushed back on that one: a Push/Pull/Legs split avoids hitting the same joint twice in a session — shoulders get worked by both presses and “chest day” moves under the original grouping. That suggestion got adopted, and the exercise list got built around one hard constraint: no bench, no rack, floor and standing movements only.

The harder design problem was the weight math. Two dumbbells moving together — an overhead press, say — double the load per rep. My first pass at that logic was wrong, and we caught it with a concrete example: a bent-over row done one arm at a time, at 10 lb, is 10 lb per rep, not 20, even though there are two 10 lb dumbbells in the room. That distinction became a load field on every exercise — bilateral, unilateral, or single — and it’s the one piece of this feature that had to be exactly right before anything else made sense, because it’s the number that eventually gets written to the database as weightLifted.

Everything else got scoped deliberately thin for a first pass: routines as a plain TypeScript constant, no schema changes, no editable reps yet, no persistence beyond crash-safety in localStorage. Progression, editable sets, and MCP exposure were explicitly called out as “phase 2, don’t block it, but don’t build it yet either.”

Shipping Push, Pull, Legs

Phase 1 became a new /freeweights tab: pick a day, a session timer starts, a checklist of sets renders with planned reps and weight, and checking a set adds to a running tally. Finishing the workout posts one WorkoutSession row — source: "Free Weights", activity: "Weight Lifting" — through the exact same /api/workouts endpoint Tonal and Peloton use, so it shows up in every existing chart and goal without any of that code needing to know Free Weights exists.

Seeding the actual numbers meant working backward from a target: roughly 8,000 lbs for a Push or Pull day, 10,000 for Legs, “ballpark is fine.” Five sets of fifteen reps across five exercises per day, with heavier compound movements (floor chest press, bent-over row, suitcase squat) getting the 20 lb dumbbells and isolation moves (lateral raise, calf raise) getting the 5s, landed within a few hundred pounds of both targets.

One bug got fixed as a freebie along the way: the mobile header’s sync buttons didn’t reserve fixed width, so “Peloton” turning into “Syncing…” would shrink the flex row enough to wrap the “Fit Track” title onto two lines. Icon-only buttons on mobile fixed it permanently, and it got bundled into this change since the header was already getting a new tab switcher regardless.

This shipped straight to main — “I’ll test it myself, it’s just a hobby app,” with explicit sign-off first.

Phase 2: Progression That Isn’t Allowed to Regress

The interesting design problem in phase 2 wasn’t the database table — that part is genuinely small, one FreeWeightProgress row per exercise, an override that falls back to the code default when absent. It was translating “I want the app to challenge me to keep that as the new baseline so I don’t regress” into an actual mechanic.

The answer: weight stays fixed to the dumbbells that physically exist (you can’t buy a 12.5 lb pair mid-trip), so only reps progress, one tap at a time in Settings. But reps can’t climb forever on light weight before that stops being useful, so there’s a ceiling — 20 reps per set — and hitting it while lighter than the heaviest tier owned surfaces a “Level Up” prompt: move to the next dumbbell size, restart at 8 reps. The baseline that gets displayed as each set’s target during a session is always the current one, DB override merged over the code default, so there’s no way to accidentally see last month’s easier numbers. And at the end of a session, the summary compares actual reps completed against that baseline and calls out anything that came in short — a nudge, not a hard gate, because this is a hobby app, not a compliance system.

Editable sets got built alongside it: a pencil icon per set that opens actual-reps and actual-weight fields, defaulted to the plan, for the moment you fail a rep or grab the wrong dumbbell. One tap still logs a set at planned values — the common case stayed exactly as simple as phase 1 left it.

This round went through an actual feature branch and pull request instead of straight to main, complete with a collapsible design-notes section in the PR body — a small but real shift in process between phase 1 and phase 2 of the same feature, on the same repo, in the same day.

Exposing It to Agents

The stated broader goal was “expose more FT data for analysis with agents,” and it turned out most of that was already free: Free Weights sessions log into the same WorkoutSession table Tonal and Peloton use, so the existing list_workouts MCP tool already surfaces them, no new work required. What was missing was the programming — the targets themselves, not the history of hitting them — so list_freeweight_progress and set_freeweight_progress joined the MCP server, following the exact pattern every other mutation tool in that file already uses: direct Prisma access, an audit-log row on write. The fitness tracker’s MCP server now exposes twelve tools total; two of them didn’t exist twelve hours earlier.

A Glossary the Code Never Needed

The most telling moment of the whole session came after testing started: “What is a Floor Chest Press? I just want to read a two-sentence description of each exercise before starting.” The app had never needed to explain an exercise to anything — the code just needed a slug, a weight, and a rep count. Whoever was actually about to do the reps needed something completely different.

Fifteen exercises, each with a genuine two-sentence how-to (stance, movement, nothing fancier), landed as a /freeweights/glossary page, deep-linked from every exercise name in the routine picker and the active session, opening in a new tab so a mid-workout lookup doesn’t lose the running timer. It’s a small feature by line count and a good reminder that “the data model has everything it needs” and “whoever’s doing the workout knows what they’re doing” are two completely separate claims.

Auditing Dependencies Without Overreacting

Separately from the feature itself, a routine “should we merge to main” check turned up a failing npm audit CI job — 13 findings, including a critical one in the Auth.js/NextAuth dependency chain. Not every fix is worth taking blind: npm audit fix alone (no --force) cleared the critical and three of six highs by shifting only package-lock.json inside existing semver ranges — verified clean with a full tsc and next build before it went anywhere. The three findings that remained all trace back to the same root cause: the Next.js-reported CVEs (Server Actions DoS, SSRF, cache confusion) only have a fix at Next.js 16.3.0, a major version up from the app’s current 15.x. That’s not a dependency patch, it’s a framework upgrade with real surface area — auth flow, MCP’s streamable HTTP transport, middleware — and it got deliberately deferred to its own reviewed pass instead of getting bundled into a “fix vulnerabilities” commit that quietly changed how the whole app runs.

What Connects Them

Every real decision in this session came down to matching the mechanism to a constraint that already existed in the physical world, not inventing a new abstraction to feel clever. The load-type field exists because a dumbbell in one hand and two dumbbells in two hands are different amounts of weight, full stop. The level-up ceiling exists because you can only own so many dumbbell sizes. The baseline nudge exists because “don’t regress” needs a number to compare against, not a vibe. Even the dependency audit followed the same instinct: fix what’s provably safe now, and treat “upgrade the framework” as the different, larger decision it actually is, instead of letting a CI red X talk anyone into a same-day major version bump.

By the Numbers

  • 3 routines (Push, Pull, Legs), 15 exercises total, 5 per day
  • 4 dumbbell weights to design around: 5, 10, 15, 20 lb
  • 3 load-type classifications (bilateral / unilateral / single) needed to get weight math right
  • ~8,250 lbs planned volume per Push/Pull day, ~10,350 lbs for Legs — against a “ballpark 8k/10k” target
  • 2 phases shipped in one session: phase 1 pushed straight to main, phase 2 through a feature branch and PR #22
  • 5 commits total across both phases plus a dependency-audit follow-up
  • 1 new database table (FreeWeightProgress), additive only, applied via the existing prisma db push build step — no migration files needed
  • 12 total MCP tools on the fitness tracker now; 2 of them (list_freeweight_progress, set_freeweight_progress) didn’t exist before this session
  • 15 exercise descriptions written for a glossary page that exists purely because testing, not the code, surfaced the need for it
  • 13 → 6 npm audit findings after a non-breaking npm audit fix, including clearing the one critical; 3 remaining highs deliberately deferred to a Next.js 16 upgrade instead of force-patched same-day

Comments