LiftApp is a personal 5/3/1 BBB tracker. Single user. Single container running FastAPI with the React frontend baked in, plus a Postgres sidecar. Tailscale gates network access; there is no auth layer beyond that. The whole app sits at a single port on the rack and an Android dev-client APK on my phone talks to it over the LAN when I’m at the gym in the basement.
I built it across four scoped runs over a weekend using the scoped cleanup pass discipline. The project is the worked example of that discipline. What follows is what it actually looked like.
What it is
5/3/1 BBB is a structured strength-training program: four lift days a week, three weeks of working sets at climbing percentages of your training max, an optional deload on week four, and a fixed pairing where each day’s heavy lift is accompanied by five sets of ten reps at a lighter percentage of a different lift’s training max. The program has well-defined math — warmup percentages by week, progression rules per lift, an AMRAP last set on weeks one through three, a reset-to-ninety-percent rule when you miss your week-three AMRAP. All of that math has to be exactly right, because the user (me) is sweaty, between sets, and not in any state to debug.
The app is a today view (“what do I do right now”), a workout screen optimized for one-handed thumb input between sets, a cycle view, a progress view with training-max-over-time and PR tracking, a daily log for things adjacent to lifting (sleep, walks, weight), and a few settings surfaces. The whole thing is mobile-first at 390px wide; the web app is the secondary surface and the Android port is what I actually use.
The methodology
This was the first project where I formalized the scoped cleanup pass as a discipline rather than something I sort of did when I remembered to. The artifacts I leaned on:
- A comprehensive spec document in the repo. Tech stack, data model, page-by-page UX notes, the 5/3/1 math, deployment shape. Wrote it before any code. The spec lasts forever; the conversation history doesn’t.
- A SKILL.md for project-specific conventions — design tokens (the green-gold-red accent system, JetBrains Mono everywhere), domain glossary (TM, BBB, AMRAP, deload), UX principles (one-tap variant swap, plate math visible next to each working set), tech constraints (no auth in MVP, all timestamps TIMESTAMPTZ UTC, SQLAlchemy 2.0 style not legacy Column).
- A FEEDBACK.md for dated, evidenced pain points to address in the next pass.
- Numbered priorities per run. Each run was three or four priorities. Never “fix everything.”
Each run was its own session, with /clear between them. Before
clearing, the model updated SKILL.md or FEEDBACK.md with anything
that needed to survive. The conversation was disposable. The
artifacts were the state.
The run shapes varied on purpose. Logic runs landed the 5/3/1 math and the schema. A UI run cleaned up the workout screen and rest timer once the math was right. An infra run added the test container, the entrypoint script that runs migrations and seeding on boot, and the deploy caller workflow. The variance is the point; three logic runs back to back exhausts the dense-context budget.
What shipped
By the end of the weekend the app was live on the rack and I was
using it for real lifts. Phase 1 (today view, workout screen,
variant swap, cycle creation, week progression, training-max advance,
the reset rule) and Phase 2 (daily log, progress view with TM and
PR charts, cycle history, settings, CSV export, workout reschedule,
overdue banner, undo-last-set) were both in. The math is covered by
a backend test suite that runs pytest against a real Postgres in
a one-shot container — JSONB and TIMESTAMPTZ semantics matter
enough that the test suite refuses to run against SQLite.
A later hardening pass — separate run, weeks afterward — added
DB-enforced partial unique indexes for the application invariants
(at most one active cycle, exactly one default variant per main
lift), a /api/admin/health-check endpoint that enumerates every
invariant the codebase assumes and validates it at read time, and
fixed a real production bug where the today endpoint crashed if
two workouts happened to share a date. The pattern there — invariants
expressed in three places (the partial unique index, the model’s
__table_args__ so test runs match production, and the health-check
endpoint that detects corruption out-of-band) — is one I’ve since
reused on other projects. Constraints that live only in application
code are constraints that get violated quietly.
The Android port came later as its own multi-run project, using the same workout API. The web app and the dev-client APK both point at the same FastAPI backend; the mobile UX is better than the PWA because the browser chrome and the haptics situation make the PWA feel like a website at the gym, which is not what I want.
The app is paused now. The discipline I built it under is what’s still active.
What it taught me
A few specific things, in order of how often I’ve used the lesson since.
The fifth bullet in every run prompt is “do not.” Long-context models with scope ambiguity will scope-creep their way into breaking things — restyling working components, refactoring code that wasn’t in the priority list, adding fields to the schema that nobody asked for. The “what not to do” list reads like overkill until you watch the difference between a clean two-file diff and an unreviewable forty-file PR. The discipline of writing out the negation, every time, is the most important habit I took away from this project.
Externalize before you clear. The point of /clear is to start
fresh. If you find yourself pasting context back in after clearing
because the model “needed it,” the right move was to write that
context into SKILL.md or FEEDBACK.md before clearing. Pasting old
context back into a fresh session is the failure mode the artifacts
exist to prevent.
Vary the run shape on purpose. Logic runs are dense and draining. UI runs are lighter, with lots of small diffs. Infra runs are heavy but self-contained. Validate runs are usually me using the thing and writing FEEDBACK.md entries, no code at all. A weekend that’s only logic runs ends with degraded output on the third one and you commit it because you’re tired. Vary it deliberately. The project gets better and the run gets cheaper.
Invariants want to live in the database. The hardening pass
landed weeks after the original four-run sprint, and it landed
because I had assumed application code would enforce the
one-active-cycle rule on its own. It didn’t. The partial unique
index does. Anywhere I now write .scalar_one_or_none() on a query
that depends on an invariant, I ask first whether the invariant is
enforced by a unique index. If it isn’t, I either add the index or
switch to ordered .limit(1).first() and lean on the health-check
to detect the corruption out-of-band. That habit came from this
project.
LiftApp is the worked example of the pattern: a real four-run weekend, real artifacts in the repo, real lessons I now apply by reflex. The discipline that came out of this project is the discipline I’ve used on every multi-run project since.