fix(bedtime): DST-safe windows, ordered edits, quieter passes

Whether the wake time is the same night or the next morning is now
decided on the local times, so a bedtime in a spring-forward gap no
longer turns a 45-minute window into a day-long one. Screen edits start
undispatched so they reach the engine's lock in the order they were made,
and an edit that rewrites the rule also re-asserts the mode, in case the
platform reset its state.

The launch-time bedtime pass runs even if an earlier engine pass failed,
a restore that never applied settings no longer forces the mode, and a
user who never turned bedtime on pays nothing for it at launch or boot.
The screen says when equal bedtime and wake-up times mean it never starts.

Refs #1
This commit is contained in:
makiolaj committed 2026-10-22 11:53:39 +02:00
1 parent 2dce78281a
commit 1a443c066f
11 files changed
+87 -38

No files matched your search

@@ -73,27 +73,35 @@ class ClockulaApp : Application() {
// a cold start caused by the fire broadcast must not interleave its
// read-modify-write over `alarm_states` with the fire's own.
scope.launch {
engine.onBootCompleted()
// After the alarm engine's pass, and idempotent within a boot: the
// boot-id gate makes the second repair a no-op, so neither engine
// depends on the other's ordering (M6 D21). Process death costs the
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
// And the third, after the other two. Process death costs the
// stopwatch nothing — the run is in DataStore and the laps in Room —
// but the service may not have survived it, so this brings it back;
// and if this is a new boot the gate above has already repaired the
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
// Last and guarded, like every bedtime call on a shared path: a
// force stop clears its AlarmManager slot too, and may have slept
// through a bedtime or wake-up, so both are caught up here.
runCatching { bedtimeEngine.resync() }
try {
launchPasses()
} finally {
// Last and guarded, like every bedtime call on a shared path,
// and in a finally so a failure above cannot cost it its pass:
// a force stop clears its AlarmManager slot too, and may have
// slept through a bedtime or wake-up, so both are caught up here.
runCatching { bedtimeEngine.resync() }
}
}
// Never completes on its own — it is the widgets' one refresh trigger
// for as long as the process lives (M10, docs/PLAN.md §9).
scope.launch { widgetSync.run() }
}
private suspend fun launchPasses() {
engine.onBootCompleted()
// After the alarm engine's pass, and idempotent within a boot: the
// boot-id gate makes the second repair a no-op, so neither engine
// depends on the other's ordering (M6 D21). Process death costs the
// timers nothing — the anchors and the AlarmManager slot survive it —
// but the service may not have, so this brings it back.
timerEngine.onBootCompleted()
// And the third, after the other two. Process death costs the
// stopwatch nothing — the run is in DataStore and the laps in Room —
// but the service may not have survived it, so this brings it back;
// and if this is a new boot the gate above has already repaired the
// run, leaving it paused at what it banked (M7 D17).
stopwatchEngine.onBootCompleted()
}
}
@@ -98,8 +98,8 @@ class BackupEngine @Inject constructor(
// The bedtime settings came with the rest (or were rolled back
// with them), so the slot and the Do Not Disturb rule follow.
// Its platform calls are guarded already; a failure here is
// not a failed import.
runCatching { bedtimeEngine.resync(refreshRule = true) }
// not a failed import. Nothing to follow if they never changed.
if (settingsApplied) runCatching { bedtimeEngine.resync(refreshRule = true) }
}
}
failure?.let { throw it }
@@ -46,19 +46,20 @@ class BedtimeEngine @Inject constructor(
/**
* A change made on the bedtime screen: stored, then applied at once. The
* rule is rewritten when a field it carries changed. When the schedule
* itself changed, the mode is switched to what it says now, so turning
* bedtime on at 23:30 for a 23:00 bedtime starts it straight away; an
* edit that cannot move a boundary (the reminder, an effect) leaves a
* mode switched by hand alone.
* or the rule changed, the mode is switched to what it says now, so
* turning bedtime on at 23:30 for a 23:00 bedtime starts it straight away
* (and a rewritten rule cannot be left off if the platform reset its
* state); a reminder edit leaves a mode switched by hand alone.
*/
suspend fun update(transform: (BedtimeSettings) -> BedtimeSettings) = lock.withLock {
val previous = settings.currentBedtime()
val updated = transform(previous)
settings.setBedtime(updated)
val refreshRule = previous.ruleFields() != updated.ruleFields()
apply(
updated,
refreshRule = previous.ruleFields() != updated.ruleFields(),
force = previous.scheduleFields() != updated.scheduleFields(),
refreshRule = refreshRule,
force = refreshRule || previous.scheduleFields() != updated.scheduleFields(),
)
}
@@ -111,6 +112,9 @@ class BedtimeEngine @Inject constructor(
zone: ZoneId = zones.current(),
) {
if (!current.enabled) {
// Already off and cleaned up: nothing to do, which keeps every
// launch and boot of a user who never turned bedtime on cheap.
if (!force && !refreshRule && !settings.bedtimeAppliedActive()) return
scheduler.cancel()
notifier.cancelReminder()
// Removing the rule ends the mode if it is on. Without access there
@@ -29,10 +29,13 @@ object BedtimeSchedule {
*/
fun windowOn(settings: BedtimeSettings, date: LocalDate, zone: ZoneId): BedtimeWindow? {
if (date.dayOfWeek !in settings.days || settings.bedtime == settings.wakeTime) return null
// Next morning or same day is decided on the local times, before DST
// resolution: a bedtime in a spring-forward gap shifts later and must
// not push a same-night wake time to the following day.
val endDate = if (settings.wakeTime.minutesOfDay > settings.bedtime.minutesOfDay) date else date.plusDays(1)
val start = AlarmOccurrences.at(date, settings.bedtime, zone)
val sameDayEnd = AlarmOccurrences.at(date, settings.wakeTime, zone)
val end = if (sameDayEnd > start) sameDayEnd else AlarmOccurrences.at(date.plusDays(1), settings.wakeTime, zone)
return BedtimeWindow(start, end)
val end = AlarmOccurrences.at(endDate, settings.wakeTime, zone)
return if (end > start) BedtimeWindow(start, end) else null
}
/** The window [now] falls in, or null when it is daytime or the schedule is off. */
@@ -88,10 +88,15 @@ fun BedtimeScreen(
GroupedRow(
title = stringResource(R.string.bedtime_enabled),
summary = if (settings.enabled) {
stringResource(R.string.bedtime_enabled_on, formatTime(settings.bedtime), formatTime(settings.wakeTime))
} else {
stringResource(R.string.bedtime_enabled_off)
summary = when {
!settings.enabled -> stringResource(R.string.bedtime_enabled_off)
// No window at all (BedtimeSchedule): say so rather than "On".
settings.bedtime == settings.wakeTime -> stringResource(R.string.bedtime_enabled_same_time)
else -> stringResource(
R.string.bedtime_enabled_on,
formatTime(settings.bedtime),
formatTime(settings.wakeTime),
)
},
position = Position.Alone,
trailing = {
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -87,7 +88,9 @@ class BedtimeViewModel @Inject internal constructor(
* and arming the slot or creating the rule.
*/
private fun update(transform: (BedtimeSettings) -> BedtimeSettings) {
appScope.launch {
// Undispatched, so each change queues on the engine's (fair) lock in
// the order the user made it: two quick taps cannot land reversed.
appScope.launch(start = CoroutineStart.UNDISPATCHED) {
runCatching { engine.update(transform) }
ruleId.value = engine.ruleId()
}
+1
View File
@@ -448,6 +448,7 @@
<string name="bedtime_enabled">Bedtime mode</string>
<string name="bedtime_enabled_on">On, %1$s to %2$s</string>
<string name="bedtime_enabled_off">Off</string>
<string name="bedtime_enabled_same_time">Bedtime and wake-up are the same time, so it never starts</string>
<string name="bedtime_access_title">Allow Do Not Disturb access</string>
<string name="bedtime_access_summary">Clockula needs it to turn its bedtime mode on and off. Find Clockula in the list and allow it. Without it, only the reminder works.</string>
<string name="bedtime_access_allow">Allow</string>
@@ -242,6 +242,18 @@ class BedtimeEngineTest {
assertThat(h.mode.on).isFalse()
}
@Test
fun `an effect edit at night re-asserts the mode`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir, now = instantAt(BERLIN, "2026-06-10T23:30"))
h.engine.update(nightly)
// As if rewriting the rule reset its state.
h.mode.on = false
h.engine.update { it.copy(effects = it.effects.copy(grayscale = false)) }
assertThat(h.mode.on).isTrue()
}
@Test
fun `changing only the reminder does not rewrite the rule`(@TempDir tempDir: Path) = runTest {
val h = harness(tempDir)
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.clockula.testing.offsetInstant
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/** 2026-06-10 is a Wednesday. */
class BedtimeScheduleTest {
@@ -119,6 +120,16 @@ class BedtimeScheduleTest {
assertThat(window.end).isEqualTo(offsetInstant("2026-10-25T07:00+01:00"))
}
@Test
fun `a bedtime in a spring-forward gap keeps its same-night wake time`() {
// Berlin springs forward on 2026-03-29: 02:30 does not exist.
val nightShift = everyNight.copy(bedtime = TimeOfDay(2, 30), wakeTime = TimeOfDay(3, 15))
assertThat(BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 29), BERLIN)).isNull()
val ordinary = BedtimeSchedule.windowOn(nightShift, java.time.LocalDate.of(2026, 3, 30), BERLIN)!!
assertThat(ordinary.end - ordinary.start).isEqualTo(45.minutes)
}
@Test
fun `no nights means nothing to schedule`() {
val none = everyNight.copy(days = RepeatDays.NONE)