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
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ extension FitnessAgeEngine {
/// Coverage at/above which an input reads as fully satisfied (a "confident" week).
public static let goodCoverageDays = 6

/// Nights of resting-HR still needed before the headline can compute AT ALL — the countdown the
/// not-ready card shows ("N more nights of wear…"). 0 once `minCoverageDays` is met. Assumes continued
/// nightly wear (a skipped night just doesn't advance the count). Shared with the Android engine so both
/// platforms show the same number.
public static func nightsUntilReady(rhrDays: Int) -> Int { max(0, minCoverageDays - rhrDays) }

private static func coverageStatus(_ days: Int, floor: Int) -> FitnessReadinessStatus {
if days >= goodCoverageDays { return .satisfied }
if days >= floor || days > 0 { return .partial }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,17 @@ final class FitnessAgeEngineTests: XCTestCase {
hasHeightWeight: false, hasWaist: false)
XCTAssertEqual(r.confidence, .ready)
}

// The not-ready countdown: nights of RHR still needed to reach the floor (minCoverageDays = 4).
func testNightsUntilReadyCountsDownToTheFloor() {
XCTAssertEqual(FitnessAgeEngine.nightsUntilReady(rhrDays: 0), 4)
XCTAssertEqual(FitnessAgeEngine.nightsUntilReady(rhrDays: 1), 3)
XCTAssertEqual(FitnessAgeEngine.nightsUntilReady(rhrDays: 3), 1)
}

// At/above the floor it's 0 (never negative), so the card flips to "ready" with no leftover countdown.
func testNightsUntilReadyZeroAtOrAboveFloor() {
XCTAssertEqual(FitnessAgeEngine.nightsUntilReady(rhrDays: 4), 0)
XCTAssertEqual(FitnessAgeEngine.nightsUntilReady(rhrDays: 7), 0)
}
}
24 changes: 20 additions & 4 deletions Strand/Screens/HealthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,23 @@ 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.
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.")
}
}

var body: some View {
VStack(alignment: .leading, spacing: NoopMetrics.gap) {
SectionHeader("Fitness Age", overline: "Weekly",
Expand Down Expand Up @@ -720,12 +737,11 @@ private struct FitnessAgeSection: View {
.transition(.opacity)
}
} else if loaded {
// No value yet: lead with the checklist so the user sees exactly what's still needed.
// No value yet: lead with a concrete countdown ("N more nights of wear…") so the user knows
// how far off it is, then the checklist shows exactly what's still needed.
ReadinessChecklistCard(
readiness: readiness,
lead: readiness.canCompute
? "A few more days and we can show your Fitness Age."
: "A few more days of wear, plus the basics below, and we can show your Fitness Age.",
lead: fitnessReadyLead(),
onFix: { fitnessSheet = .settings })
} else {
// Brief read of the weekly value; honest placeholder rather than an empty gap.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ object FitnessAgeEngine {
const val minCoverageDays = 4
const val goodCoverageDays = 6

/** Nights of resting-HR still needed before the headline can compute AT ALL — the countdown the
* not-ready card shows ("N more nights of wear…"). 0 once [minCoverageDays] is met. Assumes continued
* nightly wear. Shared with the iOS engine so both platforms show the same number. */
fun nightsUntilReady(rhrDays: Int): Int = maxOf(0, minCoverageDays - rhrDays)

private fun coverageStatus(days: Int, floor: Int): FitnessReadinessStatus = when {
days >= goodCoverageDays -> FitnessReadinessStatus.SATISFIED
days >= floor || days > 0 -> FitnessReadinessStatus.PARTIAL
Expand Down
35 changes: 26 additions & 9 deletions android/app/src/main/java/com/noop/ui/HealthScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -598,10 +598,10 @@ 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 last7 = days.takeLast(7)
val rhrDays = last7.count { it.restingHr != null }
val activityDays = last7.count { it.strain != null }
val activityDays = days.takeLast(7).count { it.strain != null }
FitnessAgeEngine.assessReadiness(
hasAge = profile.age > 0,
hasSex = profile.sex.isNotBlank(),
Expand Down Expand Up @@ -629,8 +629,9 @@ private fun FitnessAgeSection(vm: AppViewModel, days: List<DailyMetric>, profile
FitnessReadinessCard(readiness = readiness, headed = false)
}
} else {
// No weekly value yet — surface the checklist directly so the user knows what's pending.
FitnessReadinessCard(readiness = readiness, headed = true)
// No weekly value yet — lead with a concrete countdown, then the checklist.
FitnessReadinessCard(readiness = readiness, headed = true,
lead = fitnessReadyLead(rhrDays, profile.age > 0, profile.sex.isNotBlank()))
}
}
}
Expand Down Expand Up @@ -897,11 +898,27 @@ private fun FitnessAgeHero(
}
}

/** The not-ready card's lead: a concrete countdown of nights-of-wear still needed (from the shared
* [FitnessAgeEngine.nightsUntilReady]), noting the profile basics only when actually missing. Copy is kept
* WORD-FOR-WORD identical to the iOS `fitnessReadyLead` (HealthView) so the two platforms match. */
private fun fitnessReadyLead(rhrDays: Int, hasAge: Boolean, hasSex: Boolean): String {
val remaining = FitnessAgeEngine.nightsUntilReady(rhrDays)
val needsBasics = !hasAge || !hasSex
return when {
remaining == 0 && !needsBasics -> "A few more days and we can show your Fitness Age."
remaining == 0 && needsBasics -> "Add your age and sex below and we can show your Fitness Age."
remaining == 1 && !needsBasics -> "1 more night of wear and we can show your Fitness Age."
remaining == 1 && needsBasics -> "1 more night of wear, plus your age and sex below, and we can show your Fitness Age."
!needsBasics -> "$remaining more nights of wear and we can show your Fitness Age."
else -> "$remaining more nights of wear, plus your age and sex below, and we can show your Fitness Age."
}
}

/** The readiness checklist card: each input as a ✓ / ⚠ / ○ glyph + its detail, grouped by role into
* "Drives your Fitness Age" and "Unlocks your VO₂max". When [headed] (no value yet) it leads with a
* "a few more days" heading and floats the required-missing items to the top of their group. */
* "Drives your Fitness Age" and "Unlocks your VO₂max". When [headed] (no value yet) it leads with the
* [lead] countdown and floats the required-missing items to the top of their group. */
@Composable
private fun FitnessReadinessCard(readiness: FitnessAgeReadiness, headed: Boolean) {
private fun FitnessReadinessCard(readiness: FitnessAgeReadiness, headed: Boolean, lead: String = "") {
val drivesAge = readiness.items
.filter { it.role == FitnessReadinessRole.DRIVES_AGE }
.sortedBy { if (headed) readinessSortKey(it) else 0 }
Expand All @@ -914,7 +931,7 @@ private fun FitnessReadinessCard(readiness: FitnessAgeReadiness, headed: Boolean
if (headed) {
Column(verticalArrangement = Arrangement.spacedBy(Metrics.space4)) {
Text(
"A few more days and we can show your Fitness Age",
lead.ifBlank { "A few more days and we can show your Fitness Age." },
style = NoopType.headline,
color = Palette.textPrimary,
)
Expand Down