Repository navigation
Conversation
…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).
|
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 I verified the parts that are checkable here rather than taking the description's word:
Three things worth addressing. 1. var byDay = Dictionary(uniqueKeysWithValues: series.map { ($0.day, $0.value) })Twelve lines up, the existing code deliberately writes Reachability today: What makes it worth changing rather than noting: the Kotlin twin uses 2. The Swift gate keys on the metric but not the partition.
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 3. A pre-existing divergence this PR becomes the second consumer of (not yours to fix, but worth knowing).
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. |
|
One optimisation I missed on the first pass, Android-only.
The result is then passed to It isn't free even though the flow is shared: Worth fixing mainly because the Swift half of this same PR already gets it right — The fix needs care, because the obvious version reintroduces a worse problem: // 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 Separately, and definitely not for this PR: the dominant cost on the Swift Explorer is phase 2 of |
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.
|
Thanks — pulled
Still owed on this PR: the device capture with the toggle flipped on, and |
What this PR does
MetricExplorerView(iOS/macOS) andHealthScreen'sVitalDetailScreen(Android) both drill into ageneric, 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 calibratedspo2Pctwith no
spo2_candidatefallback at all — #1568 never touched it.Found 2026-08-24 diagnosing a Patrick screenshot: an install with no calibrated SpO2 source (WHOOP 4.0
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_candidatefor any day the calibrated series is missing — calibratedalways wins, this only ADDS days, never overwrites one — gated on the same
spo2CandidateDisplayEnabled/spo2CandidateDisplaytoggle every other candidate site checks. Rowssourced 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 EXISTINGvo2max-estimator:attribution patternexactly — no new plumbing, just one more case in
provenanceDisplayLabelon each platform.Reuses the already-fully-translated
spo2_strap_estimate_captionstring resource on Android and theexisting "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 teston the touched suite: 7/7.xcodebuild -scheme NOOPiOS build(iOS, shares the same edited files).compileFullDebugKotlinclean;com.noop.ui.*767/767 minus 3 pre-existingTodayExplainabilityTestfailures confirmed present on pristineupstream/mainbefore this change(date-relative, unrelated, not chased).
doc_comment_lint.pyandi18n_audit.py --ciboth clean — no new literals (the Kotlin string wasalready 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-roundingPR.