GainBook – an offline-first strength training tracker built with Expo

Install from Google Play. The iOS build is currently in TestFlight, with the App Store release coming soon.

I have been lifting for years, and over that time I went through most of the workout trackers available in the stores. Every one of them was missing something I needed: the set types I actually use, supersets that stay synchronized, training volume calculated correctly for dumbbells and single-leg exercises, or a rest timer that stays out of the way. Eventually I stopped searching and built my own.

GainBook is a strength training tracker for iOS and Android. It runs entirely on your phone — no account, no cloud, no ads, and no internet connection required. This post is part announcement and part write-up of the parts that were actually interesting to build.

The loop: plan, log, review

Templates are organised into plans and can be scheduled for specific weekdays. Each set has a target weight, reps, rest time and set type — warm-up, working, drop set or failure — while exercises can be grouped into supersets whose sets remain synchronised. Whether you are editing a template or standing in front of the rack, the app shows you what you did the last time you performed that exercise.

During a workout, you log reps and weight, add or remove sets on the fly, and the rest timer starts automatically when you complete a set, with sound and a notification if you have left the app. If the equipment you need is taken, you can replace the exercise mid-session and the history records the substitution. A persistent bar on every other screen lets you jump back into an active workout.

Volume is the part that took the longest

Most trackers calculate total volume as sets × reps × weight and leave it at that. That number is wrong in three ways that bothered me enough to fix them.

1. Your body weight is load

A pull-up with no added weight is not a zero-volume set. But a plank or a glute kickback should not count the same proportion of body weight as a pull-up either. My first attempt derived the percentage from the exercise name, and the results were terrible: it assigned 60–90% to static stretches that move nothing, 70% to a single-leg kickback even though one leg is about 16% of body mass, and 70% to a hyperextension compared with 35% for a sit-up even though both rotate roughly the same mass around the same joint. The result was that three sets of hyperextensions could out-tonnage an entire bench session.

So that heuristic is gone. Every exercise now has a reviewed body-weight load percentage, derived from segment mass × lever factor using Winter’s segment table (whole body 100, above the hip 68, above the knee 85, one leg 16, one arm 5), multiplied by a leverage/range-of-motion factor. The exercise name alone does not tell you how much mass a movement actually moves.

2. A set does not train one muscle

A bent-over row is not simply a “back exercise” — it is roughly 42% middle back, 32% lats, with the remainder distributed across biceps, shoulders and forearms. GainBook models each exercise as a single distribution across every muscle it works, including the primary muscle, with no primary/secondary split and no cap on any individual share.

The important part is that the set’s volume is divided across those muscles, never multiplied. The distribution always adds up to exactly 100, so the volume credited to the muscles equals the volume of the original set. All 892 exercises in the library were reviewed individually based on their own mechanics rather than inherited from a movement family, and each exercise has its own split displayed in the app.

3. Muscle groups are not comparable at equal tonnage

A 98 kg bench press and a 230 kg leg press can represent roughly the same effort, so colouring a body map by raw kilograms means the legs are always on fire while the arms barely register. Each muscle group therefore has a strength capacity — how much load it can actually handle, based on intermediate 1RM standards and attributed through the app’s own distribution — and every heatmap and ranking metric is normalised against it.

One thing I got wrong initially was normalising each muscle independently against a “hard session” reference. But a full-body workout distributes its volume across ten muscles, so no individual muscle can ever get close to that reference. A real four-exercise template peaked at 0.32, leaving the entire map inside just 27% of the colour range. The result was almost the same hazy map regardless of what you trained. The map was effectively answering “how does this compare to a maximal session?” when the useful question is “where did this workout’s work go?”. Now the whole map is resolved together: the shape is stretched so the hardest-worked muscle reaches the top of the range, then dimmed according to the overall magnitude of the session. That way, a warm-up still cannot look like a leg day.

Warm-up sets, incidentally, do not count toward training volume at all. A 25-rep bar-only warm-up used to light up the heatmap just like a working set.

Everything stays on the device

There is no backend. The entire app is a local SQLite database (expo-sqlite) accessed through Drizzle ORM, with schema migrations generated from the TypeScript schema. No account, no sync, no server bill, and nothing to leak.

The trade-off is that backups are entirely my problem, so there are three options and they all produce the same file: the share-sheet export you can send to yourself, automatic iCloud Drive backup on iOS, and Android Auto Backup. A backup taken from the Files app is byte-identical to an export, so it can always be imported again. Creating a separate private backup format would have meant that the user’s own copy might no longer be restorable.

That file is a zip containing a single gainbook.db. A workout log consists of short, repetitive rows spread across mostly-empty SQLite pages, so it compresses by roughly 8×: a synthetic three-year history of 450 sessions and 10,800 sets went from 672 KB down to 83 KB. Import detects the file from its leading bytes rather than relying on the extension or MIME type, because a file that has passed through a share sheet or two may arrive with whatever filename the last app decided to give it. It also still accepts the uncompressed databases exported before the zip change, since those files are already sitting in people’s Files apps and there is no migration mechanism that can reach them.

The exercise library

GainBook exercise detail for Dumbbell Bench Press with photo, muscle groups and equipment tags
Exercise detail, including the 2× volume multiplier for dumbbell work.

The bundled library is based on free-exercise-db (public domain) — 892 exercises with instructions, photos, equipment and difficulty — combined with the muscle distribution and body-weight load data described above. You can search by name, muscle group or equipment, add your own exercises, and open any exercise to see its complete history and per-muscle trend. Custom exercises can only be deleted when nothing still references them.

Stack

  • Expo SDK 57 / React Native 0.86, New Architecture enabled
  • Expo Router for file-based navigation
  • expo-sqlite + Drizzle ORM for local storage and migrations
  • Reanimated and FlashList for the set-logging screens
  • HealthKit on iOS — workouts written to Apple Health, live heart rate from Apple Watch or AirPods, per-set max heart rate and active energy
  • A vendored picker and three small native modules (iCloud backup, Live Activities)
  • Maestro for end-to-end tests on both simulators, plus Jest for the model code
  • English, Spanish and Russian
  • OTA updates through expo-updates on fingerprint runtime versions

That last one probably deserves its own post. With runtimeVersion.policy set to fingerprint, the hash is the runtime version, and anything that changes it can silently strand every binary already in the field. Nothing errors; the update simply never gets offered. In my case, an ESLint-only commit generated a new runtime version because package.json scripts were included in the hash. It is worth knowing about before you ship one.

Try it

GainBook is free on Google Play, with iOS following shortly. I train with it every week, which is the main reason it keeps evolving — whenever I find something missing, I build it. If you prefer owning your training data instead of storing it on someone else’s server, that is exactly what GainBook is built for.