Skip to content

Android: sleep stage-timeline rows (iOS #988 port) + HC sleep import fixes - #240

Merged
ryanbr merged 11 commits into
ryanbr:mainfrom
TheBoroer:sleep-stage-timeline
Jul 12, 2026
Merged

Android: sleep stage-timeline rows (iOS #988 port) + HC sleep import fixes#240
ryanbr merged 11 commits into
ryanbr:mainfrom
TheBoroer:sleep-stage-timeline

Conversation

@TheBoroer

@TheBoroer TheBoroer commented Jul 10, 2026

Copy link
Copy Markdown

What this PR does

Ports the iOS #988 sleep stage-timeline rows to Android: replaces the hero card's StageLegend with per-stage StageTimeline rows (span extraction, displaySmoothed port, Metrics constants, interval reconstruction from hero weight pairs).

Also fixes three Health Connect / sleep-hero bugs found while validating against strap data:

  • HC importer coveredDays gate — sleep import now skips days covered by the active strap ids, so a flat HC night can no longer shadow a fully-staged strap night.
  • In-bed figure — hero in-bed now spans the full night group (min onset → max wake across fragments) instead of the main fragment only, matching iOS SleepView.mergeDay.
  • Cleanup migration — purges previously-written HC my-whoop sleep rows that shadow strap-computed nights.
sleep-lastnight

Type of change

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

How it was tested

  • ./gradlew testFullDebugUnitTest — full com.noop.ingest.* and com.noop.ui.* packages pass, including the new HealthConnectCoveredDaysTest (gate + purge behavior).
  • Room compile-time SQL verification covers the two new purge queries in WhoopDao.
  • No BLE path touched — importer, DAO/repository, and Compose UI only.
  • Timeline rows verified on-device against strap-staged nights and HC-only nights (fallback/empty states).

Checklist

  • Swift package tests pass for any package I touched (none touched — Android only)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Android port of iOS #988 (stage timeline rows).

@TheBoroer
TheBoroer marked this pull request as ready for review July 10, 2026 20:49
ryanbr pushed a commit that referenced this pull request Jul 11, 2026
…rt order (iOS twin of #240) (#241)

* fix: prefer richer stagesJSON over recency in sleep-session merge

A re-imported session whose newer twin lacked stage data used to
clobber the older stage-bearing row. Merge now keeps the candidate
with non-empty stagesJSON regardless of recency; ties still resolve
by recency. Covered by richness/tie/regression cases in
SleepMergeTests.

* fix: canonical import outranks computed siblings in sourceCandidates

After a device re-add (active strap id != canonical "my-whoop"), the
resolver tried the active strap's computed sibling before the
canonical import, so fresh computed estimates shadowed richer
imported history. Candidate order is now: active strap > canonical
import > computed siblings > Apple fallback. Swift twin of the
#240 precedence fix; covered by SourceCandidatesOrderTests
(single-device path stays byte-identical).

* fix: point StrandTests TEST_HOST at renamed "NOOP Staging" product

The staging rename set the app's PRODUCT_NAME to "NOOP Staging" but
TEST_HOST still referenced NOOP.app, so local `xcodebuild test` could
not find the test host. Track the renamed product path (regenerate
with xcodegen after pulling).
ryanbr added a commit that referenced this pull request Jul 11, 2026
…computed stages (parity with #241) (#247)

* Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with #241)

Re-review of the #241/#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep
is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors
WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — #241 added the
richness exception to the Swift side only, so the shared-logic twins diverged.

Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer
clobbers a computed day that HAS stage data; days where the import carries stages, or where
neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank
= no stages), byte-parity with Swift's hasStages.

Not covered by #240: that's a Health-Connect-specific write-gate + heal-purge; it never touches
mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin.

Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin
+ MergeSleepLocalDayTest pass (8/0).

* Android: route the Sleep screen through the richness merge too (re-review)

Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export +
HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins
merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no
richness exception — so a stage-less import would still blank stages on the Android sleep
screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact
user-visible surface the fix is meant to protect.

Extract mergeSleepRichness (the imported-wins + #241 richness rule, returned UNSORTED) and
route both SleepScreen sites through it, keeping their effectiveStartTs sort (#395) and the
#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by
startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation.

compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
@ryanbr ryanbr added enhancement New feature or request Major Release (TODO) Major Release Version labels Jul 11, 2026
github-actions Bot pushed a commit to vinayakgupta62/noop-new that referenced this pull request Jul 11, 2026
…rt order (iOS twin of #240) (#241)

* fix: prefer richer stagesJSON over recency in sleep-session merge

A re-imported session whose newer twin lacked stage data used to
clobber the older stage-bearing row. Merge now keeps the candidate
with non-empty stagesJSON regardless of recency; ties still resolve
by recency. Covered by richness/tie/regression cases in
SleepMergeTests.

* fix: canonical import outranks computed siblings in sourceCandidates

After a device re-add (active strap id != canonical "my-whoop"), the
resolver tried the active strap's computed sibling before the
canonical import, so fresh computed estimates shadowed richer
imported history. Candidate order is now: active strap > canonical
import > computed siblings > Apple fallback. Swift twin of the
ryanbr/noop#240 precedence fix; covered by SourceCandidatesOrderTests
(single-device path stays byte-identical).

* fix: point StrandTests TEST_HOST at renamed "NOOP Staging" product

The staging rename set the app's PRODUCT_NAME to "NOOP Staging" but
TEST_HOST still referenced NOOP.app, so local `xcodebuild test` could
not find the test host. Track the renamed product path (regenerate
with xcodegen after pulling).
@ryanbr
ryanbr merged commit 5e8f0e8 into ryanbr:main Jul 12, 2026
ryanbr added a commit that referenced this pull request Jul 12, 2026
… not return raw fragments (#240 follow-up) (#316)

The Android port of Swift Hypnogram.displaySmoothed added an early return
`if (intervals.size <= 2 || minDurationSec <= 0.0) return intervals`. The
`|| minDurationSec <= 0.0` short-circuit isn't in Swift, which guards ONLY on count
and lets the "raw" (minDuration 0) case fall through to coalesce() — so Swift returns
the coalesced timeline, while Android returned the 60-100 un-merged epoch fragments
(the "comb" the smoothing exists to remove). Everything else in the port is faithful.

Production is unaffected (the only caller passes STAGE_ROW_SMOOTH_SEC = 90.0), so this
is a latent port-fidelity fix, not a shipping bug — but it makes the "straight port"
claim true and protects a future 0-caller.

Also strengthens `zeroFloorReturnsCoalescedInputUnsmoothed`: its old [light, deep, rem]
fixture had no adjacent same-stage runs, so coalesce was a no-op and it passed even
with the short-circuit. New fixture has a light/light seam and asserts the coalesced
result — verified it FAILS against the old short-circuit and passes with the fix.

Android-only (Health Connect / SleepScreen); testFullDebugUnitTest green.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…rt order (iOS twin of ryanbr#240) (ryanbr#241)

* fix: prefer richer stagesJSON over recency in sleep-session merge

A re-imported session whose newer twin lacked stage data used to
clobber the older stage-bearing row. Merge now keeps the candidate
with non-empty stagesJSON regardless of recency; ties still resolve
by recency. Covered by richness/tie/regression cases in
SleepMergeTests.

* fix: canonical import outranks computed siblings in sourceCandidates

After a device re-add (active strap id != canonical "my-whoop"), the
resolver tried the active strap's computed sibling before the
canonical import, so fresh computed estimates shadowed richer
imported history. Candidate order is now: active strap > canonical
import > computed siblings > Apple fallback. Swift twin of the
ryanbr#240 precedence fix; covered by SourceCandidatesOrderTests
(single-device path stays byte-identical).

* fix: point StrandTests TEST_HOST at renamed "NOOP Staging" product

The staging rename set the app's PRODUCT_NAME to "NOOP Staging" but
TEST_HOST still referenced NOOP.app, so local `xcodebuild test` could
not find the test host. Track the renamed product path (regenerate
with xcodegen after pulling).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…computed stages (parity with ryanbr#241) (ryanbr#247)

* Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with ryanbr#241)

Re-review of the ryanbr#241/ryanbr#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep
is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors
WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — ryanbr#241 added the
richness exception to the Swift side only, so the shared-logic twins diverged.

Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer
clobbers a computed day that HAS stage data; days where the import carries stages, or where
neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank
= no stages), byte-parity with Swift's hasStages.

Not covered by ryanbr#240: that's a Health-Connect-specific write-gate + heal-purge; it never touches
mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin.

Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin
+ MergeSleepLocalDayTest pass (8/0).

* Android: route the Sleep screen through the richness merge too (re-review)

Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export +
HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins
merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no
richness exception — so a stage-less import would still blank stages on the Android sleep
screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact
user-visible surface the fix is meant to protect.

Extract mergeSleepRichness (the imported-wins + ryanbr#241 richness rule, returned UNSORTED) and
route both SleepScreen sites through it, keeping their effectiveStartTs sort (ryanbr#395) and the
ryanbr#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by
startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation.

compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…mport fixes (ryanbr#240)

* docs: design + implementation plan for Android sleep stage-timeline rows (ryanbr#988 port)

* feat: sleep stage-interval reconstruction from hero weight pairs (ryanbr#988 port)

* docs: plan uses full-flavor gradle task names (app has product flavors)

* feat: port Hypnogram.displaySmoothed to Kotlin for the sleep timeline rows

* feat: per-stage row span extraction for the sleep timeline rows

* feat: Metrics constants for the sleep stage-timeline rows

* feat: StageTimeline row composables for the sleep hero (iOS ryanbr#988 port)

* feat: wire StageTimeline into the sleep hero card, drop StageLegend

* fix: gate HC sleep import on days covered by the active strap

* fix: in-bed figure spans the full night group, not the main fragment

* fix: purge HC my-whoop sleep rows shadowing strap-computed nights
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
… not return raw fragments (ryanbr#240 follow-up) (ryanbr#316)

The Android port of Swift Hypnogram.displaySmoothed added an early return
`if (intervals.size <= 2 || minDurationSec <= 0.0) return intervals`. The
`|| minDurationSec <= 0.0` short-circuit isn't in Swift, which guards ONLY on count
and lets the "raw" (minDuration 0) case fall through to coalesce() — so Swift returns
the coalesced timeline, while Android returned the 60-100 un-merged epoch fragments
(the "comb" the smoothing exists to remove). Everything else in the port is faithful.

Production is unaffected (the only caller passes STAGE_ROW_SMOOTH_SEC = 90.0), so this
is a latent port-fidelity fix, not a shipping bug — but it makes the "straight port"
claim true and protects a future 0-caller.

Also strengthens `zeroFloorReturnsCoalescedInputUnsmoothed`: its old [light, deep, rem]
fixture had no adjacent same-stage runs, so coalesce was a no-op and it passed even
with the short-circuit. New fixture has a light/light seam and asserts the coalesced
result — verified it FAILS against the old short-circuit and passes with the fix.

Android-only (Health Connect / SleepScreen); testFullDebugUnitTest green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Major Release (TODO) Major Release Version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants