Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SleepSession> {
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-<uuid>" while
* imports + prior data live under "my-whoop", so a read pinned to a SINGLE id strands the other's
Expand Down
5 changes: 1 addition & 4 deletions android/app/src/main/java/com/noop/ui/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
175 changes: 175 additions & 0 deletions android/app/src/main/java/com/noop/ui/RhythmRoute.kt
Original file line number Diff line number Diff line change
@@ -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<RhythmScreener.NightRhythmSummary?>(null)
}
var windows by remember {
mutableStateOf<List<RhythmScreener.WindowResult>>(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<RhythmScreener.WindowResult>,
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<RhythmScreener.WindowResult>()
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<GravitySample>): 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
}
108 changes: 108 additions & 0 deletions android/app/src/test/java/com/noop/data/AllSleepSessionsUnionTest.kt
Original file line number Diff line number Diff line change
@@ -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<String, List<SleepSession>>): 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)
}
}
Loading