From e9e80e0ad2619678c2a850b624868c504270bf15 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 18:47:32 +1200 Subject: [PATCH] Fitness Age card: show the "N more nights" countdown, not a dead-end 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). --- Strand/Screens/HealthView.swift | 37 ++++++----- Strand/Screens/MetricExplorerView.swift | 16 ++++- .../src/main/java/com/noop/ui/HealthScreen.kt | 62 ++++++++++++++----- 3 files changed, 85 insertions(+), 30 deletions(-) diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index 75effad9fc..5a6475b748 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -688,21 +688,12 @@ private struct FitnessAgeSection: View { hasWaist: profile.waistCm > 0) } - /// The not-ready card's lead: a concrete countdown of nights-of-wear still needed (from the shared - /// `nightsUntilReady`), noting the profile basics only when they're actually missing. Copy is kept - /// WORD-FOR-WORD identical to the Android `fitnessReadyLead` so the two platforms match. + /// The not-ready card's lead — delegates to the file-scope `fitnessReadyLeadCopy(rhrDays:hasAge:hasSex:)`, + /// shared with the Today card's `MetricDetailView` tap-through so both surfaces show the SAME countdown. private func fitnessReadyLead() -> String { - let rhrDays = repo.days.suffix(7).compactMap { $0.restingHr }.count - let remaining = FitnessAgeEngine.nightsUntilReady(rhrDays: rhrDays) - let needsBasics = profile.age <= 0 || profile.sex.isEmpty - switch (remaining, needsBasics) { - case (0, false): return String(localized: "A few more days and we can show your Fitness Age.") - case (0, true): return String(localized: "Add your age and sex below and we can show your Fitness Age.") - case (1, false): return String(localized: "1 more night of wear and we can show your Fitness Age.") - case (1, true): return String(localized: "1 more night of wear, plus your age and sex below, and we can show your Fitness Age.") - case (let n, false): return String(localized: "\(n) more nights of wear and we can show your Fitness Age.") - case (let n, true): return String(localized: "\(n) more nights of wear, plus your age and sex below, and we can show your Fitness Age.") - } + fitnessReadyLeadCopy( + rhrDays: repo.days.suffix(7).compactMap { $0.restingHr }.count, + hasAge: profile.age > 0, hasSex: !profile.sex.isEmpty) } var body: some View { @@ -875,6 +866,24 @@ private struct FitnessAgeSection: View { } } +/// The Fitness Age not-ready lead: a concrete countdown of nights-of-wear still needed (from the shared +/// `nightsUntilReady`), noting the profile basics only when they're actually missing. File-scope (not a +/// view method) so BOTH the Health hub's `FitnessAgeCard` and the Today card's `MetricDetailView` +/// tap-through render the SAME copy from one source. Kept WORD-FOR-WORD identical to the Android +/// `fitnessReadyLead` so the two platforms match. +func fitnessReadyLeadCopy(rhrDays: Int, hasAge: Bool, hasSex: Bool) -> String { + let remaining = FitnessAgeEngine.nightsUntilReady(rhrDays: rhrDays) + let needsBasics = !hasAge || !hasSex + switch (remaining, needsBasics) { + case (0, false): return String(localized: "A few more days and we can show your Fitness Age.") + case (0, true): return String(localized: "Add your age and sex below and we can show your Fitness Age.") + case (1, false): return String(localized: "1 more night of wear and we can show your Fitness Age.") + case (1, true): return String(localized: "1 more night of wear, plus your age and sex below, and we can show your Fitness Age.") + case (let n, false): return String(localized: "\(n) more nights of wear and we can show your Fitness Age.") + case (let n, true): return String(localized: "\(n) more nights of wear, plus your age and sex below, and we can show your Fitness Age.") + } +} + /// The readiness checklist card: an optional lead line, then the engine's `items` as ✓/⚠/○ rows with /// their `detail` text, GROUPED by `.role` into "Drives your Fitness Age" and "Unlocks your VO₂max". /// A required-but-missing input shows a "Fix in Settings" affordance (the engine's required+missing diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift index 9356ab9f22..a6dd5e38a3 100644 --- a/Strand/Screens/MetricExplorerView.swift +++ b/Strand/Screens/MetricExplorerView.swift @@ -399,6 +399,9 @@ private struct MetricRow: View { struct MetricDetailView: View { let metric: MetricDescriptor @EnvironmentObject var repo: Repository + // Profile basics for the Fitness Age not-ready countdown (age/sex gate its readiness lead). Injected + // app-wide at the root; previews supply their own. Only read on the fitness_age empty-state path. + @EnvironmentObject var profile: ProfileStore // Imperial/Metric display preference (D#103). Display-only: weight (kg) and skin temp (°C) re-label // here; everything else is unit-agnostic and renders unchanged. @@ -557,7 +560,17 @@ struct MetricDetailView: View { // the ranges to misrepresent, and hiding the bar here would regress this // "for context" intent. rangeBar(effectiveRange: effRange, windowed: win, windowFellBack: fellBack) - ComingSoon(what: "Import your history first. A WHOOP export in Data Sources fills every metric you can explore here in about a minute.") + if metric.key == "fitness_age" { + // Fitness Age is COMPUTED on-device from resting HR + activity — not imported — so + // the generic "import your history" copy was wrong (and a dead end) here. Lead with + // the same "N more nights of wear" countdown the Health hub shows, from the shared + // engine + `fitnessReadyLeadCopy` (parity with Android's VitalDetailScreen fix). + // `what` is a LocalizedStringKey; the lead is an already-resolved String, so wrap + // it in an interpolation (renders verbatim) rather than passing it as a lookup key. + ComingSoon(what: "\(fitnessReadyLeadCopy(rhrDays: repo.days.suffix(7).compactMap { $0.restingHr }.count, hasAge: profile.age > 0, hasSex: !profile.sex.isEmpty))", symbol: "figure.run") + } else { + ComingSoon(what: "Import your history first. A WHOOP export in Data Sources fills every metric you can explore here in about a minute.") + } } else if !loaded { rangeBar(effectiveRange: effRange, windowed: win, windowFellBack: fellBack) ComingSoon(what: "Reading your \(metric.title.lowercased())…") @@ -1000,6 +1013,7 @@ private func explorerPreviewRepo() -> Repository { MetricDetailView(metric: MetricCatalog.all.first { $0.key == "recovery" }!) } .environmentObject(explorerPreviewRepo()) + .environmentObject(ProfileStore()) .frame(width: 900, height: 820) .preferredColorScheme(.dark) } diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index 8fdfb201f2..792e01abfa 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -577,6 +577,28 @@ private fun ContributorBar( * same convention every screen uses for on-device-computed series (imported is plain "my-whoop"). */ private const val COMPUTED_SOURCE = "my-whoop-noop" +/** Fitness Age readiness from what a screen can see: RHR coverage over the last 7 merged daily rows + * (drives the "N more nights" countdown), a scored-strain day as the activity signal, and the profile + * basics. Shared by the Health hub's [FitnessAgeSection] and the Today card's [VitalDetailScreen] + * tap-through so ONE gate feeds both surfaces (no drift). Returns (rhrDays, readiness) — rhrDays also + * feeds the not-ready lead. Approximate by design; the weekly value is the authority, this explains gaps. */ +@Composable +private fun rememberFitnessReadiness(days: List, profile: ProfileStore): Pair { + val rhrDays = remember(days) { days.takeLast(7).count { it.restingHr != null } } + val readiness = remember(days, profile.age, profile.sex, profile.waistCm) { + val activityDays = days.takeLast(7).count { it.strain != null } + FitnessAgeEngine.assessReadiness( + hasAge = profile.age > 0, + hasSex = profile.sex.isNotBlank(), + rhrDays = rhrDays, + activityDays = activityDays, + hasHeightWeight = profile.heightCm > 0 && profile.weightKg > 0, + hasWaist = profile.waistCm > 0, + ) + } + return rhrDays to readiness +} + @Composable private fun FitnessAgeSection(vm: AppViewModel, days: List, profile: ProfileStore) { // Latest weekly value + its optional VO₂max companion, read once (metricSeries has no Flow, so we @@ -598,19 +620,9 @@ private fun FitnessAgeSection(vm: AppViewModel, days: List, profile // age; activity (a scored strain day) is an enrichment signal; height/weight/waist sit under the // VO₂max role. Age/sex come from the profile. Approximate by design — the weekly value is the // authority; this just explains the gaps. - // rhrDays drives BOTH the readiness verdict AND the not-ready countdown lead, so hoist it out. - val rhrDays = remember(days) { days.takeLast(7).count { it.restingHr != null } } - val readiness = remember(days, profile.age, profile.sex, profile.waistCm) { - val activityDays = days.takeLast(7).count { it.strain != null } - FitnessAgeEngine.assessReadiness( - hasAge = profile.age > 0, - hasSex = profile.sex.isNotBlank(), - rhrDays = rhrDays, - activityDays = activityDays, - hasHeightWeight = profile.heightCm > 0 && profile.weightKg > 0, - hasWaist = profile.waistCm > 0, - ) - } + // rhrDays drives BOTH the readiness verdict AND the not-ready countdown lead. Shared with the Today + // card's tap-through (VitalDetailScreen) via one helper so a single gate feeds both surfaces. + val (rhrDays, readiness) = rememberFitnessReadiness(days, profile) var showChecklist by remember { mutableStateOf(false) } @@ -1860,7 +1872,10 @@ private val SERIES_BACKED_VITAL_KEYS = setOf("fitness_age", "vitality", "steps_e @Composable fun VitalDetailScreen(vm: AppViewModel, key: String) { val days by vm.recentDays.collectAsStateWithLifecycle() - val tempUnit = UnitPrefs.temperature(LocalContext.current) + val context = LocalContext.current + val tempUnit = UnitPrefs.temperature(context) + // Profile drives the Fitness Age readiness/countdown shown when that vital has no value yet. + val profile = remember { ProfileStore.from(context.applicationContext) } val isSeriesBacked = key in SERIES_BACKED_VITAL_KEYS // Series-backed metrics are loaded async from metricSeries; the plain daily vitals build synchronously @@ -1878,9 +1893,13 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { else remember(days, key, tempUnit) { buildVitalDetail(days, key, tempUnit) } var range by remember { mutableStateOf(VitalDetailRange.MONTH) } + // When Fitness Age has no value yet we show the readiness countdown, not a trend — so the subtitle + // reflects that instead of promising a "historical trend" the card isn't about. + val fitnessNotReady = key == "fitness_age" && seriesLoaded && (detail?.points?.isEmpty() != false) ScreenScaffold( title = detail?.title ?: "Vital Signs", - subtitle = "Historical trend from cached daily metrics.", + subtitle = if (fitnessNotReady) "What your Fitness Age still needs." + else "Historical trend from cached daily metrics.", ) { if (isSeriesBacked && !seriesLoaded) { DataPendingNote( @@ -1890,6 +1909,19 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { return@ScreenScaffold } if (detail == null || detail.points.size < 2) { + // Fitness Age with NO weekly value yet (zero points): show the readiness checklist + the + // "N more nights of wear" countdown — what it actually needs — instead of the generic + // "needs two readings to chart" note, which describes the trend line and left the Today + // card's tap-through a dead end. A single reading (size 1) keeps the generic note: the value + // already shows on the card, only the trend needs a second weekly point. + if (key == "fitness_age" && (detail?.points?.isEmpty() != false)) { + val (rhrDays, readiness) = rememberFitnessReadiness(days, profile) + FitnessReadinessCard( + readiness = readiness, headed = true, + lead = fitnessReadyLead(rhrDays, profile.age > 0, profile.sex.isNotBlank()), + ) + return@ScreenScaffold + } DataPendingNote( title = "Not enough history yet", body = "This vital needs at least two historical readings before NOOP can chart it.",