Fitness Age card: show the "N more nights" countdown, not a dead-end#139
Merged
Conversation
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).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 samemy-whoop-noopsource, 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)"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).rememberFitnessReadiness(days, profile)so the hub (FitnessAgeSection) and the card share one gate — no drift.iOS (
MetricDetailView)fitness_ageempty state instead of the misleading "import your history" copy.fitnessReadyLeadout ofFitnessAgeCardinto a file‑scopefitnessReadyLeadCopy(rhrDays:hasAge:hasSex:)shared by both surfaces (still word‑for‑word matched to Android). Added@EnvironmentObject var profile(+ preview injection).Scope / notes
Verification
:app:compileFullDebugKotlinpasses locally.ComingSoon.whatis aLocalizedStringKey, so the resolvedStringlead is wrapped in an interpolation to render verbatim;@EnvironmentObjectis injected app‑wide (root) with the one direct preview updated.