Skip to content

Fitness Age card: show the "N more nights" countdown, not a dead-end#139

Merged
ryanbr merged 1 commit into
mainfrom
fix/fitness-age-not-ready-countdown
Jul 9, 2026
Merged

Fitness Age card: show the "N more nights" countdown, not a dead-end#139
ryanbr merged 1 commit into
mainfrom
fix/fitness-age-not-ready-countdown

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 9, 2026

Copy link
Copy Markdown
Owner

The bug (from a user report)

Fitness Age read "No Data" on the Today card, and tapping through hit a dead end: "Not enough history yet — needs at least two historical readings before NOOP can chart it" on Android, and "Import your history first" on iOS.

Both are wrong. Fitness Age is computed on‑device from resting HR + recent activity — nothing to import. The actual gate is ≥4 of the last 7 nights with a resting‑HR reading (FitnessAgeEngine.minCoverageDays). Age/sex default (age=30, sex="male") so they're rarely the blocker — confirmed by the reporter's Vitality computing (54), which requires age>0 and reads/writes the same my-whoop-noop source, proving the plumbing is fine. With a recently‑acquired WHOOP 4.0 (sparse sleep detection), they simply hadn't banked 4 qualifying nights yet.

The helpful readiness countdown #81 added ("N more nights of wear and we can show your Fitness Age") lived only on the Health hub — a screen the Today card doesn't open. So the user was told nothing useful.

The fix

When Fitness Age has no weekly value yet, the card's tap‑through now shows the readiness checklist + concrete countdown, the same one the hub shows.

Android (VitalDetailScreen)

  • Renders the readiness card for key "fitness_age" with zero points. A single reading keeps the generic "needs two to chart" note (the value already shows on the card; only the trend needs a second weekly point).
  • Extracted rememberFitnessReadiness(days, profile) so the hub (FitnessAgeSection) and the card share one gate — no drift.
  • Subtitle reflects the not‑ready state ("What your Fitness Age still needs").

iOS (MetricDetailView)

  • Shows the countdown for the fitness_age empty state instead of the misleading "import your history" copy.
  • Lifted fitnessReadyLead out of FitnessAgeCard into a file‑scope fitnessReadyLeadCopy(rhrDays:hasAge:hasSex:) shared by both surfaces (still word‑for‑word matched to Android). Added @EnvironmentObject var profile (+ preview injection).

Scope / notes

  • Focused on Fitness Age (the reported metric). Vitality/Body Age has a different gate (≥3 inputs, no per‑night countdown) and isn't included here — possible follow‑up.
  • No engine/gate logic changed — purely which surface the not‑ready state routes to.

Verification

  • Android :app:compileFullDebugKotlin passes locally.
  • iOS reviewed by hand (Charts/Compression can't build on WSL): ComingSoon.what is a LocalizedStringKey, so the resolved String lead is wrapped in an interpolation to render verbatim; @EnvironmentObject is injected app‑wide (root) with the one direct preview updated.

Tapping the Today Fitness Age card when it reads "No Data" led to a
generic "Not enough history yet -- needs two readings to chart it"
(Android) / "Import your history first" (iOS). Both are wrong: Fitness
Age is COMPUTED on-device from resting HR + recent activity, and the
real gate is >=4 of the last 7 nights with a resting-HR reading (age/sex
default, so they're rarely the blocker). Neither message told the user
what it actually needs, and the helpful readiness countdown that #81
added lived only on the Health hub, a screen the card doesn't open.

Now the card's tap-through, when there's no weekly value yet, shows the
same readiness checklist + concrete "N more nights of wear and we can
show your Fitness Age" countdown the hub does.

Android: VitalDetailScreen renders the readiness card for key
"fitness_age" with zero points (a single reading keeps the generic
"needs two to chart" note -- the value already shows). Extracted
rememberFitnessReadiness so the hub and the card share ONE gate (no
drift). Subtitle reflects the not-ready state.

iOS: MetricDetailView shows the countdown for the fitness_age empty
state instead of the misleading "import your history" copy. Lifted
fitnessReadyLead out of FitnessAgeCard into a file-scope
fitnessReadyLeadCopy(rhrDays:hasAge:hasSex:) shared by both surfaces
(still word-for-word matched to Android).
@ryanbr ryanbr merged commit 9671ea6 into main Jul 9, 2026
tanarchytan pushed a commit to tanarchytan/noop that referenced this pull request Jul 9, 2026
The weekly Fitness Age is only written by the intelligence recompute
(15-min backstop, gated by an HR-fingerprint watermark), so a ready user
could wait a while to see it. Add a small refresh affordance on the
not-ready readiness card that recomputes Fitness Age NOW from the
PERSISTED merged daily history , no raw-HR rescoring , so it returns fast,
works offline, and reads exactly what the readiness card shows (it also
sidesteps the recompute's own delete-timing, so it lands the value even
where the auto-pass might not).

Shared gate: extracted the ryanbr#140 gate + compute into IntelligenceEngine.
fitnessAgeRows(...), used by BOTH the recompute pass and the new
recomputeFitnessAgeOnly(...) so the two can never drift.

Android: AppViewModel.refreshFitnessAgeNow + a Refresh IconButton (spinner
while running) on FitnessReadinessCard, wired on the Health hub
(FitnessAgeSection) and the Today-card tap-through (VitalDetailScreen),
re-reading the value on completion + a Toast.

iOS: engine parity (fitnessAgeRows / recomputeFitnessAgeOnly), a refresh
button on ReadinessChecklistCard (HealthView) and the fitness_age empty
state (MetricDetailView), via the app-wide IntelligenceEngine env object.

Stacked on ryanbr#139 (readiness surfaces) + ryanbr#140 (gate fix), which this
includes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant