The bug that revealed the real problem
The trigger was a bug report on timed breathing sessions. The app was confusing rounds with reps. Hold values that made sense for counted breathing made no sense for timed breathing. The concept of "20 breaths per minute" and "a round lasts 30 seconds" were being shoehorned into the same fields. And the long full-lungs retention in hormesis — a 60–120 second hold after rapid breathing — was being modeled as just a very long in-rep hold, which broke everything about how retentions should work.
It wasn't a bug in the usual sense. It was a data model that couldn't express the domain it was trying to represent.
The old model
The engine was built around a Cycle: one inhale + optional holds + one exhale. Rounds were cyclesPerRound. The custom builder called cycles "rounds." Timed and counted modes were handled by a mode discriminator on each phase. Long retentions were just cycles with enormous hold values.
The types looked roughly like this:
type BreathPhase =
| { mode: 'time'; inhaleSec: number; exhaleSec: number; holds: HoldConfig }
| { mode: 'bpm'; bpm: number; holds: HoldConfig }
type Cycle = {
phases: BreathPhase[]
cyclesPerRound: number
repeatLast?: boolean
}
Three problems were baked in:
-
Rounds vs reps conflated.
cyclesPerRoundwas a mapping from internal engine cycles to user-visible rounds — a leaky abstraction that every UI component had to work around. -
Two breath modes that were almost the same.
mode: 'time'andmode: 'bpm'both described a breath with four phases. The distinction forced a discriminator into every type, every render, every conversion. -
Retentions as long holds. A 90-second empty-lungs retention after hormesis power breathing was stored as
holdAfterExhaleSec: 90— the same field used for a 4-second box-breathing pause. Nothing in the model distinguished them.
The question wasn't how to fix the bug. The question was what the model should actually look like.
The interview
Instead of jumping to code, the session started with a structured design interview: one question at a time, each with a stated recommendation and an explicit trade-off. The goal was to lock every decision before a single line changed.
Here are the four questions that unlocked the architecture.
Q1 — Vocabulary
Right now the code has
CycleandcyclesPerRound. You're saying this is wrong. I think the right vocabulary is a 3-level hierarchy:
- Rep = one inhale + optional hold + exhale + optional hold. The atomic breath unit.
- Round = a sequence of reps, defined by either total reps (count mode) or total duration (timed mode).
- Program = an ordered sequence of rounds.
Does this match your mental model?
User: yes that matches, program → round → rep, this also works for constructing any breathing
One exchange. Three levels. The entire naming problem resolved.
Q2 — One rep shape or two?
A rep is one inhale + optional holds + exhale + optional holds. The question is how it's parameterized.
Option A — One unified shape: 4 numbers.
Every rep is{ inhaleSec, holdAfterInhaleSec, exhaleSec, holdAfterExhaleSec }. BPM is just a UI convenience — "20 bpm, equal split" maps toinhaleSec=1.5, exhaleSec=1.5. Internally there's only one rep type.Option B — Two rep modes.
{ mode: 'time', ... }and{ mode: 'bpm', ... }. Preserves a conceptual distinction between a metronome-paced breath and a time-paced breath.Recommendation: Option A. The internal model stays a single 4-tuple. Animation can detect "fast equal split" and pulse instead of expanding smoothly — that's a render-time decision, not a data-model one.
User: Option A, but keep the count UX, we can count up if that's easier
This was the biggest design unlock. Collapsing two modes into one 4-number shape removed a discriminator from every type downstream. The data model went from "two kinds of breathing phases that mostly behave the same" to "every breath is the same shape; rounds vary." That simplification only became visible by writing it down and asking: is this distinction real?
Q4 — Retentions as a distinct round type
Hormesis's recovery is a 60–120s full-lungs hold. Box-breathing holds are 4–10s. These are not the same thing.
Option A — Retention is a distinct property of a breathing round. Hormesis = breathing round + retention, kept together.
Option B — Long holds are just long holds inside a rep.
Option C — Holds are first-class steps in a round's sequence.
Recommendation: A. Matches user mental model: "do 30 power breaths, then hold." Solves "hold values make no sense for timed version" naturally — the 10s cap applies to in-rep holds; retentions are a separate thing with their own range.
User: A
Two optional fields on Round — emptyLungsRetentionSec and fullLungsRetentionSec — replaced the entire confusion between short holds and long retentions.
Q6 — Infinite breathing
Box breathing currently uses
repeatLast: trueso the last cycle loops forever. Where does "breathe like this until I stop" fit in the new model?
User: there's no need for infinite, never asked for this, if it is in the code we can get rid of it
One of the most clarifying moments in any design session: discovering that a feature was never actually needed. repeatLast was removed entirely.
The new model
Eleven questions. Every decision locked. Then the types:
type Rep = {
inhaleSec: number // > 0
holdAfterInhaleSec: number // 0–10s
exhaleSec: number // > 0
holdAfterExhaleSec: number // 0–10s
}
type Round = {
rep: Rep
mode: 'count' | 'time'
count?: number
durationSec?: number
emptyLungsRetentionSec?: number // 0 or 30–180s
fullLungsRetentionSec?: number // 0 or 15–60s
}
type Program = {
rounds: Round[]
// id, name, category, level, ...
}
The engine processes each round through five stages in order: reps → emptyRetention → transitionIn → fullRetention → transitionOut. Time-mode rounds finish the current rep before moving on — never cutting someone off mid-inhale. Skip button appears only on holds of 30 seconds or longer. Transitions between retention types are hardcoded at 2 seconds, matching how the body actually works.
The old model's concepts — Cycle, BreathPhase modes, cyclesPerRound, repeatLast — are gone entirely.
Implementation in numbers
The rewrite was planned as 9 phases and executed in order:
| # | Phase | File(s) |
|---|---|---|
| 1 | Rewrite breath types | packages/types/src/breath.ts |
| 2a | Rewrite breath engine | packages/engine/src/breathEngine.ts |
| 2b | Rewrite catalog | packages/engine/src/programs.ts |
| 3 | Rewrite custom programs storage | apps/mobile/src/lib/customPrograms.ts |
| 3b | Supabase migration v2 | supabase/migrations/…_custom_programs_v2.sql |
| 4 | Rewrite custom builder UI | apps/mobile/src/screens/CustomBuilderScreen.tsx |
| 5 | Update SessionScreen | apps/mobile/src/screens/SessionScreen.tsx |
| 6 | Update i18n keys | packages/i18n/src/locales/{en,es}.ts |
| 7 | Cleanup and type-check | CLAUDE.md |
Single TypeScript pass. Zero errors across apps/mobile, packages/engine, and packages/types.
Two highlights worth noting: the engine became a stage machine — a central advance() function dispatches to the next step based on current stage and round configuration, which is far easier to reason about than the previous nested condition tree. And a pre-existing pause/resume bug (phase elapsed time not re-incremented after resume) was fixed by re-deriving phaseElapsed from phaseStartTime after the pause-duration shift — something that became obvious once the engine had a clear structure.
What the interview actually did
The architecture simplification in Q2 — one rep shape instead of two — was only visible because the design was written down and questioned. Free-form prompting would likely have produced code that preserved the mode discriminator, because that's what the existing code had and it's the path of least resistance.
The structured interview forced the question: is this distinction real? It wasn't. Removing it simplified every downstream type, every render, every test.
That's the pattern: before touching the codebase, spend the time to ask whether the distinctions in your current model reflect actual domain differences — or just accumulated implementation decisions that nobody questioned. An AI that asks focused questions with explicit recommendations and trade-offs is a useful forcing function for that conversation.
Eleven questions. Nine phases. One TypeScript pass. A breathing engine that can now express anything the domain actually needs.
Top comments (0)