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:
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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user