Real numbers underneath

Pick Planche in onboarding, blitz your Planche Calibration, close the app pleased with yourself. Open a session two days later — Wall Push-Ups in the warmup, Deadbugs for core. What gives? You just proved Advanced Tuck Planche.

That was the app being honest with itself and dishonest with you. Your Planche step was real (calibration measured it). Your push and core steps were guessed defaults from the onboarding diagnostic — never updated when your skill signal said "this person can press their bodyweight." This release fixes that. And while we were in there, we broadened the decode-safety pattern that pinned save-conversion at 41% two weeks ago.

Skill calibrations now cascade to the muscle families underneath

When you crush a skill calibration, the roadmap for that skill has an implies map that says which universal families the skill demands. Advanced Tuck Planche implies push step 8 (Diamond Push-Up territory) and core step 4 (Hollow Body Hold). That map has existed since v1.10 — but it only fired for step bumps from a completed workout, not for the initial step seeding at the end of calibration. So a skill-first user's push and core stayed at whatever the diagnostic seeded (usually 1-2) until they happened to log a Foundations session, which they never would because the app just prescribed them a Focus Split.

Fix is a single line inserted at the end of applyCalibrationJumpAhead: after promoting skill steps from your UserMax values, cascade the implied floors to universal families. Never-demote — if you're already at push step 9 from Strength Calibration and the planche cascade only implies step 8, you stay at 9.

Every calibration flow benefits automatically: onboarding, "Recalibrate my plan" from Profile, and any future re-seeding path. No migration needed; the fix kicks in on your next calibration completion.

Dip calibration reads finally count for push

The Strength Calibration has been asking you to do dips for months, and we've been storing the number faithfully in your UserMax table. What we weren't doing: reading it back when computing your push_rung. Only push-up count fed the push floor. So a user who could do 20 dips but no push-ups would seed at push step 1 (Wall Push-Up), unable to convince the app they knew how to press their bodyweight.

New thresholds: 1 dip → step 7, 5 dips → step 8, 10 dips → step 9, 20 dips → step 10. Applied side-by-side with the push-up thresholds via Math.max — highest signal wins.

Baseline coverage, made visible

Home now knows exactly which of your universal steps are measured (you did the test movement) versus implied (a skill calibration cascade floored it) versus default (nobody measured or implied anything, we're going off the diagnostic).

When you have a picked focus skill AND at least one universal family is still default, Home surfaces a specific nudge in the More section:

Planche is tuned, but…
Push + pull baseline still guessed. Quick Strength Check?

Same 3-minute Strength Calibration on the tap, but framed around the specific gap: it names your skill so the offer feels coherent with what you're working toward, and it names the families it would resolve so the ask feels concrete instead of vague. Skill-first users have been under-seeing calibration nudges for months because our generic "time for a strength test" copy wasn't scoped to their situation. This is.

Fires only for users with a real focus commitment (server-side focusMission), not for anyone who happened to tap through the skill picker without a plan. Foundations Tier 1 users don't see it either — their whole plan is baseline work, so the framing doesn't apply.

Save-conversion broadening — the decode-fix sweep continues

Two weeks ago we shipped v1.38 with a fix for a decode bug on RungBump.family that had been silently killing 20% of workout saves — server returned 200, iOS threw on the response, sheets closed, no success toast, no history row. Firebase confirmed the bump: save-conversion climbed once v1.38 was in most users' hands.

But 41% is still not 90%+. This release broadens the same defensive-decode pattern to three more fields that were still non-optional on iOS:

  • NextMilestoneResponse.skillKey / movementSlug / displayName — the "Working toward" row on Home. Backend emits null for edge cases (roadmap resolver drift, brand-new user without a picked focus). Non-optional decode → whole Home response threw → no Home render, no /complete confirmation flow.
  • AthleteProfileResponse family + skill fields — Stats tab hero section. Any partial response threw the whole Athlete Profile decode, and the retry path was the same one that pulled the PR list, so users with even one bad family entry lost multiple sections.
  • JourneyResponse.MilestoneItem.family + RecognizedRow.family — the Journey feed. Backfill entries can have null family in edge cases; before this fix the whole Journey tab would go blank on a single malformed row.

Same v1.38 pattern each time: String → String?, callers guard nil with sensible fallbacks. Doesn't fix a bug that fired on every user — fixes a bug that fires on the subset of users whose data has any of these edge cases. Multiply that subset across every request path and you get the low-conversion tail.

If your saves have been failing silently for a while, they should stop after this release. If a Home load has been mysteriously blank, same. Firebase will tell us in a week whether the number moves toward 90%.

What's not in this release

We deliberately parked two v1.47 candidates:

  • "Baseline coverage" on the Athlete Profile hero as a read-only pill. Nice-to-have transparency signal but requires a new backend field. Deferred to v1.48.
  • Muscle-transfer substitution for Equipment Mode — when you don't have a bar, swap in an equivalent movement instead of just warning. Content problem before code problem — Jacob needs to author the substitution table first.

Feedback welcome at support@zenmotion.app. If save-conversion still feels off after v1.47 propagates, please shake a fist at me on Instagram — the tail keeps giving up rows.