Each set, on its own tempo โ RPE now shapes each set individually
For a year, every exercise in ZenMotion had one target that every set had to hit. Three sets of Pull-Ups meant three sets at the same number. When you hit that number cleanly on all three, the target bumped. Simple, works for most people most of the time โ but it misses the reality of how sets actually feel.
Set 1 is where you're fresh. Set 3 is where you're smoked. If Set 1 flies but Set 3 grinds, "the whole exercise" isn't equally hard โ the peak set is where the work is. This release teaches the app to see that difference.
The interaction, from the user side
At the end of an exercise, ZenMotion has asked "how hard was that?" since v1.18 โ three chips: ๐ easy, ๐ค hard-but-good, ๐ฅต max effort. Until now the tap only affected the exercise as a whole: ๐ฅต froze the target completely, ๐ and ๐ค both gave a plain +1 next time.
Now the tap shapes each set independently:
- ๐ฅต Max effort. If your prescription is flat (every set the same target โ where every current user starts), the tap still freezes the whole exercise. That's the honest read: you were maxed on all three sets. If your prescription is already differentiated (the peak set has a higher target than the trailing sets), the peak freezes but trailing sets keep progressing normally. Peak drove the "max effort" reading; the trailing work was fine.
- ๐ค Hard but good. No change. Every set gets the standard +1 rep or +5 sec. This is the sweet spot where the classic linear progression works.
- ๐ Easy. The peak set now accelerates โ it gets 2ร the normal step (peak jumps +2 reps or +10 sec instead of +1/+5). Trailing sets still get the standard step. The signal is: "everything felt too easy โ peak was under-loaded โ push it faster." Over a few ๐ taps, a flat 10/10/10 prescription becomes something like 14/12/12 โ the peak pulls ahead while trailing sets stay honest.
What you actually see
The set row itself picks up a small target hint when your prescription becomes differentiated. If you're on flat sets (every current user is until they trigger something above), no visual change. Once shape shows up, each set row displays its own goal:
Set 1 โ 14 [reps input] โ
Set 2 โ 12 [reps input] โ
Set 3 โ 12 [reps input] โ
The chip only appears when it's actually saying something new. Flat prescriptions look identical to before.
Why this shape matters
Calisthenics progression naturally wants a peak-and-drop shape. Set 1 is where you find out what your true strength ceiling is. Sets 2 and 3 are where you accumulate volume at the workable weight/rep count. Forcing all three sets to move together does one of two things:
- If you always hit set 3, your peak-set potential is capped by your trailing capacity, and you're not pushing peak enough.
- If you sometimes miss set 3, your peak-set progression stalls entirely, even though set 1 was ready to bump.
The peak-and-drop shape resolves both. The peak set gets the aggressive progression signal. Trailing sets get the conservative one. Same clean-completion rules, just applied per set instead of per exercise.
The organic path from flat to shaped
Every existing user starts at a flat prescription โ the v1.21 migration seeded [N, N, N, ..., N] from your current single-number target across every set. From there, three things can create shape:
- Tap ๐ easy on a clean set. Peak accelerates by 2ร step. Trailing stays at 1ร step. New shape.
- Crush set 1 by 2ร target while just meeting set 2 and 3. The per-set jump-on-crush now applies independently: set 1 jumps to ~80% of what you actually hit, sets 2 and 3 get standard +1. New shape.
- Author differentiated prescriptions from the backend. Future work โ the schema now supports it. When we ship peak+drop pilot programs, they arrive shaped from day 1.
Pattern 1 is the interesting one because it links RPE to shape organically. Tap ๐ twice on a 10/10/10 exercise and you'll be at 14/12/12 next time. The prescription has learned from the effort signal without you touching a setting.
Under the hood
- New
targetPerSet: Int[]column onUserExerciseTarget. Length always equalstargetSets. ScalartargetAmountis kept in sync asmax(targetPerSet)so legacy callers keep working. - Migration backfills every existing row:
targetPerSet = [targetAmount] ร targetSets. Zero behavior change on day one โ you look and feel identical to v1.20. - Rewrite of
bumpTargetsFromRun. Clean means every set hit its own per-set target (not just the peak). Bump means each set advances bystepForSet(i), modulated by (RPE, isPeakSet, isFlat). Per-set jump-on-crush applies on top. - 45 backend unit tests covering flat/non-flat ร RPE={1, 2, 3, nil} ร (peak-crush | miss | clean). The classic "RPE=3 freeze the whole exercise" behavior is preserved on flat prescriptions โ no existing user sees a regression.
- iOS
Exercise.target(forSet:)helper with fallback chain: readsrequiredPerSet[i-1], falls back to scalarrequiredAmounton nil/out-of-range. Bonus sets (index >requiredSets) reuse the scalar peak. - Set row + circuit round labels now read per-set. Goal-reached feedback during hold timers, adaptive step size on the +/โ stepper, and the "โ 12" chip all resolve through the same helper.
The arc
- v1.17 โ Guided first workout: server-picked session on account creation.
- v1.18 โ Session Intelligence: post-workout narrative summary.
- v1.19 โ Plateau nudge + weekly rollup: the app spots patterns.
- v1.20 โ Reads your body: HRV + morning check-in feed today's targets.
- v1.21 โ Each set, on its own tempo: RPE shapes progression per set.
Every release since v1.17 has pushed the same theme forward: the same rung system, adaptive periodization, and prescription state that's been the engine since v1.7 becomes more legible, more responsive, more specific. This one closes a subtle gap โ the app used to treat "how hard was that?" as a single yes/no gate on the whole exercise. Now it treats it as a shaping signal on the individual sets.
Bug reports and feature ideas: support@zenmotion.app.
Train well.