Skip to content

Fitness Age: add a "refresh" button to force an immediate recompute - #142

Merged
ryanbr merged 5 commits into
mainfrom
feat/fitness-age-refresh-button
Jul 9, 2026
Merged

ryanbr merged 5 commits into
mainfrom
feat/fitness-age-refresh-button

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

A small refresh button on the Fitness Age not-ready card that forces an immediate recompute — so a ready user doesn't wait for the weekly pass.

NOOP writes Fitness Age only during the intelligence recompute (a 15-min backstop gated by an HR-fingerprint watermark), so the value can lag. This button recomputes Fitness Age now, from the persisted merged daily history — no raw-HR rescoring — so it:

How

Shared gate (no drift): extracted #140's gate + compute into IntelligenceEngine.fitnessAgeRows(...), used by both the recompute pass and the new recomputeFitnessAgeOnly(...).

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-reads the value on completion + a Toast.

iOS: engine parity (fitnessAgeRows / recomputeFitnessAgeOnly); an arrow.clockwise button on ReadinessChecklistCard (HealthView) and the fitness_age empty state (MetricDetailView), via the app-wide IntelligenceEngine environment object (preview injections added).

Notes

Verification

  • Android :app:compileFullDebugKotlin passes locally.
  • iOS hand-reviewed (Compression/Charts wall): reuses the engine's own date/window helpers + static medianOf/saturdayKey; profile passed to the shared helper as primitives (no cross-actor read); required @EnvironmentObject IntelligenceEngine is app-wide injected with both direct previews updated.

ryanbr and others added 5 commits July 9, 2026 18:47
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).
…-pass rows

User report: readiness card shows every input satisfied ("Resting heart
rate: 7 of last 7 nights", age/sex Set), yet Fitness Age stays "No Data"
while Vitality/Body Age computes. Root cause: the engine gated Fitness
Age on `fa7 = dailies` -- THIS recompute pass's freshly scored nights
(a recompute only re-scores nights whose raw HR still lives in the DB) --
while the readiness card counts the persisted/merged daily history. So
the two disagree: the card promises "ready", the engine sees <4 RHR
nights and never computes. Vitality survives because it needs only 3 of
ANY input (sleep/HRV/steps), so a sparse pass still clears its bar.

Fix: gate + compute Fitness Age from the SAME merged last-7 the card and
dashboard read (repo.daysMerged / repo.dailyMetrics union), kept separate
from `fa7` so Vitality is untouched. Monotonic-safe: the merged view can
only yield >= the RHR nights `dailies` had, using valid values, so it can
never make Fitness Age compute less often.

Also adds a strap-log line (Android) recording the RHR-night count the
engine actually sees for the gate, so a divergence from the card's "N of
last 7" is provable from an export.

Both platforms (Android IntelligenceEngine.kt + iOS IntelligenceEngine.swift).
…scores

Follow-up after "original noop worked" + the readiness card showing 7/7:
the root cause is the engine gating on computed-only `dailies`, while the
user's resting HR comes (partly) from IMPORTED daily aggregates (Apple
Health / Health Connect) that only the MERGED view carries. The first cut
read the merged view AFTER the recompute's own delete/evict, so it could
still see the narrowed window (a potential no-op) and the Android read was
unbounded.

Now snapshot the persisted/merged history BEFORE the rewrite and gate on
its UNION with this pass's fresh scores (by day, fresh wins): an RHR night
counts whether it survives in the store, was just scored, or came from an
import. Windowed to the recompute range (fixes the unbounded-read nit).
Vitality stays on `dailies` (already computes) , untouched.

Android + iOS.
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 #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 #139 (readiness surfaces) + #140 (gate fix), which this
includes.
@ryanbr
ryanbr merged commit 684b681 into main Jul 9, 2026
@ryanbr
ryanbr deleted the feat/fitness-age-refresh-button branch July 14, 2026 10:49
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…tton

Fitness Age: add a "refresh" button to force an immediate recompute
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