diff --git a/AGENTS.md b/AGENTS.md index edb719bd..e3182c99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -946,7 +946,8 @@ things to preserve: - **The hypnogram draws the counted gaps** (`hypnogramBlocks`), display-only. Never feed those blocks to `awakeMinutes`, which counts the gaps itself. - **An hour-plus gap inside the wearer's Bedtime window stays one night** (`SleepGapBridge`). Off - unless quiet hours are on; Bedtime windows decide when the night has any, else the clock window. + unless the schedule or Bedtime tracking is on — the two are independent since #126; Bedtime + windows decide when the night has any, else the clock window if the schedule is on. The sync's reconcile and `SleepRecordDeletion`'s restate must segment with the same bridge, or a record delete would split a night the next sync merges back. diff --git a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt index 50ba4ffa..abceff5f 100644 --- a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt +++ b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt @@ -26,6 +26,9 @@ class PulseLoopApplication : Application() { override fun onCreate() { super.onCreate() CrashLogger.install(this) + // Before any sync can read them: Bedtime tracking no longer needs the schedule (issue #126), + // so a Bedtime flag left on under a switched-off schedule is cleared rather than reread. + QuietHoursPrefs.migrateIndependentBedtime(this) // Application-scoped so capture runs whether or not the Debug screen is open. DiagnosticsSubscriber(PulseLoopDatabase.getInstance(this)).start(appScope) // One-time cleanup of activity totals corrupted by the old bucket handling; runs off the diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index f4c89b46..dbeb21dc 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -581,13 +581,12 @@ class EventPersistenceSubscriber( // judged by its start, because the ring often runs the sofa hour and the real night as one // record. Already-imported nights are untouched (this never deletes), and the gate is read // per record so a settings change takes effect on the next packet, not the next launch. - val quiet = QuietHoursPrefs(context) - if (quiet.enabled) { - val kept = quiet.keptMinutesForRecord(effectiveTs, effectiveStages.size, context) - ?: return - effectiveTs = effectiveTs + kept.first * 60_000L - effectiveStages = effectiveStages.subList(kept.first, kept.last + 1) - } + // The schedule and Bedtime tracking are independent (issue #126); with neither on this + // keeps the whole record. + val kept = QuietHoursPrefs(context).keptMinutesForRecord(effectiveTs, effectiveStages.size, context) + ?: return + effectiveTs = effectiveTs + kept.first * 60_000L + effectiveStages = effectiveStages.subList(kept.first, kept.last + 1) // Sleep record deletion gate: drop if the user deleted this entire record (issue #78, #82) val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(effectiveTs) diff --git a/app/src/main/java/com/pulseloop/service/SleepGapBridge.kt b/app/src/main/java/com/pulseloop/service/SleepGapBridge.kt index f8fa5905..ceea26f9 100644 --- a/app/src/main/java/com/pulseloop/service/SleepGapBridge.kt +++ b/app/src/main/java/com/pulseloop/service/SleepGapBridge.kt @@ -3,10 +3,7 @@ package com.pulseloop.service import android.content.Context import com.pulseloop.data.entity.SleepStageBlockEntity import com.pulseloop.settings.QuietHoursPrefs -import com.pulseloop.settings.ZenModeListener -import com.pulseloop.settings.ZenModeTracker import com.pulseloop.settings.ZenWindow -import com.pulseloop.util.TimeUtil import java.time.ZoneId /** @@ -38,52 +35,43 @@ fun interface SleepGapBridge { /** The bridge for the user's current quiet-hours settings. Reads them once. */ fun fromSettings(context: Context): SleepGapBridge { val prefs = QuietHoursPrefs(context) - val zenWindows = if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) { - ZenModeTracker.getWindows(context) - } else { - null - } - return forWindows(prefs.enabled, zenWindows, prefs.startMinutes, prefs.endMinutes) + return forWindows( + prefs.enabled, + QuietHoursPrefs.activeBedtimeWindows(prefs, context), + prefs.startMinutes, + prefs.endMinutes, + ) } /** - * The same source order as the import gate ([QuietHoursPrefs.keptMinutesForRecord]): off - * unless quiet hours are on; the recorded Bedtime/DND windows when that gating is on and the - * night has any; otherwise the clock window. A night that has Bedtime windows is judged by - * them alone, so a gap after Bedtime ended is not bridged by a wider clock schedule. + * The same source order as the import gate ([QuietHoursPrefs.resolveKeptMinutes]): the + * recorded Bedtime/DND windows when that tracking is on and the night has any; otherwise + * the clock window if the schedule is on; otherwise nothing is bridged. A night that has + * Bedtime windows is judged by them alone, so a gap after Bedtime ended is not bridged by a + * wider clock schedule. Bedtime tracking bridges with the schedule off (issue #126), as + * the import gate trims with it off. * - * [zenWindows] is null when Bedtime gating is off. + * [zenWindows] is null when Bedtime tracking is off. */ fun forWindows( - enabled: Boolean, + scheduleEnabled: Boolean, zenWindows: List?, startMinutes: Int, endMinutes: Int, zone: ZoneId = ZoneId.systemDefault(), ): SleepGapBridge { - if (!enabled) return NONE + if (!scheduleEnabled && zenWindows == null) return NONE return SleepGapBridge { gapStart, gapEnd -> if (gapEnd <= gapStart) return@SleepGapBridge false - val night = zenWindows?.let { nightWindows(it, gapStart, zone) }.orEmpty() + val night = zenWindows?.let { QuietHoursPrefs.nightWindows(it, gapStart, zone) }.orEmpty() if (night.isNotEmpty()) { - night.any { w -> w.start <= gapStart && gapEnd <= effectiveEnd(w) } + night.any { w -> w.start <= gapStart && gapEnd <= QuietHoursPrefs.effectiveEnd(w) } } else { - clockCovers(gapStart, gapEnd, startMinutes, endMinutes, zone) + scheduleEnabled && clockCovers(gapStart, gapEnd, startMinutes, endMinutes, zone) } } } - /** The windows of the waking day [ts] falls in — the span the import gate reads too. */ - private fun nightWindows(windows: List, ts: Long, zone: ZoneId): List { - val wakingDay = TimeUtil.wakingDayLocal(ts, zone) - val dayStart = wakingDay - 12 * 3_600_000L - val dayEnd = wakingDay + 36 * 3_600_000L - return windows.filter { w -> effectiveEnd(w) > dayStart && w.start < dayEnd } - } - - private fun effectiveEnd(w: ZenWindow): Long = - w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS) - /** * Every minute of the gap inside the daily clock window. A gap of a day or more never is, and * neither is anything under a window whose start equals its end: for the import gate that diff --git a/app/src/main/java/com/pulseloop/settings/QuietHoursPrefs.kt b/app/src/main/java/com/pulseloop/settings/QuietHoursPrefs.kt index ba1424d2..1e0366f0 100644 --- a/app/src/main/java/com/pulseloop/settings/QuietHoursPrefs.kt +++ b/app/src/main/java/com/pulseloop/settings/QuietHoursPrefs.kt @@ -9,10 +9,9 @@ import java.time.ZoneId * window. A still wrist reads as sleep, so an evening on the sofa lands as a phantom session and * the night runs an hour high; the wearer knows their bedtime, the sensor guesses. * - * Deliberately a time window rather than Android's quiet Modes (the reporter's first choice): - * a Mode gate needs notification-policy access plus a recorded interruption-filter history to - * compare imports against, and the sofa case is mostly caught by a plain window. The Modes - * version stays open as a follow-up — the reporter offered to build it. + * Two sources, each opt-in and independent of the other (issue #126): the daily clock window + * ([enabled]) and the wearer's recorded Android Bedtime / DND windows ([gateByZenMode], issue #83, + * which needs Notification access). [resolveKeptMinutes] is how they combine. * * **Minute-level, not record-level.** A record is trimmed to the minutes that fall inside the * window ([keptMinutes]) rather than accepted or declined by its start minute. The ring often @@ -29,7 +28,10 @@ class QuietHoursPrefs(context: Context) { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - /** Off by default: the ring's own behaviour is the right default for everyone else. */ + /** + * The daily clock schedule ("Sleep quiet hours"). Off by default: the ring's own behaviour is + * the right default for everyone else. Independent of [gateByZenMode] since issue #126. + */ var enabled: Boolean get() = prefs.getBoolean(KEY_ENABLED, false) set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() } @@ -44,7 +46,10 @@ class QuietHoursPrefs(context: Context) { get() = prefs.getInt(KEY_END, DEFAULT_END) set(value) { prefs.edit().putInt(KEY_END, value.coerceIn(0, 24 * 60)).apply() } - /** Opt-in: gate quiet hours on Android Bedtime / DND mode (issue #83). */ + /** + * Opt-in: trim to Android Bedtime / DND windows (issue #83). Works on its own since issue #126 + * — before that it was read only while [enabled] was on, and the settings row was hidden with it. + */ var gateByZenMode: Boolean get() = prefs.getBoolean(KEY_GATE_BY_ZEN_MODE, false) set(value) { prefs.edit().putBoolean(KEY_GATE_BY_ZEN_MODE, value).apply() } @@ -62,6 +67,7 @@ class QuietHoursPrefs(context: Context) { private const val KEY_START = "startMinutes" private const val KEY_END = "endMinutes" private const val KEY_GATE_BY_ZEN_MODE = "gate_by_zen_mode" + private const val KEY_INDEPENDENT_BEDTIME_MIGRATED = "independentBedtime.v1" const val DEFAULT_START = 22 * 60 const val DEFAULT_END = 7 * 60 @@ -70,33 +76,109 @@ class QuietHoursPrefs(context: Context) { QuietHoursPrefs(context).gateByZenMode && ZenModeListener.isAccessGranted(context) /** - * Which minutes of a ring record to keep given user preferences. - * If [gateByZenMode] is enabled and Notification access is granted, filters against recorded - * Bedtime/DND quiet windows. If no quiet windows exist for the waking day, falls back - * to the configured clock window. + * The recorded Bedtime/DND windows when that tracking is active, else null — the + * `bedtimeWindows` argument of [resolveKeptMinutes] and [com.pulseloop.service.SleepGapBridge.forWindows]. */ + fun activeBedtimeWindows(prefs: QuietHoursPrefs, context: Context): List? = + if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) { + ZenModeTracker.getWindows(context) + } else { + null + } + + /** + * One-time upgrade step for issue #126, run from [com.pulseloop.PulseLoopApplication] before + * anything reads these prefs. Bedtime=on with the schedule off was reachable before #126 + * (turn both on, then the schedule off: the Bedtime row hid but kept its value) and meant + * *no trimming at all*. Read under the new rules it would mean "Bedtime only" and start + * trimming on the first sync after the update — and trimmed minutes are never imported. + * So that state is cleared to "neither", which is what it did; the wearer re-enables + * Bedtime tracking if they want it. Every other state already means what it meant. + */ + fun migrateIndependentBedtime(context: Context) { + val prefs = QuietHoursPrefs(context) + val raw = prefs.prefs + if (raw.getBoolean(KEY_INDEPENDENT_BEDTIME_MIGRATED, false)) return + val gate = migratedGateByZenMode(prefs.enabled, prefs.gateByZenMode) + if (gate != prefs.gateByZenMode) { + prefs.gateByZenMode = gate + ZenModeListener.setEnabled(context, false) + } + raw.edit().putBoolean(KEY_INDEPENDENT_BEDTIME_MIGRATED, true).apply() + } + + /** The stored Bedtime flag after the #126 upgrade: kept only while the schedule is on. */ + fun migratedGateByZenMode(scheduleEnabled: Boolean, gateByZenMode: Boolean): Boolean = + gateByZenMode && scheduleEnabled + + /** Which minutes of a ring record to keep under the user's current settings; see [resolveKeptMinutes]. */ fun keptMinutesForRecord( ts: Long, minutes: Int, prefs: QuietHoursPrefs, context: Context, zone: ZoneId = ZoneId.systemDefault(), + ): IntRange? = resolveKeptMinutes( + ts, + minutes, + scheduleEnabled = prefs.enabled, + bedtimeWindows = activeBedtimeWindows(prefs, context), + startMinutes = prefs.startMinutes, + endMinutes = prefs.endMinutes, + zone = zone, + ) + + /** + * Which minutes of a ring record to keep (issue #126: the two sources are independent). + * [bedtimeWindows] is null when Bedtime tracking is off or lacks Notification access. + * + * - **Neither:** the whole record. + * - **Schedule only:** trimmed to the clock window ([keptMinutes]). + * - **Bedtime only:** trimmed to the Bedtime/DND windows it overlaps; a record that overlaps + * none — a night Bedtime was forgotten — is kept whole. Forgetting Bedtime must not cost + * the night: the #126 reporter lost 07:00–08:10 to the clock fallback, permanently. + * "Overlaps", not "the waking day has a window": the filter cannot tell Bedtime from a + * DND meeting the next afternoon (same waking day), and dropping the night over that + * would be unrecoverable. The cost is a separate sofa record before Bedtime is kept; the + * hour gap rule still keeps it apart from the night. + * - **Both:** the Bedtime windows when the night has any and they overlap the record; + * otherwise the clock window (unchanged from #83). + * + * Null drops the record. + */ + fun resolveKeptMinutes( + ts: Long, + minutes: Int, + scheduleEnabled: Boolean, + bedtimeWindows: List?, + startMinutes: Int, + endMinutes: Int, + zone: ZoneId = ZoneId.systemDefault(), ): IntRange? { - if (!prefs.enabled) return 0 until minutes - if (prefs.gateByZenMode && ZenModeListener.isAccessGranted(context)) { - val wakingDay = com.pulseloop.util.TimeUtil.wakingDayLocal(ts) - val dayStart = wakingDay - 12 * 3600_000L - val dayEnd = wakingDay + 36 * 3600_000L - val windows = ZenModeTracker.getWindows(context, since = dayStart) - .filter { w -> (w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS)) > dayStart && w.start < dayEnd } - if (windows.isNotEmpty()) { - val kept = keptMinutesByWindows(ts, minutes, windows) - if (kept != null) return kept - } + if (minutes <= 0) return null + val night = bedtimeWindows?.let { nightWindows(it, ts, zone) }.orEmpty() + if (night.isNotEmpty()) { + val kept = keptMinutesByWindows(ts, minutes, night) + if (kept != null) return kept } - return keptMinutes(ts, minutes, prefs.startMinutes, prefs.endMinutes, zone) + if (scheduleEnabled) return keptMinutes(ts, minutes, startMinutes, endMinutes, zone) + return 0 until minutes } + /** + * The windows of the waking day [ts] falls in — the span both the import gate and + * [com.pulseloop.service.SleepGapBridge] judge a night by. + */ + fun nightWindows(windows: List, ts: Long, zone: ZoneId = ZoneId.systemDefault()): List { + val wakingDay = com.pulseloop.util.TimeUtil.wakingDayLocal(ts, zone) + val dayStart = wakingDay - 12 * 3_600_000L + val dayEnd = wakingDay + 36 * 3_600_000L + return windows.filter { w -> effectiveEnd(w) > dayStart && w.start < dayEnd } + } + + /** A still-open window counts as running for at most [ZenModeTracker.MAX_WINDOW_DURATION_MS]. */ + fun effectiveEnd(w: ZenWindow): Long = w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS) + /** * Longest contiguous stretch of minutes falling inside any of [windows]. */ @@ -110,10 +192,7 @@ class QuietHoursPrefs(context: Context) { var runStart = -1 for (i in 0..minutes) { val minuteTs = ts + i * 60_000L - val inside = i < minutes && windows.any { w -> - val effectiveEnd = w.end ?: (w.start + ZenModeTracker.MAX_WINDOW_DURATION_MS) - minuteTs >= w.start && minuteTs < effectiveEnd - } + val inside = i < minutes && windows.any { w -> minuteTs >= w.start && minuteTs < effectiveEnd(w) } if (inside && runStart < 0) runStart = i if (!inside && runStart >= 0) { val run = runStart until i diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index a710a4c0..ea6d529b 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -1651,9 +1651,10 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U // MARK: - Wearable /** - * The opt-in quiet-hours gate (issue #79): sleep the ring opens outside the window is ignored on - * import. The rationale lives on [QuietHoursPrefs]; this is just the controls — a switch and the - * two window ends, on the Wearable screen because it governs what a sync is allowed to store. + * The opt-in sleep trimming gate (issues #79, #83, #126): sleep the ring records outside the + * wearer's window is trimmed on import. The rationale lives on [QuietHoursPrefs]; this is just the + * controls — two independent switches (Bedtime/DND tracking, the clock schedule) and the schedule's + * two ends, on the Wearable screen because it governs what a sync is allowed to store. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -1688,70 +1689,76 @@ private fun QuietHoursCard() { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { + Text("Sleep trimming", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "A still wrist reads as sleep, so an evening on the sofa can run the night high. " + + "Trimmed minutes are never imported — changing these later won't bring them back.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + // Independent of the schedule below (issue #126), so the Notification-access prompt + // has to work from here with the schedule off. Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Sleep quiet hours", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Switch(checked = enabled, onCheckedChange = { enabled = it; prefs.enabled = it }) - } - Text( - "When on, sleep the ring records outside the window is trimmed off on import — a " + - "still wrist reads as sleep, so an evening on the sofa can log a phantom session " + - "and run the night high. Set the start to when you actually go to bed. Naps " + - "outside the window are skipped too. Already-imported nights are never touched, " + - "and what the gate skips while it is narrower stays skipped.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (enabled) { - Spacer(Modifier.height(12.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f).padding(end = 8.dp)) { - Text( - "Track Bedtime / DND mode", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - "Automatically align quiet hours with Android's Bedtime or Do Not Disturb mode.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = gateByZenMode, - onCheckedChange = { desired -> - if (desired) { - ZenModeListener.setEnabled(context, true) - if (ZenModeListener.isAccessGranted(context)) { - gateByZenMode = true - prefs.gateByZenMode = true - } else { - showPermissionDialog = true - } + Column(Modifier.weight(1f).padding(end = 8.dp)) { + Text( + "Track Bedtime / DND mode", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Keep only sleep inside Android's Bedtime or Do Not Disturb. " + + if (enabled) "Nights without it use the schedule." else "Nights without it are kept whole.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = gateByZenMode, + onCheckedChange = { desired -> + if (desired) { + ZenModeListener.setEnabled(context, true) + if (ZenModeListener.isAccessGranted(context)) { + gateByZenMode = true + prefs.gateByZenMode = true } else { - gateByZenMode = false - prefs.gateByZenMode = false - ZenModeListener.setEnabled(context, false) + showPermissionDialog = true } - }, + } else { + gateByZenMode = false + prefs.gateByZenMode = false + ZenModeListener.setEnabled(context, false) + } + }, + ) + } + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + Text( + "Sleep quiet hours", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + if (gateByZenMode) "Daily schedule for nights without Bedtime/DND." + else "Keep only sleep inside a daily schedule. Naps outside it are skipped.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - + Switch(checked = enabled, onCheckedChange = { enabled = it; prefs.enabled = it }) + } + if (enabled) { Spacer(Modifier.height(8.dp)) - Text( - if (gateByZenMode) "Fallback schedule (used when no Bedtime/DND mode was active):" - else "Quiet hours schedule:", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(4.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedButton(onClick = { picking = "start" }, modifier = Modifier.weight(1f)) { Text("From ${formatMinutesOfDay(start)}") diff --git a/app/src/test/java/com/pulseloop/service/SleepGapsTest.kt b/app/src/test/java/com/pulseloop/service/SleepGapsTest.kt index 25e54a53..09454f42 100644 --- a/app/src/test/java/com/pulseloop/service/SleepGapsTest.kt +++ b/app/src/test/java/com/pulseloop/service/SleepGapsTest.kt @@ -2,6 +2,7 @@ package com.pulseloop.service import com.pulseloop.data.entity.SleepStageBlockEntity import com.pulseloop.ring.SleepStage +import com.pulseloop.settings.QuietHoursPrefs import com.pulseloop.settings.ZenWindow import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -147,11 +148,48 @@ class SleepGapsTest { } @Test - fun `nothing is bridged while quiet hours are off`() { - val bridge = SleepGapBridge.forWindows(false, listOf(bedtime), 22 * 60, 7 * 60, utc) + fun `nothing is bridged with neither the schedule nor Bedtime tracking on`() { + val bridge = SleepGapBridge.forWindows(false, null, 22 * 60, 7 * 60, utc) assertFalse(bridge.bridges(gapStart, gapEnd)) } + /** Bedtime tracking works without the schedule (issue #126), for bridging as for importing. */ + @Test + fun `Bedtime only bridges a gap inside the Bedtime window`() { + val bridge = SleepGapBridge.forWindows(false, listOf(bedtime), 22 * 60, 7 * 60, utc) + assertTrue(bridge.bridges(gapStart, gapEnd)) + } + + /** No Bedtime that night and no schedule: the hour rule alone decides, as the import keeps all. */ + @Test + fun `Bedtime only bridges nothing on a night without a Bedtime window`() { + val bridge = SleepGapBridge.forWindows(false, emptyList(), 22 * 60, 7 * 60, utc) + assertFalse(bridge.bridges(gapStart, gapEnd)) // 22–07 would cover it + } + + /** + * The bridge and the import gate pick the same source for each of the four settings (issue + * #126): a gap the bridge keeps inside the night is one the import keeps whole as a record. + */ + @Test + fun `the bridge and the import gate agree on the source for every combination`() { + val gapMinutes = ((gapEnd - gapStart) / 60_000L).toInt() + // A gap inside Bedtime (00:25–08:06) but past a 22:00–06:00 schedule tells the sources apart. + val cases = listOf( + Triple(false, null, false), // neither + Triple(true, null, false), // schedule only: past 06:00 + Triple(false, listOf(bedtime), true), // Bedtime only + Triple(true, listOf(bedtime), true), // both: Bedtime decides + ) + for ((schedule, windows, bridged) in cases) { + val bridge = SleepGapBridge.forWindows(schedule, windows, 22 * 60, 6 * 60, utc) + assertEquals("schedule=$schedule windows=$windows", bridged, bridge.bridges(gapStart, gapEnd)) + val kept = QuietHoursPrefs.resolveKeptMinutes(gapStart, gapMinutes, schedule, windows, 22 * 60, 6 * 60, utc) + val active = schedule || windows != null + assertEquals("schedule=$schedule windows=$windows", bridged, active && kept == 0 until gapMinutes) + } + } + @Test fun `a gap that runs past the end of Bedtime is not bridged`() { val earlyEnd = ZenWindow(start = at(0, 25), end = at(6, 0)) diff --git a/app/src/test/java/com/pulseloop/settings/QuietHoursPrefsTest.kt b/app/src/test/java/com/pulseloop/settings/QuietHoursPrefsTest.kt index 146bcd28..1d0d9a0d 100644 --- a/app/src/test/java/com/pulseloop/settings/QuietHoursPrefsTest.kt +++ b/app/src/test/java/com/pulseloop/settings/QuietHoursPrefsTest.kt @@ -181,4 +181,73 @@ class QuietHoursPrefsTest { val kept = QuietHoursPrefs.keptMinutesByWindows(sleepStart, 60, windows) assertEquals(null, kept) } + + // ── #126: Bedtime tracking and the schedule are independent ───────────── + + /** Oct 10–11 2026 UTC wall clock; the #126 night ran 23:40 → 08:10. */ + private fun oct(day: Int, h: Int, m: Int): Long = + java.time.ZonedDateTime.of(2026, 10, day, h, m, 0, 0, utc).toInstant().toEpochMilli() + + private val nightStart = oct(10, 23, 40) + private val nightMinutes = 510 // → 08:10 + private val bedtime126 = listOf(ZenWindow(start = oct(11, 0, 10), end = oct(11, 7, 30))) + + private fun resolve( + schedule: Boolean, + windows: List?, + ts: Long = nightStart, + minutes: Int = nightMinutes, + ) = QuietHoursPrefs.resolveKeptMinutes(ts, minutes, schedule, windows, 22 * 60, 7 * 60, utc) + + @Test + fun `neither keeps the whole record`() { + assertEquals(0 until nightMinutes, resolve(schedule = false, windows = null)) + } + + @Test + fun `schedule only trims to the clock window as before`() { + assertEquals(0 until 440, resolve(schedule = true, windows = null)) // cut at 07:00 + } + + /** The #126 report: Bedtime forgotten, so the night has no window — keep all of it. */ + @Test + fun `Bedtime only keeps a night without a Bedtime window whole`() { + assertEquals(0 until nightMinutes, resolve(schedule = false, windows = emptyList())) + // Windows from another night don't count for this one. + val lastWeek = listOf(ZenWindow(start = oct(4, 0, 10), end = oct(4, 7, 30))) + assertEquals(0 until nightMinutes, resolve(schedule = false, windows = lastWeek)) + } + + @Test + fun `Bedtime only trims to the Bedtime window when the night has one`() { + assertEquals(30 until 470, resolve(schedule = false, windows = bedtime126)) // 00:10–07:30 + } + + /** A DND meeting the next afternoon shares the waking day; it must not drop the night. */ + @Test + fun `Bedtime only keeps a record that overlaps no window`() { + val meeting = listOf(ZenWindow(start = oct(11, 14, 0), end = oct(11, 14, 30))) + assertEquals(0 until nightMinutes, resolve(schedule = false, windows = meeting)) + } + + @Test + fun `both use the Bedtime window and fall back to the schedule`() { + assertEquals(30 until 470, resolve(schedule = true, windows = bedtime126)) + assertEquals(0 until 440, resolve(schedule = true, windows = emptyList())) + // Unchanged from #83: a record outside every window falls back to the clock window. + val sofa = resolve(schedule = true, windows = bedtime126, ts = oct(10, 21, 0), minutes = 90) + assertEquals(60 until 90, sofa) // 22:00–22:30 + } + + /** + * Bedtime on under a switched-off schedule meant "no trimming" before #126 (the row was hidden + * and the gate never read). The upgrade clears it so it does not start meaning "Bedtime only". + */ + @Test + fun `the upgrade keeps Bedtime only where the schedule was on`() { + assertTrue(QuietHoursPrefs.migratedGateByZenMode(scheduleEnabled = true, gateByZenMode = true)) + assertFalse(QuietHoursPrefs.migratedGateByZenMode(scheduleEnabled = false, gateByZenMode = true)) + assertFalse(QuietHoursPrefs.migratedGateByZenMode(scheduleEnabled = true, gateByZenMode = false)) + assertFalse(QuietHoursPrefs.migratedGateByZenMode(scheduleEnabled = false, gateByZenMode = false)) + } }