diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt index e44566c21..4316b1863 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt @@ -385,6 +385,7 @@ class FakeLibPebble : LibPebble { latestDataTimestamp = null, daysOfData = 0, weekdayTypicalSteps = emptyMap(), + weekdayTypicalSleep = emptyMap(), ) } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt index 8af9219cb..5eb32227b 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt @@ -19,6 +19,7 @@ import io.rebble.libpebblecommon.datalogging.HealthDataProcessor import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope import io.rebble.libpebblecommon.services.DailySleep import io.rebble.libpebblecommon.services.calculateHealthAverages +import io.rebble.libpebblecommon.services.computeAllWeekdayTypicalSleep import io.rebble.libpebblecommon.services.computeAllWeekdayTypicalSteps import io.rebble.libpebblecommon.services.decodeTypicalStepTotal import io.rebble.libpebblecommon.services.groupSleepSessions @@ -98,6 +99,7 @@ class Health( val weekdayTypicalSteps = computeAllWeekdayTypicalSteps(healthDao, today, timeZone) .mapValues { (_, payload) -> decodeTypicalStepTotal(payload) } + val weekdayTypicalSleep = computeAllWeekdayTypicalSleep(healthDao, today, timeZone) return HealthDebugStats( totalSteps30Days = averages.totalSteps, @@ -109,6 +111,7 @@ class Health( latestDataTimestamp = latestTimestamp, daysOfData = daysOfData, weekdayTypicalSteps = weekdayTypicalSteps, + weekdayTypicalSleep = weekdayTypicalSleep, ) } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/HealthRecords.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/HealthRecords.kt index 731e0c96d..e2690f57c 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/HealthRecords.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/HealthRecords.kt @@ -81,6 +81,10 @@ class RawOverlayRecord : StructMappable() { * [weekdayTypicalSteps] carries the per-weekday typical-step totals computed for the * `_steps` BlobDB rows; absent keys are weekdays where the user has insufficient * same-weekday history (we don't write a row for them, so the watch shows no comparison). + * + * [weekdayTypicalSleep] carries the four typical sleep values per weekday written into the + * tail of each `_sleepData` BlobDB row; absent keys are weekdays below the same + * minimum-history threshold. */ data class HealthDebugStats( val totalSteps30Days: Long, @@ -92,4 +96,5 @@ data class HealthDebugStats( val latestDataTimestamp: Long?, val daysOfData: Int, val weekdayTypicalSteps: Map = emptyMap(), + val weekdayTypicalSleep: Map = emptyMap(), ) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt index 93055c63f..74fac19e8 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -34,6 +34,11 @@ import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToLong +import kotlin.math.sin private val logger = Logger.withTag("HealthStatsSync") @@ -76,6 +81,12 @@ internal suspend fun updateHealthStatsInDatabase( payload = encodeUInt(averages.averageSleepSecondsPerDay.coerceAtLeast(0).toUInt()).toByteArray() )) + // Per-weekday typical sleep values (consumed by firmware activity_insights for the + // sleep summary card's typical-bedtime/wake display and the sleep notification's + // "X% above/below your typical" comparison). Computed once and looked up per weekday + // inside the loop below. + val sleepTypicals = computeAllWeekdayTypicalSleep(healthDao, today, timeZone) + // Compute weekly movement and sleep data (excluding today) val oldestDate = today.minus(DatePeriod(days = MOVEMENT_HISTORY_DAYS - 1)) val rangeStart = oldestDate.startOfDayEpochSeconds(timeZone) @@ -109,7 +120,8 @@ internal suspend fun updateHealthStatsInDatabase( dailySleep?.totalSleep?.toInt() ?: 0, dailySleep?.deepSleep?.toInt() ?: 0, dailySleep?.firstStart?.toInt() ?: 0, - dailySleep?.lastEnd?.toInt() ?: 0 + dailySleep?.lastEnd?.toInt() ?: 0, + typicals = sleepTypicals[day.dayOfWeek], ) stats.add(HealthStat( key = sleepKey, @@ -117,6 +129,29 @@ internal suspend fun updateHealthStatsInDatabase( )) } + // The loop above skips today's weekday by design (incomplete current-day daily fields + // shouldn't overwrite the watch's accelerometer-tracked values). But today's weekday's + // _sleepData blob is exactly what the firmware reads for "today's typical sleep" on the + // sleep summary card, so without this extra write its typicals stay stale for a day. + // Send a typicals-only blob with last_processed_timestamp=0; firmware's + // prv_notify_health_listeners gates the in-memory daily-metric update on a valid + // timestamp and bails out, so the daily fields=0 don't corrupt today's tracked values. + // health_db_insert still stores the blob, so the typicals are readable. + SLEEP_KEYS[today.dayOfWeek]?.let { todaySleepKey -> + val todaySleepPayloadData = sleepPayload( + dayStartEpochSec = 0L, + sleepDuration = 0, + deepSleepDuration = 0, + fallAsleepTime = 0, + wakeupTime = 0, + typicals = sleepTypicals[today.dayOfWeek], + ) + stats.add(HealthStat( + key = todaySleepKey, + payload = todaySleepPayloadData.toByteArray() + )) + } + // Per-weekday typical-step blobs (consumed by firmware activity_insights for the // "X% above/below typical" comparison in the end-of-day activity summary notification). val typicalsByWeekday = computeAllWeekdayTypicalSteps(healthDao, today, timeZone) @@ -142,7 +177,8 @@ private fun sleepPayload( sleepDuration: Int, deepSleepDuration: Int, fallAsleepTime: Int, - wakeupTime: Int + wakeupTime: Int, + typicals: WeekdaySleepTypicals? = null, ): UByteArray { val buffer = DataBuffer(SLEEP_PAYLOAD_SIZE).apply { setEndian(Endian.Little) } @@ -152,14 +188,15 @@ private fun sleepPayload( buffer.putUInt(deepSleepDuration.toUInt()) // deep_sleep_duration buffer.putUInt(fallAsleepTime.toUInt()) // fall_asleep_time buffer.putUInt(wakeupTime.toUInt()) // wakeup_time - buffer.putUInt(0u) // typical_sleep_duration (we don't calculate this yet) - buffer.putUInt(0u) // typical_deep_sleep_duration - buffer.putUInt(0u) // typical_fall_asleep_time - buffer.putUInt(0u) // typical_wakeup_time + buffer.putUInt((typicals?.sleepDurationSeconds ?: 0).toUInt()) // typical_sleep_duration + buffer.putUInt((typicals?.deepSleepDurationSeconds ?: 0).toUInt()) // typical_deep_sleep_duration + buffer.putUInt((typicals?.fallAsleepSecondsOfDay ?: 0).toUInt()) // typical_fall_asleep_time + buffer.putUInt((typicals?.wakeupSecondsOfDay ?: 0).toUInt()) // typical_wakeup_time logger.d { "HEALTH_STATS: Sleep payload - version=$HEALTH_STATS_VERSION, timestamp=$dayStartEpochSec, " + - "sleepDuration=$sleepDuration, deepSleep=$deepSleepDuration, fallAsleep=$fallAsleepTime, wakeup=$wakeupTime" + "sleepDuration=$sleepDuration, deepSleep=$deepSleepDuration, fallAsleep=$fallAsleepTime, wakeup=$wakeupTime, " + + "typicals=${typicals ?: "absent"}" } return buffer.array() @@ -284,6 +321,107 @@ internal suspend fun computeAllWeekdayTypicalSteps( return buildWeekdayTypicalsFromData(allData, timeZone) } +/** + * Reduces per-day sleep summaries into per-weekday typical-sleep values. + * + * For each weekday with at least MIN_DAYS_FOR_TYPICAL_SLEEP days that have at least one + * sleep session ≥ MIN_SLEEP_SESSION_SECONDS, emits a WeekdaySleepTypicals containing: + * - arithmetic-mean total/deep sleep durations (seconds) + * - circular-mean fall-asleep and wakeup seconds-of-local-day (0..86399) + * + * Below-threshold weekdays are omitted from the result. + */ +internal fun buildWeekdaySleepTypicalsFromData( + dailySleepByDate: Map, + timeZone: TimeZone, +): Map { + if (dailySleepByDate.isEmpty()) return emptyMap() + + data class NightStats( + val totalSec: Int, + val deepSec: Int, + val startSecOfDay: Int, + val endSecOfDay: Int, + ) + + val perWeekday = mutableMapOf>() + for ((date, dailySleep) in dailySleepByDate) { + if (dailySleep == null) continue + val qualifying = dailySleep.sessions.filter { it.totalSleep >= MIN_SLEEP_SESSION_SECONDS } + if (qualifying.isEmpty()) continue + val totalSec = qualifying.sumOf { it.totalSleep }.toInt() + val deepSec = qualifying.sumOf { it.deepSleep }.toInt() + val startSecOfDay = secondsOfDay(qualifying.first().start, timeZone) + val endSecOfDay = secondsOfDay(qualifying.last().end, timeZone) + perWeekday + .getOrPut(date.dayOfWeek) { mutableListOf() } + .add(NightStats(totalSec, deepSec, startSecOfDay, endSecOfDay)) + } + + val result = mutableMapOf() + for ((wd, nights) in perWeekday) { + if (nights.size < MIN_DAYS_FOR_TYPICAL_SLEEP) continue + val sleepMean = (nights.sumOf { it.totalSec.toLong() } / nights.size).toInt() + val deepMean = (nights.sumOf { it.deepSec.toLong() } / nights.size).toInt() + val fallAsleepMean = circularMeanSecondsOfDay(nights.map { it.startSecOfDay }) + val wakeupMean = circularMeanSecondsOfDay(nights.map { it.endSecOfDay }) + result[wd] = WeekdaySleepTypicals(sleepMean, deepMean, fallAsleepMean, wakeupMean) + } + return result +} + +private fun secondsOfDay(epochSec: Long, timeZone: TimeZone): Int { + val ldt = kotlinx.datetime.Instant.fromEpochSeconds(epochSec).toLocalDateTime(timeZone) + return ldt.hour * 3600 + ldt.minute * 60 + ldt.second +} + +private fun circularMeanSecondsOfDay(values: List): Int { + if (values.isEmpty()) return 0 + val twoPi = 2 * PI + val secondsPerDay = SECONDS_PER_DAY.toDouble() + var sumX = 0.0 + var sumY = 0.0 + for (v in values) { + val angle = v / secondsPerDay * twoPi + sumX += cos(angle) + sumY += sin(angle) + } + val meanAngle = atan2(sumY, sumX) // returns [-π, π] + val secs = (meanAngle / twoPi * secondsPerDay).roundToLong() + return (((secs % SECONDS_PER_DAY) + SECONDS_PER_DAY) % SECONDS_PER_DAY).toInt() +} + +/** + * Pulls the last [TYPICAL_SLEEP_HISTORY_WEEKS] weeks of same-weekday DailySleep + * (via [fetchAndGroupDailySleep] per past matching date) and reduces them via + * [buildWeekdaySleepTypicalsFromData]. + * + * Returns one entry per weekday with at least [MIN_DAYS_FOR_TYPICAL_SLEEP] qualifying days. + * Empty map when no weekday clears the threshold. + * + * Issues 49 DAO queries (7 weekdays × 7 weeks); each is a single ~32h `getOverlayEntries`. + * Runs once daily as part of [updateHealthStatsInDatabase]. + */ +internal suspend fun computeAllWeekdayTypicalSleep( + healthDao: HealthDao, + today: LocalDate, + timeZone: TimeZone, +): Map { + val dailySleepByDate = mutableMapOf() + for (wd in DayOfWeek.entries) { + var ref = today.minus(DatePeriod(days = 1)) + while (ref.dayOfWeek != wd) { + ref = ref.minus(DatePeriod(days = 1)) + } + for (weeksAgo in 0 until TYPICAL_SLEEP_HISTORY_WEEKS) { + val pastDate = ref.minus(DatePeriod(days = 7 * weeksAgo)) + val dayStart = pastDate.atStartOfDayIn(timeZone).epochSeconds + dailySleepByDate[pastDate] = fetchAndGroupDailySleep(healthDao, dayStart, timeZone) + } + } + return buildWeekdaySleepTypicalsFromData(dailySleepByDate, timeZone) +} + // Extension functions private fun Long.kilocalories(): Long = this / 1000L @@ -354,3 +492,15 @@ private val STEP_TYPICAL_KEYS = mapOf( DayOfWeek.SATURDAY to "saturday_steps", DayOfWeek.SUNDAY to "sunday_steps", ) + +data class WeekdaySleepTypicals( + val sleepDurationSeconds: Int, + val deepSleepDurationSeconds: Int, + val fallAsleepSecondsOfDay: Int, + val wakeupSecondsOfDay: Int, +) + +private const val MIN_DAYS_FOR_TYPICAL_SLEEP = 2 +private const val TYPICAL_SLEEP_HISTORY_WEEKS = 7 +private const val MIN_SLEEP_SESSION_SECONDS = 1800L +private const val SECONDS_PER_DAY = 86_400 diff --git a/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt b/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt index d35141a92..77b272fbf 100644 --- a/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt +++ b/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt @@ -77,6 +77,208 @@ class HealthStatsSyncTest { assertEquals(0xFFFF, readUShortLE(payload, 95), "slot 95 must be UNKNOWN sentinel") } + @Test + fun buildWeekdaySleepTypicalsFromData_emptyInput_returnsEmptyMap() { + val result = buildWeekdaySleepTypicalsFromData(emptyMap(), TimeZone.UTC) + assertTrue(result.isEmpty(), "empty input should produce empty map, got keys=${result.keys}") + } + + @Test + fun buildWeekdaySleepTypicalsFromData_singleMatchingDay_skipsWeekday() { + val monday = LocalDate(2026, 1, 5) + val mondayStart = monday.atStartOfDayIn(TimeZone.UTC).epochSeconds + val sleep = nightSleep( + startEpochSec = mondayStart - 3600, // Sun 23:00 + endEpochSec = mondayStart + 7 * 3600, // Mon 07:00 + ) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(monday to sleep), + TimeZone.UTC, + ) + + assertFalse( + result.containsKey(DayOfWeek.MONDAY), + "Monday should be absent with only 1 matching day, got keys=${result.keys}", + ) + } + + @Test + fun buildWeekdaySleepTypicalsFromData_twoMondays_producesTypicals() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + + // Both Mondays: bedtime 23:00 prev day, wake 07:00 Monday, 8h sleep + val sleep1 = nightSleep(mon1Start - 3600, mon1Start + 7 * 3600) + val sleep2 = nightSleep(mon2Start - 3600, mon2Start + 7 * 3600) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to sleep1, mon2 to sleep2), + tz, + ) + + val monday = result[DayOfWeek.MONDAY] + assertNotNull(monday, "Monday should be present; got keys=${result.keys}") + assertEquals(8 * 3600, monday.sleepDurationSeconds, "8h = 28800s") + assertEquals(0, monday.deepSleepDurationSeconds, "no deep sleep in fixture") + assertEquals(23 * 3600, monday.fallAsleepSecondsOfDay, "bedtime 23:00 = 82800s") + assertEquals(7 * 3600, monday.wakeupSecondsOfDay, "wake 07:00 = 25200s") + } + + @Test + fun buildWeekdaySleepTypicalsFromData_napOnlyDay_filteredOut() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + + // mon1: 25-min nap (below threshold). mon2: full 8h night. + val nap = nightSleep( + startEpochSec = mon1Start + 14 * 3600, + endEpochSec = mon1Start + 14 * 3600 + 1500, + totalSec = 1500L, + ) + val night = nightSleep(mon2Start - 3600, mon2Start + 7 * 3600) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to nap, mon2 to night), + tz, + ) + + // mon1's only session is filtered out (<1800s), so only mon2 qualifies — that's 1 < threshold + assertFalse( + result.containsKey(DayOfWeek.MONDAY), + "Monday should be absent; nap-only day filtered, only 1 qualifying day remains. Got keys=${result.keys}", + ) + } + + @Test + fun buildWeekdaySleepTypicalsFromData_circularMean_handlesMidnightWrap() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + + // Bedtimes 23:00 (1h before Monday) and 01:00 (1h into Monday). Each session is 6h long. + val sleep1 = nightSleep(mon1Start - 3600, mon1Start + 5 * 3600) // 23:00 → 05:00 + val sleep2 = nightSleep(mon2Start + 3600, mon2Start + 7 * 3600) // 01:00 → 07:00 + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to sleep1, mon2 to sleep2), + tz, + ) + + val monday = result[DayOfWeek.MONDAY] + assertNotNull(monday, "Monday should be present; got keys=${result.keys}") + // Circular mean of 23:00 (82800s) and 01:00 (3600s) should be ~00:00 (0 or 86400-ε), + // NOT the arithmetic mean (43200, noon). Allow ±60s tolerance for FP rounding. + val fallAsleep = monday.fallAsleepSecondsOfDay + val isNearMidnight = fallAsleep <= 60 || fallAsleep >= 86400 - 60 + assertTrue( + isNearMidnight, + "Expected fallAsleep near midnight (~0 or ~86400), got $fallAsleep — circular mean broken?", + ) + } + + @Test + fun buildWeekdaySleepTypicalsFromData_perWeekdayIndependence_onlyEligibleWeekdaysReturned() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val tue = LocalDate(2026, 1, 6) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + val tueStart = tue.atStartOfDayIn(tz).epochSeconds + + val mondaySleep1 = nightSleep(mon1Start - 3600, mon1Start + 7 * 3600) + val mondaySleep2 = nightSleep(mon2Start - 3600, mon2Start + 7 * 3600) + val tuesdaySleep = nightSleep(tueStart - 3600, tueStart + 7 * 3600) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to mondaySleep1, mon2 to mondaySleep2, tue to tuesdaySleep), + tz, + ) + + assertTrue(result.containsKey(DayOfWeek.MONDAY), "Monday should be present (2 qualifying days)") + assertFalse( + result.containsKey(DayOfWeek.TUESDAY), + "Tuesday should be absent (only 1 qualifying day, below threshold)", + ) + } + + @Test + fun buildWeekdaySleepTypicalsFromData_splitSleep_usesLastSessionEnd() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + + // Split-sleep night: first session 23:00 → 06:30 (7.5h). Second 06:45 → 07:30 (45min). + // Both >30min; both qualify. last().end is 07:30 = 27000s. + fun split(dayStart: Long) = nightSleepMulti( + listOf( + session(dayStart - 3600, dayStart + 6 * 3600 + 1800), // 23:00 → 06:30 + session(dayStart + 6 * 3600 + 2700, dayStart + 7 * 3600 + 1800), // 06:45 → 07:30 + ) + ) + val mon1Sleep = split(mon1Start) + val mon2Sleep = split(mon2Start) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to mon1Sleep, mon2 to mon2Sleep), + tz, + ) + + val monday = result[DayOfWeek.MONDAY] + assertNotNull(monday) + assertEquals( + 7 * 3600 + 1800, monday.wakeupSecondsOfDay, + "Wake derives from last qualifying session's end (07:30 = 27000s), not first's", + ) + } + + @Test + fun buildWeekdaySleepTypicalsFromData_mixedValidity_filtersShortSession() { + val tz = TimeZone.UTC + val mon1 = LocalDate(2026, 1, 5) + val mon2 = LocalDate(2026, 1, 12) + val mon1Start = mon1.atStartOfDayIn(tz).epochSeconds + val mon2Start = mon2.atStartOfDayIn(tz).epochSeconds + + // Each Monday has a 6h night PLUS a 25-min nap. Nap is filtered; bedtime/wake derive + // from the 6h night session only. + // Night: 22:00 → 04:00 (6h, totalSec=21600). Nap: 13:00 → 13:25 (25min, totalSec=1500). + // Sessions are chronologically ordered (night starts at dayStart - 2*3600, nap at dayStart + 13*3600). + fun mixed(dayStart: Long) = nightSleepMulti( + listOf( + session(dayStart - 2 * 3600, dayStart + 4 * 3600, totalSec = 6L * 3600), // 22:00 prev → 04:00 (6h) + session( + dayStart + 13 * 3600, dayStart + 13 * 3600 + 1500, + totalSec = 1500L, + ), // 13:00 → 13:25 nap + ) + ) + val mon1Sleep = mixed(mon1Start) + val mon2Sleep = mixed(mon2Start) + + val result = buildWeekdaySleepTypicalsFromData( + mapOf(mon1 to mon1Sleep, mon2 to mon2Sleep), + tz, + ) + + val monday = result[DayOfWeek.MONDAY] + assertNotNull(monday) + assertEquals(6 * 3600, monday.sleepDurationSeconds, "Only the 6h session contributes (nap filtered)") + assertEquals(22 * 3600, monday.fallAsleepSecondsOfDay, "Bedtime 22:00 = 79200s, from 6h session's start") + assertEquals(4 * 3600, monday.wakeupSecondsOfDay, "Wake 04:00 = 14400s, from 6h session's end") + } + @Test fun buildWeekdayTypicalsFromData_partialSlotCoverage_avgIsPerSlotNotPerDay() { // Five Mondays: each contributes 100 steps in slot 60. Two more Mondays exist @@ -121,3 +323,30 @@ private fun row(timestamp: Long, steps: Int) = HealthDataEntity( activeGramCalories = 0, distanceCm = 0, ) + +private fun session( + startEpochSec: Long, + endEpochSec: Long, + totalSec: Long = endEpochSec - startEpochSec, + deepSec: Long = 0L, +): SleepSession = SleepSession( + start = startEpochSec, + end = endEpochSec, + totalSleep = totalSec, + deepSleep = deepSec, + intervals = mutableListOf(), +) + +private fun nightSleepMulti(sessions: List): DailySleep = + DailySleep( + sessions = sessions, + totalSleep = sessions.sumOf { it.totalSleep }, + deepSleep = sessions.sumOf { it.deepSleep }, + ) + +private fun nightSleep( + startEpochSec: Long, + endEpochSec: Long, + totalSec: Long = endEpochSec - startEpochSec, + deepSec: Long = 0L, +): DailySleep = nightSleepMulti(listOf(session(startEpochSec, endEpochSec, totalSec, deepSec))) diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthStatsDialog.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthStatsDialog.kt index ef13cff7d..f5d915578 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthStatsDialog.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthStatsDialog.kt @@ -201,6 +201,37 @@ fun HealthStatsDialog(libPebble: LibPebble, onDismissRequest: () -> Unit) { ) } } + + Spacer(Modifier.height(4.dp)) + + Text( + "Typical sleep by weekday", + style = MaterialTheme.typography.bodyMedium, + ) + for (wd in DayOfWeek.entries) { + val typ = s.weekdayTypicalSleep[wd] + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + wd.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + typ?.let { + "${formatHm(it.sleepDurationSeconds)} / ${formatHm(it.deepSleepDurationSeconds)} ${formatClock(it.fallAsleepSecondsOfDay)}→${formatClock(it.wakeupSecondsOfDay)}" + } ?: "--", + style = MaterialTheme.typography.bodySmall, + color = if (typ != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } } } else { Text( @@ -215,4 +246,16 @@ fun HealthStatsDialog(libPebble: LibPebble, onDismissRequest: () -> Unit) { } expect fun Double.format(digits: Int): String -fun Float.format(digits: Int): String = toDouble().format(digits) \ No newline at end of file +fun Float.format(digits: Int): String = toDouble().format(digits) + +private fun formatHm(totalSeconds: Int): String { + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + return "${h}h${m.toString().padStart(2, '0')}m" +} + +private fun formatClock(secondsOfDay: Int): String { + val h = secondsOfDay / 3600 + val m = (secondsOfDay % 3600) / 60 + return "${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}" +} \ No newline at end of file diff --git a/pebble/src/iosMain/kotlin/coredevices/pebble/actions/watch/HealthStats.kt b/pebble/src/iosMain/kotlin/coredevices/pebble/actions/watch/HealthStats.kt index be801bfb9..80e9d1e22 100644 --- a/pebble/src/iosMain/kotlin/coredevices/pebble/actions/watch/HealthStats.kt +++ b/pebble/src/iosMain/kotlin/coredevices/pebble/actions/watch/HealthStats.kt @@ -10,5 +10,8 @@ fun healthDebugStatsToJson(stats: HealthDebugStats): String { val latestTs = stats.latestDataTimestamp?.toString() ?: "null" val typicalSteps = stats.weekdayTypicalSteps.entries .joinToString(prefix = "{", postfix = "}") { (wd, total) -> "\"$wd\":$total" } - return """{"totalSteps30Days":${stats.totalSteps30Days},"averageStepsPerDay":${stats.averageStepsPerDay},"totalSleepSeconds30Days":${stats.totalSleepSeconds30Days},"averageSleepSecondsPerDay":${stats.averageSleepSecondsPerDay},"todaySteps":${stats.todaySteps},"lastNightSleepHours":$lastNight,"latestDataTimestamp":$latestTs,"daysOfData":${stats.daysOfData},"weekdayTypicalSteps":$typicalSteps}""" + val typicalSleep = stats.weekdayTypicalSleep.entries.joinToString(prefix = "{", postfix = "}") { (wd, v) -> + "\"$wd\":{\"sleepDurationSeconds\":${v.sleepDurationSeconds},\"deepSleepDurationSeconds\":${v.deepSleepDurationSeconds},\"fallAsleepSecondsOfDay\":${v.fallAsleepSecondsOfDay},\"wakeupSecondsOfDay\":${v.wakeupSecondsOfDay}}" + } + return """{"totalSteps30Days":${stats.totalSteps30Days},"averageStepsPerDay":${stats.averageStepsPerDay},"totalSleepSeconds30Days":${stats.totalSleepSeconds30Days},"averageSleepSecondsPerDay":${stats.averageSleepSecondsPerDay},"todaySteps":${stats.todaySteps},"lastNightSleepHours":$lastNight,"latestDataTimestamp":$latestTs,"daysOfData":${stats.daysOfData},"weekdayTypicalSteps":$typicalSteps,"weekdayTypicalSleep":$typicalSleep}""" }