From 2c364b9bd357856b250727cc8edc1901542b47e0 Mon Sep 17 00:00:00 2001 From: voiceagentscc Date: Wed, 4 Nov 2026 13:10:11 -0500 Subject: [PATCH] fix(sleep): prevent epoch dates on Sleep startup (#137) --- .../com/pulseloop/ui/screens/SleepScreen.kt | 14 +++- .../com/pulseloop/ui/viewmodels/ViewModels.kt | 4 +- .../pulseloop/ui/screens/SleepDayDateTest.kt | 79 +++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/pulseloop/ui/screens/SleepDayDateTest.kt diff --git a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt index 837b4367..79e5c00e 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt @@ -874,6 +874,7 @@ private fun SleepDurationHistogram( @Composable private fun SleepDayNavHeader(state: SleepViewModel.SleepState, viewModel: SleepViewModel?) { var showPicker by remember { mutableStateOf(false) } + val shownDayMillis = resolvedSleepDayMillis(state.shownDayMillis) val canOlder = state.dayOffset < state.maxDayOffset val canNewer = state.dayOffset > 0 @@ -892,10 +893,10 @@ private fun SleepDayNavHeader(state: SleepViewModel.SleepState, viewModel: Sleep horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - dayLabel(state.shownDayMillis), + dayLabel(shownDayMillis), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = PulseColors.textPrimary, ) - val sub = daySubLabel(state.shownDayMillis) + val sub = daySubLabel(shownDayMillis) Text( sub.ifEmpty { " " }, fontSize = 11.sp, fontWeight = FontWeight.Medium, @@ -907,7 +908,7 @@ private fun SleepDayNavHeader(state: SleepViewModel.SleepState, viewModel: Sleep if (showPicker) { SleepDayPickerDialog( - shownDayMillis = state.shownDayMillis, + shownDayMillis = shownDayMillis, maxDayOffset = state.maxDayOffset, onDismiss = { showPicker = false }, onPick = { viewModel?.jumpToDay(it); showPicker = false }, @@ -915,6 +916,13 @@ private fun SleepDayNavHeader(state: SleepViewModel.SleepState, viewModel: Sleep } } +/** Missing day keys must never reach the header or date picker as Unix-epoch dates. */ +internal fun resolvedSleepDayMillis( + dayMillis: Long, + nowMs: Long = System.currentTimeMillis(), + zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), +): Long = if (dayMillis > 0L) dayMillis else com.pulseloop.util.TimeUtil.referenceNightLocal(nowMs, zone) + @Composable private fun NavChevron( icon: androidx.compose.ui.graphics.vector.ImageVector, diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index a2b215b7..13637d7c 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -224,8 +224,8 @@ class SleepViewModel( val dayOffset: Int = 0, /** How far back the stepper may page (0 locks to today when there's no history). */ val maxDayOffset: Int = 0, - /** Local-midnight key of the shown day, for the nav header label. */ - val shownDayMillis: Long = 0L, + /** Local-midnight key of the shown day; valid even before the first database emission. */ + val shownDayMillis: Long = TimeUtil.referenceNightLocal(), val recentSessions: List = emptyList(), // Aggregate view (week/month/year) val rangeSessions: List = emptyList(), diff --git a/app/src/test/java/com/pulseloop/ui/screens/SleepDayDateTest.kt b/app/src/test/java/com/pulseloop/ui/screens/SleepDayDateTest.kt new file mode 100644 index 00000000..1b982c10 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/screens/SleepDayDateTest.kt @@ -0,0 +1,79 @@ +package com.pulseloop.ui.screens + +import com.pulseloop.ui.viewmodels.SleepViewModel +import com.pulseloop.util.TimeUtil +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.TimeZone + +class SleepDayDateTest { + private val zones = listOf("America/New_York", "UTC", "Asia/Tokyo").map(ZoneId::of) + + private fun at(dateTime: String, zone: ZoneId): Long = + LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() + + private fun midnight(date: String, zone: ZoneId): Long = + LocalDate.parse(date).atStartOfDay(zone).toInstant().toEpochMilli() + + @Test + fun `initial empty sleep state has a reference date without database or ring data`() { + val originalZone = TimeZone.getDefault() + try { + for (zone in zones) { + TimeZone.setDefault(TimeZone.getTimeZone(zone)) + val before = TimeUtil.referenceNightLocal() + val state = SleepViewModel.SleepState() + val after = TimeUtil.referenceNightLocal() + + assertTrue("Initial date in $zone", state.shownDayMillis in setOf(before, after)) + assertTrue(state.daySessions.isEmpty()) + assertEquals(state.shownDayMillis, resolvedSleepDayMillis(state.shownDayMillis)) + } + } finally { + TimeZone.setDefault(originalZone) + } + } + + @Test + fun `zero and negative dates resolve to today from 4am in every zone`() { + for (zone in zones) { + val now = at("2026-11-04T04:00:00", zone) + for (invalid in listOf(0L, -1L, Long.MIN_VALUE)) { + assertEquals("Invalid $invalid in $zone", midnight("2026-11-04", zone), + resolvedSleepDayMillis(invalid, now, zone)) + } + } + } + + @Test + fun `missing dates before 4am resolve to the previous reference night`() { + for (zone in zones) { + val now = at("2026-11-04T03:59:59", zone) + assertEquals(midnight("2026-11-03", zone), resolvedSleepDayMillis(0L, now, zone)) + } + } + + @Test + fun `fallback respects local midnight across daylight saving transitions`() { + val zone = ZoneId.of("America/New_York") + for ((now, expectedDay) in listOf( + "2026-03-09T01:30:00" to "2026-03-08", + "2026-11-02T01:30:00" to "2026-11-01", + )) { + assertEquals(midnight(expectedDay, zone), resolvedSleepDayMillis(0L, at(now, zone), zone)) + } + } + + @Test + fun `valid historical selection is preserved for header and picker`() { + for (zone in zones) { + val selected = midnight("2026-10-25", zone) + val now = at("2026-11-04T12:00:00", zone) + assertEquals(selected, resolvedSleepDayMillis(selected, now, zone)) + } + } +}