Skip to content

fix(oura): SpO2 candidate fallback missing from the "Your Cards" - #1590

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-cards-explorer-candidate-fallback
Aug 24, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-cards-explorer-candidate-fallback

Conversation

@pipiche38

Copy link
Copy Markdown

What this PR does

MetricExplorerView (iOS/macOS) and HealthScreen's VitalDetailScreen (Android) both drill into a
generic, catalog-driven detail page — the one with the chart, the WINDOW/POINTS/LATEST header, and the
readings table, reachable by tapping into "Your Cards". Unlike every other Blood Oxygen surface (the
Key Metrics tile, VitalSignsSummary, LiquidTodayView), this screen read only calibrated spo2Pct
with no spo2_candidate fallback at all — #1568 never touched it.

Found 2026-08-24 diagnosing a Patrick screenshot: an install with no calibrated SpO2 source (WHOOP 4.0

  • Oura, no 5/MG) showed a real candidate number (97%) on the Key Metrics tile, but the "Your Cards"
    Blood Oxygen drill-down was empty/stale, frozen on the last day a calibrated import existed
    (2026-07-30) — weeks earlier.

The fix

Both platforms now merge in spo2_candidate for any day the calibrated series is missing — calibrated
always wins, this only ADDS days, never overwrites one — gated on the same
spo2CandidateDisplayEnabled/spo2CandidateDisplay toggle every other candidate site checks. Rows
sourced from the candidate are labelled "strap estimate (unverified)" in the readings table's Source
column via a new source-token idiom (spo2CandidateAttributionSource /
SPO2_CANDIDATE_ATTRIBUTION_SOURCE), mirroring the EXISTING vo2max-estimator: attribution pattern
exactly — no new plumbing, just one more case in provenanceDisplayLabel on each platform.

Reuses the already-fully-translated spo2_strap_estimate_caption string resource on Android and the
existing "strap estimate (unverified)" literal on Swift — no new i18n debt.

Tests

2 new tests (one per platform) pin the new source label resolving to "strap estimate (unverified)",
matching the existing VitalReadingsTableTest(s) style.

Verification

  • xcodebuild -scheme Strand build (macOS app-target Swift — CI doesn't cover this) + xcodebuild test on the touched suite: 7/7.
  • xcodebuild -scheme NOOPiOS build (iOS, shares the same edited files).
  • Android compileFullDebugKotlin clean; com.noop.ui.* 767/767 minus 3 pre-existing
    TodayExplainabilityTest failures confirmed present on pristine upstream/main before this change
    (date-relative, unrelated, not chased).
  • doc_comment_lint.py and i18n_audit.py --ci both clean — no new literals (the Kotlin string was
    already translated in all 4 focus locales).

Not yet done

Not flashed to a device with the toggle on — that end-to-end capture is owed alongside the sibling
fix/oura-spo2-candidate-mean-rounding PR.

…il screen

MetricExplorerView (iOS/macOS) and HealthScreen's VitalDetailScreen
(Android) both drill into a generic, catalog-driven detail page - the
one with the chart, the WINDOW/POINTS/LATEST header, and the readings
table - reachable from "Your Cards". Unlike every other Blood Oxygen
surface (the Key Metrics tile, VitalSignsSummary, LiquidTodayView),
this screen read only calibrated spo2Pct with no spo2_candidate
fallback at all - ryanbr#1568 never touched it.

Found 2026-08-24: an install with no calibrated SpO2 source (WHOOP 4.0
+ Oura, no 5/MG) showed a real candidate number on the Key Metrics
tile but an empty/stale "Your Cards" detail page frozen on the last
day a calibrated import existed, weeks earlier.

Both platforms now merge in spo2_candidate for any day the calibrated
series is missing (calibrated always wins; this only adds days,
never overwrites one), gated on the same toggle every other candidate
site checks. Rows sourced from the candidate are labelled "strap
estimate (unverified)" in the readings table's Source column via a
new source-token idiom (spo2CandidateAttributionSource /
SPO2_CANDIDATE_ATTRIBUTION_SOURCE), mirroring the existing
vo2max-estimator attribution pattern exactly so no new plumbing was
needed - just one more case in provenanceDisplayLabel on each
platform. Reuses the already-fully-translated
spo2_strap_estimate_caption string resource on Android and the
existing "strap estimate (unverified)" literal on Swift - no new
i18n debt.

2 new tests (one per platform) pin the new source label. Verified:
xcodebuild -scheme Strand build (macOS app, app-target Swift - CI
doesn't cover this) + xcodebuild test on the touched suite (7/7);
Android compileFullDebugKotlin clean, com.noop.ui.* 767/767 minus 3
pre-existing TodayExplainabilityTest failures confirmed present on
pristine upstream/main before this change (unrelated, not chased);
doc_comment_lint.py and i18n_audit.py --ci both clean (no new
literals - the Kotlin string was already translated in all 4 focus
locales).
@ryanbr
ryanbr marked this pull request as ready for review August 24, 2026 13:00
@ryanbr

ryanbr commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Good find, and the diagnosis is right — this screen genuinely was the one Blood Oxygen surface #1568 never reached. The attribution-token reuse is the correct call: one more case in provenanceDisplayLabel per platform, no new plumbing, and no new i18n debt since spo2_strap_estimate_caption was already translated.

I verified the parts that are checkable here rather than taking the description's word:

  • Merged against current main (your base is 8 commits behind): merges clean, Android suite 4295 tests, 0 failures. The 3 TodayExplainabilityTest failures you flagged as pre-existing do not reproduce today — they're date-relative, so your read was right and there's nothing owed there.
  • Calibrated-wins invariant holds on both sides: Swift's where byDay[point.day] == nil and Kotlin's filterKeys { it !in calibrated } are equivalent. Candidate days are additive only.
  • The Android toggle gate is real — it lives in the flow (if (!toggleOn || days.isEmpty()) emptyMap()), not the call site, so the comment's claim checks out.

Three things worth addressing.

1. Dictionary(uniqueKeysWithValues:) traps where the line above it defends.

var byDay = Dictionary(uniqueKeysWithValues: series.map { ($0.day, $0.value) })

Twelve lines up, the existing code deliberately writes Dictionary(resolution.points.map { … }, uniquingKeysWith: { first, _ in first }). That guard is there for a reason, and the new line drops it.

Reachability today: exploreSeries returns byDay.sorted { … } on the my-whoop path, so it's deduped and this can't fire. But that's the only path that dedupes — series(key:source:), which exploreSeries delegates to for every other source, ends return pts.map { ($0.day, $0.value) } with no collapsing at all.

What makes it worth changing rather than noting: the Kotlin twin uses .toMap(), which silently keeps the last value on a duplicate day. Same input, Swift traps and Kotlin doesn't — a crash on one platform and a quiet result on the other, from a parity pair. uniquingKeysWith: { first, _ in first } costs nothing and matches the neighbour.

2. The Swift gate keys on the metric but not the partition.

if metric.key == "spo2" matches two catalog entries:

d("spo2", …, "my-whoop",    …)   // line 137
d("spo2", …, "xiaomi-band", …)   // line 202

So the branch also runs on the Xiaomi Band Blood Oxygen card. It's harmless as written — the candidate query is source-scoped, and a Xiaomi partition has no spo2_candidate rows, so it returns empty and falls through. But the guard doesn't express what it means, and MetricDescriptor.id is literally source + ":" + key, so the catalog treats these as different metrics even though the key collides. Adding metric.source == "my-whoop" makes the intent explicit and forecloses a WHOOP/Oura strap estimate ever surfacing on a Xiaomi card. Android has no equivalent exposure — VitalDetailScreen is keyed by metric alone, not partitioned by source.

3. A pre-existing divergence this PR becomes the second consumer of (not yours to fix, but worth knowing).

spo2CandidateByDay reads the pref via flowOf(NoopPrefs.spo2CandidateDisplay(appContext)) — evaluated once, when the flow is constructed. Turning the toggle off doesn't re-emit until the ViewModel is recreated. Swift re-reads PuffinExperiment.spo2CandidateDisplayEnabled on every load(), so it responds on the next open.

That means: toggle off, reopen this screen — Android may still show candidate rows where iOS won't. It predates this PR, but until now the tile was the only consumer; this adds a second surface with the same lag. Happy to take it separately if you'd rather keep this PR to one concern.

1 and 2 are small and in the Swift file only. 3 is optional. Otherwise this is ready.

@ryanbr

ryanbr commented Aug 24, 2026

Copy link
Copy Markdown
Owner

One optimisation I missed on the first pass, Android-only.

collectAsStateWithLifecycle() on spo2CandidateByDay sits at the top of VitalDetailScreen, above isSeriesBacked — so it runs for every vital detail, not just Blood Oxygen. Open Resting Heart Rate, HRV, Skin Temperature or Sleep and you now subscribe a flow whose upstream is a real database read:

metricSeriesComputedUnion → computedSourceIds → metricSeries(range) [× ids] → mergeComputedSeriesUnion

The result is then passed to buildVitalDetail, which only looks at it inside the "spo2" branch. Every other metric pays for a query it discards.

It isn't free even though the flow is shared: stateIn(…, WhileSubscribed(5_000), …) means a navigation that arrives more than five seconds after the Key Metrics tile unsubscribed re-runs the upstream — which is the normal path here, since you reach this screen by going Today → Health → tap.

Worth fixing mainly because the Swift half of this same PR already gets it right — if metric.key == "spo2", PuffinExperiment.spo2CandidateDisplayEnabled means the extra exploreSeries call only happens on the Blood Oxygen explorer. Android does the work everywhere.

The fix needs care, because the obvious version reintroduces a worse problem: collectAsStateWithLifecycle is a composable, so it must not be called conditionally, and folding the key test into a short-circuit (key == "spo2" && …) would make it a composable call in a conditionally-evaluated position — a slot-table hazard when the condition later flips. Swapping the flow rather than the call keeps one unconditional call site:

// One collect call, always. Only the spo2 screen subscribes the real flow; everything else
// subscribes a constant, so no other vital pays for a candidate query it discards.
val candidateFlow: StateFlow<Map<String, Double>> = remember(key) {
    if (key == "spo2") vm.spo2CandidateByDay else MutableStateFlow(emptyMap())
}
val spo2CandidateByDay by candidateFlow.collectAsStateWithLifecycle()

That also tightens the remember(days, key, tempUnit, effortScale, spo2CandidateByDay) memo below it: for every non-spo2 metric the map is now a constant emptyMap(), so a candidate recompute can no longer invalidate an unrelated vital's detail model.

Separately, and definitely not for this PR: the dominant cost on the Swift Explorer is phase 2 of load(), which walks MetricCatalog.all (~59 entries) awaiting exploreSeries one at a time. That serial loop outweighs everything discussed here by a wide margin if anyone wants to look at it later.

Three points from review, all on the new code.

Swift, the merge dictionary: `uniqueKeysWithValues` TRAPS on a duplicate
day, where the `sourceByDay` build twelve lines above deliberately uses
`uniquingKeysWith`. `exploreSeries` collapses by day on the `my-whoop`
path taken here, but `series(key:source:)` — where every other source
falls through — ends `pts.map { … }` with no collapsing, so the guarantee
belongs to the caller rather than the type. The Kotlin twin uses
`.toMap()`, which keeps the last value silently, so a trap here would
mean identical input crashing one platform and not the other.

Swift, the gate: the catalog carries TWO `spo2` descriptors, `my-whoop`
and `xiaomi-band`, and `MetricDescriptor.id` is `source + ":" + key` —
they are different metrics sharing a key. Only the WHOOP/Oura partition
has a candidate series, so the source joins the gate; a Xiaomi Band's
Blood Oxygen card should never ask for a strap estimate that isn't its
own.

Android, the subscription: `collectAsStateWithLifecycle` sat above
`isSeriesBacked`, so every vital — Resting HR, HRV, Skin Temp — subscribed
a flow whose upstream is a `metricSeriesComputedUnion` database read, and
discarded it. `WhileSubscribed(5_000)` means the normal Today → Health →
tap path re-runs it. The flow is now swapped per metric rather than the
call gated: gating the call would put a composable in a
conditionally-evaluated position and corrupt the slot table when the key
changes, so there is still exactly one unconditional call site.

Android compile + full unit suite green (4293/0) and assembleFullDebug
clean. The Swift half is app-target and needs app-build.
@ryanbr
ryanbr merged commit 49f35a9 into ryanbr:main Aug 24, 2026
3 checks passed
@pipiche38

Copy link
Copy Markdown
Author

Thanks — pulled d74f75e7 into integration/oura-full (clean cherry-pick, no conflicts) and rebuilt.

xcodebuild -scheme Strand build is green with all three fixes (dedup-safe merge, source-scoped gate,
Android per-metric flow swap). Hit an unrelated local Xcode explicit-modules cache bug mid-build
(fatal error: module file '.../*.pcm' not found, on swift-cmark, a third-party SPM dep) — worked
around with CLANG_ENABLE_EXPLICIT_MODULES=NO/SWIFT_ENABLE_EXPLICIT_MODULES=NO, not a code issue.

Still owed on this PR: the device capture with the toggle flipped on, and xcodebuild -scheme NOOPiOS
for the iOS leg.

@pipiche38
pipiche38 deleted the fix/oura-spo2-cards-explorer-candidate-fallback branch September 24, 2026 16:08
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.

2 participants