Skip to content

Fitness Age: "N more nights" countdown while it's building (iOS + Android) - #81

Merged
ryanbr merged 1 commit into
mainfrom
fitness-age-countdown
Jul 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fitness-age-countdown

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Requested: a countdown for how long until the Fitness Age generates. Today the card shows a vague "A few more days of wear…"; this makes it a concrete "N more nights of wear and we can show your Fitness Age." — the same honest "building, not broken" treatment Charge already uses ("Calibrating - N of 4 nights", #335).

What drives it

The only thing gating the headline is resting-HR coverage: rhrDays ≥ minCoverageDays (4). So the countdown is just max(0, 4 − rhrDays) — both values already in the readiness path.

Changes

  • Shared engine helper FitnessAgeEngine.nightsUntilReady(rhrDays) on both platforms (Swift + Kotlin) so the number is identical.
  • iOS (HealthView.FitnessAgeSection) and Android (HealthScreen.FitnessAgeSection): the not-ready lead is now the countdown, noting age/sex only when those are actually missing. The six copy variants are word-for-word identical across platforms (verified: iOS \(n) ↔ Android $remaining).
  • Swift test for nightsUntilReady (0→4, 1→3, 3→1, 4→0, 7→0).

Testing

  • Android compiles locally (compileFullDebugKotlin, exit 0) — caught + fixed a @Composable-annotation-detach on the way (re-review value).
  • The Swift nightsUntilReady test can't run on WSL (StrandAnalytics pulls GRDB → sqlite3.h wall), but it's trivial arithmetic and runs in CI.

UI + one pure helper; no scoring change.

Files: FitnessAgeEngine.swift/.kt, HealthView.swift, HealthScreen.kt, FitnessAgeEngineTests.swift.

The Fitness Age card sat on a vague "A few more days of wear…" until it had
enough history (resting HR on ≥4 of the last 7 nights). Give it a concrete
countdown instead, mirroring the honest "Calibrating - N of 4 nights" treatment
Charge already uses (#335).

- Shared engine helper FitnessAgeEngine.nightsUntilReady(rhrDays) = max(0,
  minCoverageDays - rhrDays), on BOTH platforms so the number is identical.
- The not-ready lead becomes "N more nights of wear and we can show your Fitness
  Age." (singular/plural handled), noting age/sex only when those are actually
  missing. Copy is word-for-word identical between iOS (HealthView) and Android
  (HealthScreen).
- Swift test for the countdown; Android compiles locally.

UI + one pure helper; no scoring change. rhrDays and the floor were already in
the readiness path.
@ryanbr
ryanbr merged commit 0e06dde into main Jul 9, 2026
ryanbr added a commit that referenced this pull request Jul 9, 2026
Fix iOS/macOS build: ReadinessChecklistCard.lead is a String (#81 regression)
pipiche38 pushed a commit to pipiche38/noop that referenced this pull request Jul 9, 2026
…alizedStringKey

The v8.5.0 release build failed to compile on both iOS and macOS:
  HealthView.swift:744: cannot convert 'String' to 'LocalizedStringKey'

fitnessReadyLead() (ryanbr#81, Fitness Age countdown) returns an already-localized
String (String(localized:), word-for-word matched to Android), but the card's
`lead` was typed LocalizedStringKey?. Re-keying an already-resolved string is
wrong anyway; the fix is to type `lead` as String? and render it verbatim via
Text(String). Both callers already pass a String / nil, and the only use is the
single Text(lead), so this is self-contained.

This slipped in because the fork runs the Swift build only at release/on-demand,
not per-commit, so ryanbr#81's iOS-only change wasn't compiled before merge.
tanarchytan pushed a commit to tanarchytan/noop that referenced this pull request Jul 9, 2026
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 ryanbr#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).
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 9, 2026
fitnessReadyLead() returns an already-localized String (via
String(localized:)), but ReadinessChecklistCard.lead was typed
LocalizedStringKey?, which SwiftUI treats as a lookup key rather than
literal text. Retype to String? — Text(_:) accepts both, and this is
the correct type for pre-resolved copy.

Pre-existing break from commit 75a096a (PR ryanbr#81), unrelated to this
branch's localization change; fixing here so PR #5's CI build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ryanbr
ryanbr deleted the fitness-age-countdown branch July 14, 2026 10:49
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
Fitness Age: "N more nights" countdown while it's building (iOS + Android)
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…alizedStringKey

The v8.5.0 release build failed to compile on both iOS and macOS:
  HealthView.swift:744: cannot convert 'String' to 'LocalizedStringKey'

fitnessReadyLead() (ryanbr#81, Fitness Age countdown) returns an already-localized
String (String(localized:), word-for-word matched to Android), but the card's
`lead` was typed LocalizedStringKey?. Re-keying an already-resolved string is
wrong anyway; the fix is to type `lead` as String? and render it verbatim via
Text(String). Both callers already pass a String / nil, and the only use is the
single Text(lead), so this is self-contained.

This slipped in because the fork runs the Swift build only at release/on-demand,
not per-commit, so ryanbr#81's iOS-only change wasn't compiled before merge.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…tringkey

Fix iOS/macOS build: ReadinessChecklistCard.lead is a String (ryanbr#81 regression)
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
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 ryanbr#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).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
fitnessReadyLead() returns an already-localized String (via
String(localized:)), but ReadinessChecklistCard.lead was typed
LocalizedStringKey?, which SwiftUI treats as a lookup key rather than
literal text. Retype to String? — Text(_:) accepts both, and this is
the correct type for pre-resolved copy.

Pre-existing break from commit 9de3b59 (PR ryanbr#81), unrelated to this
branch's localization change; fixing here so PR #5's CI build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
fitnessReadyLead() returns an already-localized String (via
String(localized:)), but ReadinessChecklistCard.lead was typed
LocalizedStringKey?, which SwiftUI treats as a lookup key rather than
literal text. Retype to String? — Text(_:) accepts both, and this is
the correct type for pre-resolved copy.

Pre-existing break from commit 75a096a (PR ryanbr#81), unrelated to this
branch's localization change; fixing here so PR ryanbr#5's CI build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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