Skip to content
Open
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
14 changes: 11 additions & 3 deletions app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -907,14 +908,21 @@ 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 },
)
}
}

/** 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,
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SleepSessionEntity> = emptyList(),
// Aggregate view (week/month/year)
val rangeSessions: List<SleepSessionEntity> = emptyList(),
Expand Down
79 changes: 79 additions & 0 deletions app/src/test/java/com/pulseloop/ui/screens/SleepDayDateTest.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading