From cb27fc27c12d5b660604083ea25c46e2bd09e3ad Mon Sep 17 00:00:00 2001 From: Andreas Grasser Date: Fri, 18 Sep 2026 22:35:42 +0200 Subject: [PATCH 1/2] Android: implement Rhythm data pipeline to match iOS behavior The Rhythm screen was fully implemented but showed no data because it was hardcoded with empty inputs (night = null, windows = emptyList()). This commit adds the missing data-loading layer (RhythmRoute) that mirrors macOS RhythmHost.load(): - Add anyRegisteredWhoopId() to WhoopRepository to load data from any registered WHOOP device, not just the currently active one. This allows Rhythm to work offline with historical data. - Use sleepSessionsMerged() to search across both imported (raw) and computed ("-noop") sleep sessions, matching Swift allSleepSessions() which reads from both namespaces. - Select the most recent session via maxByOrNull { it.endTs } instead of lastOrNull(), ensuring we load the actual most recent sleep data rather than the last item in an unsorted list. - Window the night into 5-minute slices, gate each on stillness, and run the pure RhythmScreener engine over each window. - Use 14-day lookback window to match iOS allSleepSessions(days: 14). Rhythm now displays data when sleep sessions exist in the database, even when no strap is currently connected. --- .../java/com/noop/data/WhoopRepository.kt | 19 ++ .../app/src/main/java/com/noop/ui/AppRoot.kt | 5 +- .../src/main/java/com/noop/ui/RhythmRoute.kt | 183 ++++++++++++++++++ 3 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/noop/ui/RhythmRoute.kt 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..7a2bce1869 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1646,6 +1646,25 @@ class WhoopRepository( dao.pairedDevices().filter { it.brand.equals("WHOOP", ignoreCase = true) }.map { it.id }, ) + /** + * Returns the ID of any registered WHOOP device, preferring the active device if available. + * Falls back to the first registered WHOOP if no device is currently active. Returns null if no + * WHOOP device has ever been registered. + * + * This enables features like Rhythm to load historical data even when no strap is currently connected, + * matching Swift `allSleepSessions` behavior which reads across all registered devices. + */ + suspend fun anyRegisteredWhoopId(activeDeviceId: String? = null): String? { + // Prefer the active device if it's set and non-empty + if (!activeDeviceId.isNullOrEmpty()) return activeDeviceId + + // Fall back to any registered WHOOP device + val whoopIds = dao.pairedDevices() + .filter { it.brand.equals("WHOOP", ignoreCase = true) } + .map { it.id } + return whoopIds.firstOrNull() + } + suspend fun sleepSessionsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.sleepSessions(deviceId, from, to, limit) 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..e94cc406fc --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/RhythmRoute.kt @@ -0,0 +1,183 @@ +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 +) { + // Use any registered WHOOP device, not just the currently active one. + // This allows Rhythm to load historical data even when no strap is connected, + // matching Swift `allSleepSessions` behavior. + val deviceId = viewModel.repo.anyRegisteredWhoopId(viewModel.activeStrapId) ?: run { + // No WHOOP device ever registered — stay in GATHERING_DATA state + onLoaded(null, emptyList(), RhythmEmptyState.GATHERING_DATA) + return + } + val repo = viewModel.repo + + // Step 1: Load the most recent sleep session (last 14 days) + // Use sleepSessionsMerged to search across both imported (raw) and computed ("-noop") sessions, + // matching Swift `allSleepSessions` behavior which reads from both namespaces. + val now = System.currentTimeMillis() / 1000L + val from = now - 14 * 86_400L + val sessions = runCatching { + repo.sleepSessionsMerged(deviceId, from, now, limit = 1000) + }.getOrDefault(emptyList()) + + // Sort by endTs descending to get the most recent session, not just the last in the list + val lastSleep = sessions.maxByOrNull { it.endTs } ?: 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 +} From 098230de87a08ec3bfb884b7da49561d8438cfee Mon Sep 17 00:00:00 2001 From: Andreas Grasser Date: Tue, 22 Sep 2026 14:15:57 +0200 Subject: [PATCH 2/2] Add function allSleepSessionsUnion and three Tests for the EGC backend --- .../java/com/noop/data/WhoopRepository.kt | 45 +++++--- .../src/main/java/com/noop/ui/RhythmRoute.kt | 24 ++-- .../noop/data/AllSleepSessionsUnionTest.kt | 108 ++++++++++++++++++ 3 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 android/app/src/test/java/com/noop/data/AllSleepSessionsUnionTest.kt 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 7a2bce1869..387ca29788 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1646,25 +1646,6 @@ class WhoopRepository( dao.pairedDevices().filter { it.brand.equals("WHOOP", ignoreCase = true) }.map { it.id }, ) - /** - * Returns the ID of any registered WHOOP device, preferring the active device if available. - * Falls back to the first registered WHOOP if no device is currently active. Returns null if no - * WHOOP device has ever been registered. - * - * This enables features like Rhythm to load historical data even when no strap is currently connected, - * matching Swift `allSleepSessions` behavior which reads across all registered devices. - */ - suspend fun anyRegisteredWhoopId(activeDeviceId: String? = null): String? { - // Prefer the active device if it's set and non-empty - if (!activeDeviceId.isNullOrEmpty()) return activeDeviceId - - // Fall back to any registered WHOOP device - val whoopIds = dao.pairedDevices() - .filter { it.brand.equals("WHOOP", ignoreCase = true) } - .map { it.id } - return whoopIds.firstOrNull() - } - suspend fun sleepSessionsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.sleepSessions(deviceId, from, to, limit) @@ -2066,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/RhythmRoute.kt b/android/app/src/main/java/com/noop/ui/RhythmRoute.kt index e94cc406fc..39189011df 100644 --- a/android/app/src/main/java/com/noop/ui/RhythmRoute.kt +++ b/android/app/src/main/java/com/noop/ui/RhythmRoute.kt @@ -65,27 +65,19 @@ private suspend fun loadRhythmData( emptyReason: RhythmEmptyState ) -> Unit ) { - // Use any registered WHOOP device, not just the currently active one. - // This allows Rhythm to load historical data even when no strap is connected, - // matching Swift `allSleepSessions` behavior. - val deviceId = viewModel.repo.anyRegisteredWhoopId(viewModel.activeStrapId) ?: run { - // No WHOOP device ever registered — stay in GATHERING_DATA state - onLoaded(null, emptyList(), RhythmEmptyState.GATHERING_DATA) - return - } + // 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) - // Use sleepSessionsMerged to search across both imported (raw) and computed ("-noop") sessions, - // matching Swift `allSleepSessions` behavior which reads from both namespaces. - val now = System.currentTimeMillis() / 1000L - val from = now - 14 * 86_400L + // 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.sleepSessionsMerged(deviceId, from, now, limit = 1000) + repo.allSleepSessionsUnion(deviceId, days = 14) }.getOrDefault(emptyList()) - // Sort by endTs descending to get the most recent session, not just the last in the list - val lastSleep = sessions.maxByOrNull { it.endTs } ?: run { + // 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 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) + } +}