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:
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
parent 6e40b72c50
commit e471c9b750
2 changed files with 505 additions and 0 deletions
@@ -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 }
}
}