feat(domain): next-fire resolution, with skip and snooze watermarks
The resolver is pure: it reads an alarm and its ring state and returns what should happen, and the engine does every write. Two rules keep re-resolution honest, and both are load-bearing enough to be worth naming. The grace window lets a fire that arrives a couple of minutes late still count, so a device powered on at 07:01 rings its 07:00 alarm. The handled-occurrence watermark suppresses a candidate only when it is both at-or-before the watermark and at-or-before now — the second conjunct is what stops a user who set the clock forward, let an alarm fire, then set it back from having every future occurrence silenced forever. Skip needs a watermark of its own, because a bare flag eats a second alarm once the skipped occurrence has passed. On a one-shot alarm, skipping the only occurrence is dismissing it, so it disables the alarm instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
enum class FireSource { NONE, OCCURRENCE, SNOOZE, RESUMED_RING }
|
||||
|
||||
/**
|
||||
* What one alarm resolves to, plus the state as it should now be persisted and
|
||||
* the two instructions that live outside the state table.
|
||||
*/
|
||||
data class AlarmResolution(
|
||||
val alarmId: Long,
|
||||
val nextFire: Instant?,
|
||||
val source: FireSource,
|
||||
val state: AlarmRingState,
|
||||
val clearSkipFlag: Boolean = false,
|
||||
val disableAlarm: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure. Never touches a repository, a clock or `ZoneId.systemDefault()`, so the
|
||||
* whole of the milestone's behaviour is callable from a plain JUnit test with
|
||||
* hand-built inputs.
|
||||
*
|
||||
* The precedence is fixed and total: disabled, then ringing, then snoozed, then
|
||||
* the natural occurrence.
|
||||
*/
|
||||
object AlarmResolver {
|
||||
fun resolve(
|
||||
alarm: Alarm,
|
||||
state: AlarmRingState,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
|
||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
||||
): AlarmResolution {
|
||||
// 1. Disabled: every volatile field goes, except the dismissal watermark
|
||||
// — re-enabling an alarm must not un-suppress an occurrence already rung.
|
||||
if (!alarm.enabled) {
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
nextFire = null,
|
||||
source = FireSource.NONE,
|
||||
state = AlarmRingState(alarmId = state.alarmId, handledOccurrence = state.handledOccurrence),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Ringing. A clock moved backwards under a ringing alarm reads as a
|
||||
// negative age, which is inside the window: it keeps ringing.
|
||||
val ringingSince = state.ringingSince
|
||||
if (ringingSince != null && now - ringingSince < autoSilenceAfter) {
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
nextFire = null,
|
||||
source = FireSource.RESUMED_RING,
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
var working = if (ringingSince == null) {
|
||||
state
|
||||
} else {
|
||||
// Nobody dismissed it: the cycle closes as missed and we fall through.
|
||||
state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
||||
}
|
||||
|
||||
// 3. Snooze. A snooze promised before a reboot is kept, even if it came
|
||||
// due while the device was off; one older than the ring window is gone.
|
||||
val snoozedUntil = working.snoozedUntil
|
||||
val snoozePending = snoozedUntil != null && now - snoozedUntil < autoSilenceAfter
|
||||
if (snoozedUntil != null && !snoozePending) {
|
||||
working = working.copy(snoozedUntil = null, snoozeCount = 0)
|
||||
}
|
||||
|
||||
if (snoozedUntil != null && snoozePending) {
|
||||
// Arming a skip is deferred until the snooze cycle ends, so nothing
|
||||
// skip-related is read or written here.
|
||||
val snoozing = working
|
||||
val armed = snoozing.skippedOccurrence
|
||||
val occurrence = candidates(alarm, snoozing, now, zone, fireGrace)
|
||||
.first { armed == null || it > armed }
|
||||
return if (occurrence < snoozedUntil) {
|
||||
// Defined so the function is total; unreachable for one alarm in practice.
|
||||
AlarmResolution(alarm.id, occurrence, FireSource.OCCURRENCE, working)
|
||||
} else {
|
||||
AlarmResolution(alarm.id, snoozedUntil, FireSource.SNOOZE, working)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The natural occurrence, with the skip watermark on top.
|
||||
if (!alarm.skipNextOccurrence) {
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
nextFire = candidates(alarm, working, now, zone, fireGrace).first(),
|
||||
source = FireSource.OCCURRENCE,
|
||||
state = working.copy(skippedOccurrence = null),
|
||||
)
|
||||
}
|
||||
|
||||
// A one-shot alarm has exactly one occurrence, so skipping it is
|
||||
// dismissing it in advance (D5).
|
||||
if (!alarm.repeatDays.isRepeating) {
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
nextFire = null,
|
||||
source = FireSource.NONE,
|
||||
state = working.copy(skippedOccurrence = null),
|
||||
clearSkipFlag = true,
|
||||
disableAlarm = true,
|
||||
)
|
||||
}
|
||||
|
||||
val watermark = working.skippedOccurrence ?: candidates(alarm, working, now, zone, fireGrace).first()
|
||||
val consumed = watermark <= now
|
||||
return AlarmResolution(
|
||||
alarmId = alarm.id,
|
||||
// Always the candidate *after* the watermark, so a skipped occurrence
|
||||
// cannot ring on its way out through the grace window.
|
||||
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
|
||||
source = FireSource.OCCURRENCE,
|
||||
state = working.copy(skippedOccurrence = watermark.takeUnless { consumed }),
|
||||
clearSkipFlag = consumed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The eligible occurrences, in order. The grace window lets a late fire
|
||||
* count; the watermark suppresses an occurrence already handled, but only
|
||||
* while it is still in the past — so a clock moved backwards can never
|
||||
* silence the future.
|
||||
*/
|
||||
private fun candidates(
|
||||
alarm: Alarm,
|
||||
state: AlarmRingState,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
fireGrace: Duration,
|
||||
): Sequence<Instant> {
|
||||
val handled = state.handledOccurrence
|
||||
return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone)
|
||||
.filterNot { handled != null && it <= handled && it <= now }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user