Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions Strand/Screens/HealthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion Strand/Screens/MetricExplorerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())…")
Expand Down Expand Up @@ -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)
}
Expand Down
62 changes: 47 additions & 15 deletions android/app/src/main/java/com/noop/ui/HealthScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<DailyMetric>, profile: ProfileStore): Pair<Int, FitnessAgeReadiness> {
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<DailyMetric>, profile: ProfileStore) {
// Latest weekly value + its optional VO₂max companion, read once (metricSeries has no Flow, so we
Expand All @@ -598,19 +620,9 @@ private fun FitnessAgeSection(vm: AppViewModel, days: List<DailyMetric>, 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) }

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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.",
Expand Down