← Projects
Personal project Offline PWA · no backend In daily use · open source

Forge

A workout logger that lives on my phone, tells me what to lift next, and shows the reasoning behind the number. No account, no server, and nothing anywhere in it that can tell me I’ve missed a session.

React 19 Zustand Vite Service worker / PWA Vitest Cloudflare Workers

Source on GitHub — MIT ↗

Why I built this

I keep a journal of how training actually goes, and by the summer it said the gym is the first thing I drop when the week gets heavier: eight weeks running, each with a different reason. Then a week came with none of them, energy fine and nothing in the diary, and I still barely trained. The reasons had been occasions, not causes.

The apps I paid for recorded all of it and decided nothing. I wanted something that holds the plan, has the weight and reps ready, and can show why it picked them, and when the subscription (around £70 a year) lapsed, building it looked the better deal. A first attempt in April tried to cover strength, nutrition and recovery at once and went dormant in May under its own weight. This version kept one job: record sessions and say what to lift next.

What it does

A three-day full-body rotation (Squat-led, Hinge-led, Press-led), four exercises each, about forty minutes. Open it and it already knows which session is due and what the first lift will be. Tick sets off with a rest timer between them, and a summary at the end says what changes next time. Swaps offer like-for-like replacements first, a session can be built on the spot and still feed progression, and runs and other cardio go in the same log.

It installs to the home screen from Safari, keeps everything on the phone with no account, and works with no signal, which matters in the basement gym I use. Backups are a JSON file you export yourself; after eight sessions without one, it asks.

What it refuses to do

Most of what makes fitness apps unpleasant is one mechanism: a state where you have failed. Streaks break, rings sit unfilled, a calendar draws the day you skipped. Forge has nowhere to store a failure.

  • Blocks count completed sessions, not dates: “Session 4 of 12”, never a week number. Miss a fortnight and the block stretches rather than breaks
  • Days with nothing logged are not drawn in History at all, with no greyed-out row or rest marker
  • The consistency line is past tense and recomputed each time (“Trained 5 of the last 6 weeks”), so there is no current run to break
  • The training-mix bar is proportional to what you did most of, never to a target
  • The week shape (lift, cardio, long, rest) feeds a suggestion, not an appointment, so a missed day leaves no trace

Also kilograms only, dark only, and no haptics setting, because navigator.vibrate() does nothing in an installed iOS web app.

How it decides

Double progression, deliberately the boring rule: work up a rep range at a fixed weight, and when every set reaches the top, add one increment. “Every set” allows a rep of shortfall per set, pooled, because the third set trails the first through fatigue and 12-11-10 would otherwise stall for ever; no set may fall below the range. More than half the sets short, twice running, and it takes 5% off. Increments round to what you can load: 2 kg for dumbbells, 2.5 kg otherwise, with per-exercise overrides for machines.

Every target carries its reason in plain words (“Up 2.5 kg — you hit 3×12 last time”), because a suggestion you can’t audit is one you stop trusting. Targets are computed from logged history every time and never stored, so correcting a mistyped set fixes the next target immediately. The working weight is the heaviest completed set, not the last, since sets added mid-workout are usually lighter back-offs.

Architecture

The engine is pure and knows nothing about storage or screens, so the part where a bug would corrupt months of training can be tested on a laptop.

Installed PWAoffline shell
Media CDNthe only network call
ScreensToday, Plan, Library, History
JSON backupvia share sheet
store.jsone Zustand store
localStorageone key, debounced
coach.jsthe day’s session
nextTarget()all training logic
session.jslive workout
Everything runs on the phone. Screens talk to one store; the store calls the engine and saves to localStorage.

It deploys as a static folder to any HTTPS host, and HTTPS is required: service workers, offline caching and the screen wake lock refuse to run without it. One trap: Cloudflare Access looks like a security upgrade but breaks Add to Home Screen, the service worker and offline use in one click.

The exercise library is 371 records cut from an openly licensed set of 1,324, safe to widen and dangerous to narrow, because a logged exercise whose record disappears loses its name. The illustrations belong to a third party, so the app bundles none of them: it loads them at runtime, and every exercise screen works without an image.

Screens

Real screens, real history — my actual training log, ten sessions in. The exercise illustrations are switched off, so each exercise shows a lettered tile instead.

Forge's Today screen — a Lift card headed Press-led, Session 11 of 12, with the first target and a list of the session's four exercises each showing its own reason
Today. It has already picked the session and knows the first number.
Forge's logger — 32.5 kg by 8 to 12 reps, last time 32.5 kg for 10, 12, 10, with large weight and rep steppers and an expanded panel reading Holding, you got 10, 12, 10 last session
The logger, with the target’s reasoning opened.
Forge's History screen — lifting sessions and runs interleaved in one timeline grouped by day, with days that have nothing logged simply absent
History. Note the missing days — they aren’t drawn at all.

The hard part

A green test suite means little here. Two total-failure bugs shipped past 172 passing tests, a clean lint and a good build. The worst wrapped the store in a plain function, which silently disabled every control in the app while the tests, which build their own store, stayed green. So the tests aim almost entirely at the pure progression engine, where a quiet bug corrupts months of training, and everything else is gated on use on a real phone.

Then, on 10 September, the engine itself turned out to be wrong: the one thing the app exists to do, saying when the weight goes up, had been wrong since launch. A review of every other calculation followed and found more counting faults, the plainest a block counter that ignored deleted workouts. It was the one number stored rather than derived, and it is now derived like everything else. The suite stands at 317 tests across 14 files.

The data also has to survive me. It lives in one browser storage key that iOS can clear, and deleting the home-screen icon wipes it, which I confirmed the expensive way. So the schema version is frozen at 1, since bumping it would break every older backup, and every new field is additive and optional. The export records its timestamp only if the share sheet completed, so a cancelled save can’t silence the backup reminder.

Making it public

Published under MIT on 23 September as a scrubbed copy: the working repository holds personal notes, the design spec and training data. About forty code comments that cited the spec were rewritten to stand alone and checked against the code, which caught one describing the wrong rule. The README is written for a stranger who wants to self-host, the Plan screen’s starter template lets anyone build their own program, and the three rotations stay fixed. The illustrations’ licence doesn’t pass to anyone who clones it, so a single build flag produces an instance that never requests them, and the repository’s screenshots use invented history.

Where it’s at

Deployed on 27 August 2026 and used in every session since. The test that mattered, whether it survives real gym days where the first version didn’t, passed on 6 September: its exports now feed two training dashboards, so stopping would show.

Those exports also showed what the app doesn’t say. On 2 September it reported 11 of 12 sets done on a Squat-led day without mentioning that the skipped lift was the squat: completion is not adherence. Custom sessions and swap suggestions followed, so a departure from the template is logged as what it was, and on 11 September the log held its first session with every prescribed set done and nothing swapped or dropped. One session, not yet a trend.

  • Eleven of the first block’s twelve sessions done by 22 September
  • Open source under MIT since 23 September 2026
  • 317 tests across 14 files; the phone is still the real gate: a backup round-trips identically, a deploy preserves history mid-block, a reinstall destroys it
  • Still open: a black band along the bottom of the screen at launch that clears when you drag. Three attempts to fix it were each worse than leaving it

What’s next

The second block is designed from the first block’s logged history, the first time the app’s record has shaped the plan rather than reported on it; loading it onto the phone is next. Then the rest of the adherence gap: noticing that a session named after a lift didn’t include it, without turning into the scolding the rest of the design avoids. Detecting it is trivial; getting the tone right is the problem.

← Back to Projects