diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 03d88a75f6..387ca29788 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -2047,6 +2047,32 @@ class WhoopRepository( return dedupSleepBlocks(ids.flatMap { dao.sleepSessions(it, from, to, limit) }) } + /** + * ALL sleep sessions across every registered WHOOP (active first, archived included, canonical + * last) over the last [days], imported [sleepSessionsUnion] merged with the computed + * [computedSleepSessionsUnion] twin: a computed session is kept only when its LOCAL wake-day (the + * same `AnalyticsEngine.dayString` keyer `mergeSleep` uses) is NOT already covered by an imported + * session that day — no richness exception, unlike `mergeSleepRichness`/[sleepSessionsMerged]. + * Sorted by [SleepSession.effectiveStartTs] ascending, so the caller's `.lastOrNull()` is the most + * recent night. Robust to a stale/wrong [deviceId] (e.g. no strap currently connected) because + * [rawWhoopSourceIds] enumerates every registered WHOOP regardless of which id is passed in. + * Mirrors Swift `Repository.allSleepSessions(days:)` exactly. + */ + suspend fun allSleepSessionsUnion(deviceId: String, days: Int = 4000): List { + val now = System.currentTimeMillis() / 1000L + val lo = now - days * 86_400L + val hi = now + 86_400L + val imported = sleepSessionsUnion(deviceId, lo, hi) + val computed = computedSleepSessionsUnion(deviceId, lo, hi) + fun endDay(s: SleepSession): String { + val offsetSec = (java.util.TimeZone.getDefault().getOffset(s.endTs * 1000) / 1000).toLong() + return com.noop.analytics.AnalyticsEngine.dayString(s.endTs, offsetSec) + } + val importedDays = imported.mapTo(HashSet(), ::endDay) + val computedKept = computed.filter { endDay(it) !in importedDays } + return (imported + computedKept).sortedBy { it.effectiveStartTs } + } + /** Workouts over every registered WHOOP (active first, archived retained) plus canonical "my-whoop", * matching [hrSamplesUnion] / [sleepSessionsUnion]. A re-added strap owns "whoop-" while * imports + prior data live under "my-whoop", so a read pinned to a SINGLE id strands the other's diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index 1b17cc8523..b1b35030c8 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -752,10 +752,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { composable(Destination.InsightsHub.route) { InsightsHubScreen(viewModel) } composable(Destination.LabBook.route) { LabBookScreen(viewModel) } composable(Destination.Rhythm.route) { - // EXPERIMENTAL: self-gates on its own consent clickwrap (default OFF). The night - // summary + per-window Poincaré results land with the rhythm capture pipeline; until - // then it renders its honest "no clear reading yet" empty state behind the gate. - RhythmScreen(night = null, windows = emptyList()) + RhythmRoute(viewModel) } composable(Destination.FusedRecord.route) { FusedRecordRoute(viewModel) } composable(Destination.AppleHealth.route) { AppleHealthScreen(viewModel) } diff --git a/android/app/src/main/java/com/noop/ui/RhythmRoute.kt b/android/app/src/main/java/com/noop/ui/RhythmRoute.kt new file mode 100644 index 0000000000..39189011df --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/RhythmRoute.kt @@ -0,0 +1,175 @@ +package com.noop.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.noop.analytics.RhythmEmptyState +import com.noop.analytics.RhythmScreener +import com.noop.data.GravitySample +import com.noop.protocol.RrInterval as ProtocolRrInterval +import kotlin.math.min +import kotlin.math.sqrt + +/** + * The data-loading route for the experimental Rhythm visualization. Loads the most recent + * banked sleep session, pulls its R-R + gravity samples, windows the night into 5-minute slices, + * gates each on stillness, and runs the pure [RhythmScreener] engine. Self-gates on consent + * (handled inside [RhythmScreen]). + * + * Mirrors macOS `RhythmHost.load()` logic for cross-platform parity. + */ +@Composable +fun RhythmRoute(viewModel: AppViewModel) { + // State: the three outputs RhythmScreen takes + var night by remember { + mutableStateOf(null) + } + var windows by remember { + mutableStateOf>(emptyList()) + } + var emptyReason by remember { + mutableStateOf(RhythmEmptyState.GATHERING_DATA) + } + + // Load data once on composition (compute-once-per-host-lifetime pattern) + LaunchedEffect(Unit) { + loadRhythmData(viewModel, onLoaded = { n, w, e -> + night = n + windows = w + emptyReason = e + }) + } + + // Feed the screen (consent gate is inside RhythmScreen) + RhythmScreen( + night = night, + windows = windows, + emptyReason = emptyReason, + onClose = null, // No close callback needed when navigated to + ) +} + +/** + * Load the most recent banked night's R-R windows, run the pure [RhythmScreener] over each + * still, resting window. All math is on-device; nothing is computed until the user passes + * the consent gate (handled by [RhythmScreen]). + */ +private suspend fun loadRhythmData( + viewModel: AppViewModel, + onLoaded: ( + night: RhythmScreener.NightRhythmSummary?, + windows: List, + emptyReason: RhythmEmptyState + ) -> Unit +) { + // allSleepSessionsUnion reads across EVERY registered WHOOP regardless of which id is "active", + // so passing the (possibly stale, possibly never-connected) active strap id is safe here. + val deviceId = viewModel.activeStrapId + val repo = viewModel.repo + + // Step 1: Load the most recent sleep session (last 14 days), imported UNION computed across every + // registered strap, matching Swift `allSleepSessions(days: 14)` exactly. + val sessions = runCatching { + repo.allSleepSessionsUnion(deviceId, days = 14) + }.getOrDefault(emptyList()) + + // Sessions are sorted by effectiveStartTs ascending, so the last entry is the most recent night. + val lastSleep = sessions.lastOrNull() ?: run { + // No sleep session found — stay in GATHERING_DATA state + onLoaded(null, emptyList(), RhythmEmptyState.GATHERING_DATA) + return + } + + // Step 2: Extract time bounds + val lo = lastSleep.effectiveStartTs + val hi = lastSleep.endTs + if (hi <= lo) { + onLoaded(null, emptyList(), RhythmEmptyState.GATHERING_DATA) + return + } + + // Step 3: Load R-R intervals and gravity samples for the night + val rrRows = runCatching { + repo.rrIntervalsUnion(deviceId, lo, hi, limit = 200_000) + }.getOrDefault(emptyList()) + + if (rrRows.isEmpty()) { + // No R-R data — diagnose honestly + val grav = runCatching { + repo.gravitySamplesUnion(deviceId, lo, hi, limit = 200_000) + }.getOrDefault(emptyList()) + + val emptyReason = RhythmScreener.classifyEmptyState( + windows = emptyList(), + hadMotionSignal = grav.isNotEmpty(), + beatsAreBanked = false + ) + onLoaded(null, emptyList(), emptyReason) + return + } + + val grav = runCatching { + repo.gravitySamplesUnion(deviceId, lo, hi, limit = 200_000) + }.getOrDefault(emptyList()) + + // Step 4: Window the night into 5-minute slices + val windowSec = 5 * 60L + val results = mutableListOf() + var t = lo + + while (t < hi) { + val wEnd = minOf(t + windowSec, hi) + val wRR = rrRows.filter { it.ts >= t && it.ts < wEnd } + + if (wRR.size >= RhythmScreener.WINDOW_MIN_BEATS) { + val wGrav = grav.filter { it.ts >= t && it.ts < wEnd } + val still = isStill(wGrav) + // Convert Room entities to protocol objects for the analytics engine + val protocolRR = wRR.map { ProtocolRrInterval(ts = it.ts.toInt(), rrMs = it.rrMs) } + val input = RhythmScreener.WindowInput.fromRr(protocolRR, motionStill = still) + results.add(RhythmScreener.screenWindow(input)) + } + + t = wEnd + } + + // Step 5: Compute night summary and empty state + val night = RhythmScreener.summarizeNight(results) + val emptyReason = RhythmScreener.classifyEmptyState( + windows = results, + hadMotionSignal = grav.isNotEmpty(), + beatsAreBanked = RhythmScreener.nightBeatsAreBanked( + rrMs = rrRows.map { it.rrMs.toDouble() }, + tsSec = rrRows.map { it.ts.toInt() } + ) + ) + + onLoaded(night, results, emptyReason) +} + +/** + * A window is "still" when its accelerometer magnitude varies little (a resting wrist). + * A coarse, conservative gate — movement is the single biggest false signal for a regularity + * read, so we err toward NOT reading a window rather than describing a moving one. + * + * Requires at least 4 samples; normalised standard deviation below 3% of the mean magnitude + * reads as a still wrist. Mirrors macOS `RhythmHost.isStill()`. + */ +private fun isStill(grav: List): Boolean { + if (grav.size < 4) return false + + val mags = grav.map { g -> + sqrt(g.x * g.x + g.y * g.y + g.z * g.z) + } + + val mean = mags.sum() / mags.size + if (mean <= 0.0) return false + + val variance = mags.map { (it - mean) * (it - mean) }.sum() / mags.size + val normalizedStd = sqrt(variance) / mean + + return normalizedStd < 0.03 +} diff --git a/android/app/src/test/java/com/noop/data/AllSleepSessionsUnionTest.kt b/android/app/src/test/java/com/noop/data/AllSleepSessionsUnionTest.kt new file mode 100644 index 0000000000..f740ffcff8 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/AllSleepSessionsUnionTest.kt @@ -0,0 +1,108 @@ +package com.noop.data + +import java.lang.reflect.Proxy +import java.util.TimeZone +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Rhythm's read path (`RhythmRoute.loadRhythmData`) used to call `sleepSessionsMerged`, scoped to just + * (activeDeviceId, canonical "my-whoop") — an archived third strap's nights were invisible to it, despite + * doc comments claiming parity with Swift `Repository.allSleepSessions`. [WhoopRepository.allSleepSessionsUnion] + * is the actual twin: built from [WhoopRepository.sleepSessionsUnion] / [WhoopRepository.computedSleepSessionsUnion] + * (the full-registry unions, robust to a stale/wrong active id), with an imported-day-excludes-computed-day + * merge — no richness exception, unlike [WhoopRepository.mergeSleepRichness]. + * + * Pinned to a fixed UTC default zone so the local-wake-day keying is deterministic (mirrors + * [MergeSleepLocalDayTest]'s approach). + */ +class AllSleepSessionsUnionTest { + private val saved: TimeZone = TimeZone.getDefault() + + @Before fun setUtc() { TimeZone.setDefault(TimeZone.getTimeZone("UTC")) } + + @After fun restore() { TimeZone.setDefault(saved) } + + private fun device(id: String, status: String, addedAt: Long, brand: String = "WHOOP") = + PairedDeviceRow(id, brand, "test", null, null, "whoop", "hr", status, addedAt, addedAt) + + private val allWhoops = listOf( + device("whoop-old", "archived", 1), + device("my-whoop", "paired", 2), + device("whoop-new", "active", 3), + ) + + private fun proxyDao(rows: Map>): WhoopDao = + Proxy.newProxyInstance( + WhoopDao::class.java.classLoader, + arrayOf(WhoopDao::class.java), + ) { _, method, args -> + when (method.name) { + "pairedDevice", "activeDeviceId" -> null + "hasWhoop5RrSource" -> false + "pairedDevices" -> allWhoops + "sleepSessions" -> rows[args[0] as String].orEmpty() + else -> throw UnsupportedOperationException(method.name) + } + } as WhoopDao + + @Test + fun archivedThirdStrapNightSurfacesEvenWhenNeitherActiveNorCanonical() = runBlocking { + // A night banked only under "whoop-old" — neither the active strap ("whoop-new") nor the + // canonical import bucket ("my-whoop"). sleepSessionsMerged(deviceId) would have missed it + // entirely, since importedSourceIdsFor only ever unions (deviceId, "my-whoop"). + val archivedNight = SleepSession(deviceId = "whoop-old", startTs = 1_000, endTs = 30_000) + val repo = WhoopRepository(proxyDao(mapOf("whoop-old" to listOf(archivedNight)))) + + val sessions = repo.allSleepSessionsUnion("whoop-new", days = 4000) + + assertEquals(listOf(1_000L), sessions.map { it.startTs }) + } + + @Test + fun importedWinsOverComputedOnTheSameLocalWakeDay() = runBlocking { + // Both end within the same UTC day (2026-06-14); the computed twin has no richness exception + // to fall back on here, unlike mergeSleepRichness, so it must simply be excluded. + val dayEnd = 1_781_476_800L // 2026-06-14 22:40:00 UTC + val imported = SleepSession(deviceId = "whoop-new", startTs = dayEnd - 8 * 3_600L, endTs = dayEnd) + val computed = SleepSession( + deviceId = "whoop-new-noop", + startTs = dayEnd - 7 * 3_600L, + endTs = dayEnd - 3_600L, + ) + val repo = WhoopRepository( + proxyDao(mapOf("whoop-new" to listOf(imported), "whoop-new-noop" to listOf(computed))), + ) + + val sessions = repo.allSleepSessionsUnion("whoop-new", days = 4000) + + assertEquals("computed session on an already-imported local day must be dropped", 1, sessions.size) + assertEquals(imported.startTs, sessions.single().startTs) + } + + @Test + fun resultIsSortedAscendingSoLastIsTheMostRecentNight() = runBlocking { + val older = SleepSession(deviceId = "whoop-old", startTs = 1_000, endTs = 30_000) + val newest = SleepSession(deviceId = "whoop-new", startTs = 200_000, endTs = 230_000) + val middle = SleepSession(deviceId = "my-whoop", startTs = 100_000, endTs = 130_000) + // Insertion order deliberately NOT chronological: newest strap is queried first by + // rawWhoopSourceIdsFor (active-first ordering), so an unsorted union would put "newest" first. + val repo = WhoopRepository( + proxyDao( + mapOf( + "whoop-new" to listOf(newest), + "whoop-old" to listOf(older), + "my-whoop" to listOf(middle), + ), + ), + ) + + val sessions = repo.allSleepSessionsUnion("whoop-new", days = 4000) + + assertEquals(listOf(older.startTs, middle.startTs, newest.startTs), sessions.map { it.startTs }) + assertEquals(newest.startTs, sessions.last().startTs) + } +}