Skip to content

Baselines: imported rows with nil values no longer shadow computed nights - #401

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
TheBoroer:hrv-baseline-shadow-fix
Jul 14, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
TheBoroer:hrv-baseline-shadow-fix

Conversation

@TheBoroer

Copy link
Copy Markdown

What this PR does

Fixes a "Needs the strap" / "No Data" false negative on Today and Recovery when the user does have sleep data for the day.

When a day had both an imported row (e.g. a Whoop/backup import) and an on-device computed row, the merge in IntelligenceEngine let the imported row win the whole day even when its individual fields were nil. Those nil fields then shadowed real computed values, so recovery/sleep scoring saw an empty night and the UI fell back to "Needs the strap" / "No Data" despite a scored sleep session existing.

The merge now treats imported precedence per-field: an imported nil no longer blanket-wins the day, and computed values fill any fields the import doesn't actually carry. Applied identically to the Swift engine (Strand/Data/IntelligenceEngine.swift) and its Kotlin twin (android/.../com/noop/analytics/IntelligenceEngine.kt) so the two lanes stay in step.

Also updates TodayExplainabilityTests to render LocalizedStringKey titles instead of comparing keys: the carried-night titles are built with interpolation ("Last night · %@") so the string-catalog extractor sees the format key, which makes literal-key equality always false. The tests now resolve the key + arguments and pin the exact user-visible copy.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Analytics/merge change only — no BLE path touched.

  • New regression tests on both lanes covering the shadow scenario (imported row with nil fields + computed sibling for the same day): StrandTests/IntelligenceBaselineShadowTests.swift and android/.../IntelligenceBaselineShadowTest.kt.
  • Swift: xcodebuild test -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS,arch=arm64' — IntelligenceBaselineShadowTests and the full TodayExplainabilityTests suite pass (TEST SUCCEEDED).
  • Android: ./gradlew :app:testDemoDebugUnitTest (the demo flavor is the unit-test variant in this checkout) — shadow test plus the existing baseline/recalibration tests pass, BUILD SUCCESSFUL.
  • Reproduced the original report first: engine-level harness showed the imported nil row blocking the computed night before the fix and the recovered score after it.

Checklist

  • N/A — Swift package tests (Packages/<name> not touched; change is in the Strand app target)
  • Android unit tests pass if I touched android/ (ran :app:testDemoDebugUnitTest; no testFullDebugUnitTest task in this project's flavor setup)
  • No new build warnings introduced
  • N/A — no UI changes (copy/layout untouched; test-only change on the Today explainability side)
  • N/A — no protocol changes, no frame bytes involved
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

No upstream issue filed — fix comes from a user report (screenshots showed "Needs the strap" + Recovery "No Data" on a day with a 5h 24m scored sleep in the same backup).

Boro Vukovic added 2 commits July 13, 2026 12:23
…ghts

The nightly-into-history merge only checked KEY presence before letting a
computed night in. A Health Connect / backup import that creates a row for
a day with a nil HRV/RHR value therefore shadowed the real strap-computed
night forever: zero nights reached the baseline fold, the baseline never
left CALIBRATING, and Today rendered "No Data" / "Needs the strap" despite
scored sleep (the reported bug).

Fix, per-field and identical on both platforms: imported non-nil wins; a
nil (or absent) slot is backfilled by the computed value.

- Swift: hist[day] == nil on [String: Double?] is only true for an absent
  key (.some(.none) passed the old check). Also mark the helper
  nonisolated so it is testable off the main actor.
- Kotlin: day !in hist had the same shape; hist[day] == null covers both
  absent keys and null values.

Tests (RED first on both platforms): IntelligenceBaselineShadowTests
(Swift) and IntelligenceBaselineShadowTest (Kotlin) cover imported-non-nil
wins, nil backfilled, no-computed stays missing, and the 7/7 strap-night
starvation end-to-end shape.
The carriedLastNight titles are now interpolated ("Last night · %@")
so the string-catalog extractor sees the format key (ryanbr#779), which makes
LocalizedStringKey equality against a literal key always false. Resolve
the key + arguments via Mirror and pin the rendered user-visible copy.
@TheBoroer
TheBoroer marked this pull request as ready for review July 13, 2026 17:47
@ryanbr
ryanbr merged commit 2c4afb5 into ryanbr:main Jul 14, 2026
2 checks passed
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
From #34 (alarm not buzzing): the strap consistently reported a STALE armed time regardless
of what NOOP sent (its clock was reset) — the ryanbr#401 readback caught it, but nothing surfaced
it and the log even claimed 'armed' while disconnected.

#3 — Alarm block in the debug export (both platforms): the configured wake + the last arm's
  sent-vs-strap-reports epochs with a MISMATCH flag, so a 'didn't buzz' report is decidable at
  a glance. armStrapAlarm persists the sent epoch (+ when + connected); the GET_ALARM_TIME
  readback persists what the strap reports; the export composes them (best-effort, guarded).

#4 — don't log 'Alarm: armed' when the strap isn't connected (the SET was dropped). Log
  'queued — will send on next connect' instead, so the log stops claiming an arm that never
  transmitted.

Both platforms (iOS BLEManager/FrameRouter/DebugDataDiagnostics; Android WhoopBleClient/
AndroidDiagnostics). Android compiles clean; iOS gated by CI.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ghts (ryanbr#401)

* Baselines: imported rows with nil values no longer shadow computed nights

The nightly-into-history merge only checked KEY presence before letting a
computed night in. A Health Connect / backup import that creates a row for
a day with a nil HRV/RHR value therefore shadowed the real strap-computed
night forever: zero nights reached the baseline fold, the baseline never
left CALIBRATING, and Today rendered "No Data" / "Needs the strap" despite
scored sleep (the reported bug).

Fix, per-field and identical on both platforms: imported non-nil wins; a
nil (or absent) slot is backfilled by the computed value.

- Swift: hist[day] == nil on [String: Double?] is only true for an absent
  key (.some(.none) passed the old check). Also mark the helper
  nonisolated so it is testable off the main actor.
- Kotlin: day !in hist had the same shape; hist[day] == null covers both
  absent keys and null values.

Tests (RED first on both platforms): IntelligenceBaselineShadowTests
(Swift) and IntelligenceBaselineShadowTest (Kotlin) cover imported-non-nil
wins, nil backfilled, no-computed stays missing, and the 7/7 strap-night
starvation end-to-end shape.

* Tests: render LocalizedStringKey titles instead of comparing keys

The carriedLastNight titles are now interpolated ("Last night · %@")
so the string-catalog extractor sees the format key (ryanbr#779), which makes
LocalizedStringKey equality against a literal key always false. Resolve
the key + arguments via Mirror and pin the rendered user-visible copy.

---------

Co-authored-by: Boro Vukovic <hello@loudtronix.ca>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ghts (ryanbr#401)

* Baselines: imported rows with nil values no longer shadow computed nights

The nightly-into-history merge only checked KEY presence before letting a
computed night in. A Health Connect / backup import that creates a row for
a day with a nil HRV/RHR value therefore shadowed the real strap-computed
night forever: zero nights reached the baseline fold, the baseline never
left CALIBRATING, and Today rendered "No Data" / "Needs the strap" despite
scored sleep (the reported bug).

Fix, per-field and identical on both platforms: imported non-nil wins; a
nil (or absent) slot is backfilled by the computed value.

- Swift: hist[day] == nil on [String: Double?] is only true for an absent
  key (.some(.none) passed the old check). Also mark the helper
  nonisolated so it is testable off the main actor.
- Kotlin: day !in hist had the same shape; hist[day] == null covers both
  absent keys and null values.

Tests (RED first on both platforms): IntelligenceBaselineShadowTests
(Swift) and IntelligenceBaselineShadowTest (Kotlin) cover imported-non-nil
wins, nil backfilled, no-computed stays missing, and the 7/7 strap-night
starvation end-to-end shape.

* Tests: render LocalizedStringKey titles instead of comparing keys

The carriedLastNight titles are now interpolated ("Last night · %@")
so the string-catalog extractor sees the format key (ryanbr#779), which makes
LocalizedStringKey equality against a literal key always false. Resolve
the key + arguments via Mirror and pin the rendered user-visible copy.

---------

Co-authored-by: Boro Vukovic <hello@loudtronix.ca>
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