Repository navigation
Fitness Age: gate on the persisted history the card reads, not the in-pass rows - #140
Merged
Merged
Conversation
…-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.
Owner
Author
|
Update — root cause refined + hardened (after "original noop worked"). The Fitness Age block is byte-identical to upstream NoopApp/noop, so the code isn't inherently broken. With the readiness card showing 7/7 nights, the real cause is: the engine gates on computed-only Two downsides from the first cut are fixed here:
Vitality stays on |
tanarchytan
pushed a commit
to tanarchytan/noop
that referenced
this pull request
Jul 9, 2026
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 ryanbr#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 ryanbr#139 (readiness surfaces) + ryanbr#140 (gate fix), which this includes.
pipiche38
pushed a commit
to pipiche38/noop
that referenced
this pull request
Jul 9, 2026
…ot enough history" The Today card scores Fitness Age from one weekly reading (the ryanbr#140 fix), but tapping through hit the detail view's chart gate (points.size >= 2) and fell to the generic "Not enough history yet" note -- a contradiction: a value on the card, "no data" on tap. Every user in their first week after the fix sees this. Fitness Age is WEEKLY; only the TREND needs a second point. Android now shows the single reading's value + an honest "trend fills in once a second weekly reading lands" line, instead of the no-data dead end. Brings Android to parity with iOS, which already renders the value hero at a single point (its else-branch shows the value for size >= 1).
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
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 ryanbr#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 ryanbr#139 (readiness surfaces) + ryanbr#140 (gate fix), which this includes.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ot enough history" The Today card scores Fitness Age from one weekly reading (the ryanbr#140 fix), but tapping through hit the detail view's chart gate (points.size >= 2) and fell to the generic "Not enough history yet" note -- a contradiction: a value on the card, "no data" on tap. Every user in their first week after the fix sees this. Fitness Age is WEEKLY; only the TREND needs a second point. Android now shows the single reading's value + an honest "trend fills in once a second weekly reading lands" line, instead of the no-data dead end. Brings Android to parity with iOS, which already renders the value hero at a single point (its else-branch shows the value for size >= 1).
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Reported from the field: the Fitness Age readiness card shows every input satisfied — "Resting heart rate: 7 of last 7 nights", age/sex Set, activity 7 of 7 — yet the card reads "No Data" and never produces a value. Meanwhile Vitality / Body Age computes fine (54 / 48). Age and sex confirmed set in the profile, so it's not a coverage or profile gap.
Root cause
The engine gated Fitness Age on
fa7 = dailies— this recompute pass's freshly scored nights. A recompute only re‑scores a night whose raw HR still lives in the DB (if (hr.size < MIN_HR_SAMPLES) continue), but the computed daily rows (and their resting‑HR) persist after the raw streams age out. The readiness card, by contrast, counts the persisted / merged daily history.So the two disagree: the card counts the full persisted week ("7 of 7"), the engine's
dailiessees fewer than 4 resting‑HR nights →assessReadinessreturnsNOT_READY→ Fitness Age is never written. Vitality survives the same sparsefa7becauseVitalityEngineneeds only 3 of any input (sleep / HRV / steps), which is exactly why Body Age shows but Fitness Age doesn't.Fix
Gate + compute Fitness Age from the same merged last‑7 the card and dashboard read (
repo.daysMergedon Android,repo.dailyMetrics(fromDay:toDay:)union on iOS), kept separate fromfa7so Vitality (which already computes) is untouched.Monotonic‑safe: the merged view can only yield ≥ the resting‑HR nights
dailieshad, using valid values — so it can never make Fitness Age compute less often. Worst case it's a no‑op; best case it computes.Also adds a strap‑log line (Android) recording the resting‑HR‑night count the engine actually sees for the gate, so any remaining divergence from the card's "N of last 7" is provable from an export.
Honest caveat / validation
The recompute deletes+reinserts computed rows mid‑pass, so there's a chance the merged read reflects the same narrowed window and the fix is a no‑op for this exact case. That's what the diagnostic is for. To validate: after this ships and a recompute runs, does Fitness Age populate? If not, the strap‑log
fitnessAge gate … rhrNights=Nline pinpoints whether the persisted store itself is being pruned (a deeper follow‑up).Verification
:app:compileFullDebugKotlinpasses locally.repo.dailyMetrics(fromDay:toDay:)isasync(non‑throwing), in scope witholdestDay/newestDay;fa7/faRHRsretained for Vitality; removedfaActiveStrains/faMeanActiveStrainhave zero remaining refs.