From 7259a9e40cc63de3974d1e81c25c29175506d9f5 Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 05:37:41 +0200 Subject: [PATCH 1/6] HealthStatsSync: add WeekdaySleepTypicals type and constants Adds the four-field data class and file-private constants (history weeks, min-history threshold, min-session-seconds filter, seconds-per-day) that the upcoming typical-sleep writer will use. No behavior change yet. --- .../libpebblecommon/services/HealthStatsSync.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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..838aa5fd3 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -354,3 +354,15 @@ private val STEP_TYPICAL_KEYS = mapOf( DayOfWeek.SATURDAY to "saturday_steps", DayOfWeek.SUNDAY to "sunday_steps", ) + +internal 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 From 95dac2da6827a5191456c8c2901b1418f2220781 Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 13:28:25 +0200 Subject: [PATCH 2/6] HealthStatsSync: stub buildWeekdaySleepTypicalsFromData Adds the helper signature, empty-input fast path, and test fixtures (session, nightSleepMulti, nightSleep) that subsequent tests will use. Subsequent commits fill in the per-weekday reducer test-by-test. --- .../services/HealthStatsSync.kt | 18 ++++++++++ .../services/HealthStatsSyncTest.kt | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 838aa5fd3..1aa17b74a 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -284,6 +284,24 @@ 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() + return emptyMap() +} + // Extension functions private fun Long.kilocalories(): Long = this / 1000L 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..8b2734dcc 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,12 @@ 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 buildWeekdayTypicalsFromData_partialSlotCoverage_avgIsPerSlotNotPerDay() { // Five Mondays: each contributes 100 steps in slot 60. Two more Mondays exist @@ -121,3 +127,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))) From 885d933ca58a082010f074cf09702dfecf7e02bd Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 13:35:41 +0200 Subject: [PATCH 3/6] HealthStatsSync: implement per-weekday typical-sleep reducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements buildWeekdaySleepTypicalsFromData: filters sessions ≥30min, partitions by weekday, computes arithmetic-mean durations and circular-mean bedtime/wake (so 23:00+01:00 averages to ~00:00, not noon). Weekdays below the 2-day minimum-history threshold are omitted. Adds the secondsOfDay and circularMeanSecondsOfDay helpers. Tests cover: empty input, below-threshold skip, two-day basic case, nap-only filtering, the critical circular-mean midnight-wrap case, per-weekday independence, split-sleep last-session-wake, and mixed-validity within a single day. --- .../services/HealthStatsSync.kt | 59 +++++- .../services/HealthStatsSyncTest.kt | 196 ++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) 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 1aa17b74a..a1ac2b2de 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") @@ -299,7 +304,59 @@ internal fun buildWeekdaySleepTypicalsFromData( timeZone: TimeZone, ): Map { if (dailySleepByDate.isEmpty()) return emptyMap() - 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() } // Extension functions 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 8b2734dcc..77b272fbf 100644 --- a/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt +++ b/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/services/HealthStatsSyncTest.kt @@ -83,6 +83,202 @@ class HealthStatsSyncTest { 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 From f31f3b72a3f7c64bdf216e0a3cfd9b9098fdd9cc Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 13:46:51 +0200 Subject: [PATCH 4/6] HealthStatsSync: write per-weekday typical sleep values to BlobDB Wires computeAllWeekdayTypicalSleep into updateHealthStatsInDatabase so the four typical_* fields of each _sleepData blob (which have been hardcoded to 0u since the writer was first added) now carry real values: arithmetic-mean total/deep sleep durations and circular-mean bedtime/wake, computed over the user's past 7 weeks of same-weekday history. This populates the firmware fields read by health_db_get_typical_value (blob_db/health_db.c) and consumed by the sleep summary card (apps/system/health/data.c) and the activity_insights sleep notification path. --- .../services/HealthStatsSync.kt | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) 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 a1ac2b2de..4db609b59 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -81,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) @@ -114,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, @@ -147,7 +154,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) } @@ -157,14 +165,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() @@ -359,6 +368,37 @@ private fun circularMeanSecondsOfDay(values: List): Int { 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 From 32e3ba5f5fe30955aae9a2449ec65af3028506a7 Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 14:48:24 +0200 Subject: [PATCH 5/6] HealthStatsSync: refresh today's weekday _sleepData typicals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing daily 6-day loop deliberately skips today (incomplete current-day daily fields shouldn't overwrite the watch's accelerometer-tracked values), but the firmware reads `_sleepData` typicals on the sleep summary card. Without this extra write the typicals for today's weekday stay stale for a day after the new typical-sleep code starts running. After the daily loop, add a typicals-only blob for today's weekday 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 daily fields=0 don't corrupt today's tracked values, but health_db_insert still stores the blob — making the typicals readable via health_db_get_typical_value. This brings sleep typicals in line with the step typicals, which already cover all 7 weekdays via their separate per-weekday loop. --- .../services/HealthStatsSync.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 4db609b59..728a5d4fd 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -129,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) From 9aeea865b32a2a8704c12a7578d59f7eec25bf6d Mon Sep 17 00:00:00 2001 From: ave Date: Sat, 16 May 2026 15:15:06 +0200 Subject: [PATCH 6/6] HealthStatsDialog: show per-weekday typical sleep values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the typical-steps debug section added on fix/typical-steps, showing the four values the watch reads from each _sleepData blob's typical tail: duration / deep duration / bedtime → wakeup. Weekdays below the 2-day minimum-history threshold show "--". WeekdaySleepTypicals visibility bumped from internal to public so the new HealthDebugStats field can expose it across modules. The data class is just four Ints and was always going to be reachable by the debug dialog by design. --- .../connection/FakeLibPebble.kt | 1 + .../rebble/libpebblecommon/health/Health.kt | 3 ++ .../libpebblecommon/health/HealthRecords.kt | 5 +++ .../services/HealthStatsSync.kt | 2 +- .../pebble/ui/HealthStatsDialog.kt | 45 ++++++++++++++++++- .../pebble/actions/watch/HealthStats.kt | 5 ++- 6 files changed, 58 insertions(+), 3 deletions(-) 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 728a5d4fd..74fac19e8 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/HealthStatsSync.kt @@ -493,7 +493,7 @@ private val STEP_TYPICAL_KEYS = mapOf( DayOfWeek.SUNDAY to "sunday_steps", ) -internal data class WeekdaySleepTypicals( +data class WeekdaySleepTypicals( val sleepDurationSeconds: Int, val deepSleepDurationSeconds: Int, val fallAsleepSecondsOfDay: Int, 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}""" }