← back to overview

iOS app · built with Claude Code · Aug–Sep 2026

I wrote the spec. Claude Code wrote the Swift. I use it every week.

GymTracker is a SwiftUI + SwiftData iOS app I use for every workout. I wrote the spec, made the product and data-model decisions, and reviewed every change - Claude Code wrote and committed the Swift. This is what that process actually looked like.

56commits
Aug–Sep 2026
6weeks
spec to daily use
0external libraries
Apple frameworks only
100%offline
no account, no server

the product plan

Before any code, I defined the actual goal: log every set of every workout, with minimum friction mid-set, fully offline. From there I scoped an MVP - program setup, an exercise library, and the live workout flow - and pushed everything else into later waves: a rest timer, workout history, body-weight tracking, data export, and eventually a retrospective view of progress against each program's goal.

Only once that scope and sequencing were settled did work move to the data structure, and only after that to the screens themselves.

the product decision

Prescribed is not the same as trained. That's the one sentence the whole app is built to protect, and it's a product decision before it's a technical one.

A program defines what should happen: an ordered day, with prescribed sets, reps and starting weight. A session records what actually happened, logged directly against the exercise itself - not against the plan.

That separation is deliberate. If I swap an exercise mid-workout (the gym's cable machine is taken, so I do rows instead), the session should still record exactly what I did, without silently rewriting what was planned or losing the substitution. Plan and reality stay two different things, on purpose.

designed for the gym floor

The interface rules came from actually using it mid-set, not from a design system: large touch targets, one-handed operation, minimum taps to log a set, readable at a glance on a screen I'm not looking at closely. Every set is pre-filled from the last time I did that exercise, so logging a normal set is a single tap, not data entry.

Ending a workout requires a confirmation. A stray tap mid-set shouldn't be able to erase 45 minutes of logged training.

Live workout screen with set logging and rest timer
Live workout - set logging, pre-filled from the last session of the same exercise.

the design pass that got redone three times

The home screen's progress view is a panoramic "path to the summit" - a hike up a mountain range that fills in as the training block progresses. It went through visible iteration, commit by commit: three mountain layers aligned on the same curve, a lighter, more distant peak repositioned onto the same ridge as the main summit, then darker, less saturated mountain colors matched against a Claude Design reference image. None of that was code review - it was closer to art direction, done in plain language, one visual adjustment per commit.

GymTracker home screen, current design
Home - current "liquid glass" design pass.
Mountain path progress visualization
The "path to the summit" progress view - three mountain layers on one shared ridge line.

Later, the whole app went through a second, larger pass: a "liquid glass" visual refresh across every screen, including one screen ported pixel-for-pixel from a Claude Design canvas file straight into SwiftUI. The design tool and the coding agent were two steps of one pipeline, not two separate tools I had to bridge myself.

how it's built

Everything lives in SwiftData, fully offline - there's no reason a set-logging app needs an account or a server. The schema is deliberately built around the plan/reality separation above, plus room for non-lifting activity (a run, a hike, a round of golf) without forcing it through the strength-training shape. I'm keeping the exact model to myself - it's a small app, but it's mine.

what's next

Still in daily use, still being tuned - small fixes and refinements as real workouts keep surfacing edge cases the spec didn't anticipate.

demo

Video walkthrough slot - drop in as demo.mp4 (or embed a hosted link) and swap this block for a <video> or iframe embed.