feat(domain): DST-correct occurrence generation over a repeat mask

Occurrences are generated by walking local dates forward and mapping each
through ZonedDateTime.of, never by adding 24 hours to an instant. That is the
whole DST story: java.time's default resolver shifts a 02:30 alarm forward to
03:30 on a spring-forward night rather than dropping it, and takes the earlier
of the two 02:30s on a fall-back night rather than ringing twice.

Asserted for Berlin and New York, and across all 128 repeat masks.

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 f4b7c1a16c
commit 6e40b72c50
5 changed files with 553 additions and 0 deletions
@@ -0,0 +1,74 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Instant
/**
* Local-date iteration into absolute instants. The one place in the app that
* knows a DST gap or overlap exists: a next fire time is re-resolved from the
* local time-of-day and the zone, never derived by adding millis to the last one
* (docs/PLAN.md §4).
*/
object AlarmOccurrences {
/**
* `ZonedDateTime.of(date, time, zone).toInstant()`, with java.time's default
* resolver and no help from us: a gap shifts forward by the gap's own length
* (a 02:30 alarm on a spring-forward night rings at 03:30) and an overlap
* takes the earlier offset (a fall-back 02:30 rings once, not twice).
*/
fun at(date: LocalDate, time: TimeOfDay, zone: ZoneId): Instant =
ZonedDateTime.of(date, LocalTime.of(time.hour, time.minute), zone)
.toInstant()
.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) }
/**
* Strictly increasing instants, each strictly after [after]. Infinite for any
* mask, including [RepeatDays.NONE], which matches every day — a one-shot
* alarm is just "the next daily candidate", and what makes it one-shot is
* what happens after it rings.
*
* Two local dates can collapse onto one instant (a skipped calendar day, a
* gap), so a candidate that did not advance is dropped and the caller may
* rely on `drop(1)` meaning "the one after".
*/
fun sequence(
time: TimeOfDay,
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
): Sequence<Instant> = Sequence {
iterator {
// One day back, because a gap can push a candidate from the previous
// local date past [after].
var date = after.localDate(zone).minusDays(1)
var previous: Instant? = null
while (true) {
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
val candidate = at(date, time, zone)
val last = previous
if (candidate > after && (last == null || candidate > last)) {
previous = candidate
yield(candidate)
}
}
date = date.plusDays(1)
}
}
}
fun next(
time: TimeOfDay,
repeatDays: RepeatDays,
after: Instant,
zone: ZoneId,
): Instant? = sequence(time, repeatDays, after, zone).firstOrNull()
private fun Instant.localDate(zone: ZoneId): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
}
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.clockula.domain.alarm
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* The ring cycle's constants. They are values rather than settings: v1 does not
* offer them to the user (docs/PLAN.md §4, slice plan D8/D19).
*/
object AlarmRing {
/**
* How long an undismissed alarm rings before it is silenced as missed.
* AOSP DeskClock's default, and the window a resumed ring is measured against.
*/
val AUTO_SILENCE_AFTER: Duration = 10.minutes
/**
* How late a fire may arrive and still count. A fire delayed by doze, a slow
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
*/
val FIRE_GRACE: Duration = 2.minutes
/** How often the ringing service steps the volume ramp. */
val RAMP_TICK: Duration = 200.milliseconds
/** The ramp's floor — an alarm never starts at true silence. */
const val MIN_VOLUME: Float = 0.05f
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain.alarm
import kotlin.time.Instant
/** Volatile per-alarm ring state. Persisted in `alarm_states` (schema v2). */
data class AlarmRingState(
val alarmId: Long,
val snoozedUntil: Instant? = null,
val snoozeCount: Int = 0,
val ringingSince: Instant? = null,
val handledOccurrence: Instant? = null,
val skippedOccurrence: Instant? = null,
) {
val isRinging: Boolean get() = ringingSince != null
val isSnoozed: Boolean get() = snoozedUntil != null
companion object {
/** The record an alarm with no stored row reads back as. */
fun initial(alarmId: Long): AlarmRingState = AlarmRingState(alarmId = alarmId)
}
}
@@ -0,0 +1,39 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmSettings
import kotlin.time.Instant
data class UpcomingAlarm(
val alarm: Alarm,
val nextFire: Instant?,
val source: FireSource,
)
data class ScheduledFire(
val alarmId: Long,
val fireAt: Instant,
val source: FireSource,
val mode: ScheduleMode,
)
/** Everything the ringing service needs, rebuilt from persisted state after a kill. */
data class RingSession(
val alarm: Alarm,
val settings: AlarmSettings,
val startedAt: Instant,
val canSnooze: Boolean,
)
sealed interface FireOutcome {
data class Ringing(val alarmId: Long) : FireOutcome
data object Ignored : FireOutcome
}
sealed interface SnoozeOutcome {
data class Snoozed(val alarmId: Long, val until: Instant, val remainingSnoozes: Int) : SnoozeOutcome
/** The limit was already reached; the alarm was dismissed instead. */
data class LimitReached(val alarmId: Long) : SnoozeOutcome
data object NotRinging : SnoozeOutcome
}
@@ -0,0 +1,389 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.APIA
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.LORD_HOWE
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.UTC_ZONE
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.localAt
import de.jeanlucmakiola.clockula.testing.offsetAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* The DST heart of the app. Every expectation here is written as an absolute
* instant with the offset it should carry, so an assertion can never restate
* the behaviour it is checking.
*/
class AlarmOccurrencesTest {
private val daily = RepeatDays.NONE
private fun nextFor(
hour: Int,
minute: Int,
repeatDays: RepeatDays = daily,
after: Instant,
zone: ZoneId = BERLIN,
): Instant? = AlarmOccurrences.next(TimeOfDay(hour, minute), repeatDays, after, zone)
private fun take(
count: Int,
hour: Int,
minute: Int,
repeatDays: RepeatDays = daily,
after: Instant,
zone: ZoneId = BERLIN,
): List<Instant> =
AlarmOccurrences.sequence(TimeOfDay(hour, minute), repeatDays, after, zone).take(count).toList()
private fun hasGapAt(zone: ZoneId, local: LocalDateTime): Boolean =
zone.rules.getTransition(local)?.isGap == true
// --- T1–T11: the plain calendar cases ---
@Test
fun `next is today when the time of day is still ahead`() {
val after = instantAt(BERLIN, "2026-06-10T06:00")
val next = nextFor(hour = 7, minute = 0, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
}
@Test
fun `next rolls to tomorrow once today's time of day has gone by`() {
val after = instantAt(BERLIN, "2026-06-10T08:00")
val next = nextFor(hour = 7, minute = 0, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
}
@Test
fun `next is strictly after, so an after equal to the occurrence yields the following day`() {
val after = offsetInstant("2026-06-10T07:00+02:00")
val next = nextFor(hour = 7, minute = 0, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
}
@Test
fun `next is today's occurrence when after is one millisecond before it`() {
val after = offsetInstant("2026-06-10T07:00+02:00") - 1.milliseconds
val next = nextFor(hour = 7, minute = 0, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
}
@Test
fun `a weekday alarm asked on a Saturday lands on Monday`() {
val after = instantAt(BERLIN, "2026-06-13T12:00") // a Saturday
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKDAYS, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
}
@Test
fun `a weekday alarm asked on a Friday morning skips the weekend`() {
val after = instantAt(BERLIN, "2026-06-12T09:00") // a Friday, after 07:00
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKDAYS, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
}
@Test
fun `a Sunday-only alarm asked early on a Sunday fires today`() {
val after = instantAt(BERLIN, "2026-06-14T06:00") // a Sunday
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.of(DayOfWeek.SUNDAY), after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-14T07:00+02:00"))
}
@Test
fun `a Sunday-only alarm asked after its time waits a full week`() {
val after = instantAt(BERLIN, "2026-06-14T08:00")
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.of(DayOfWeek.SUNDAY), after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-21T07:00+02:00"))
}
@Test
fun `every day yields seven consecutive local days at the same local time`() {
val after = instantAt(BERLIN, "2026-06-10T08:00")
val week = take(7, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY, after = after)
assertThat(week.map { it.localAt(BERLIN) }).containsExactly(
LocalDateTime.parse("2026-06-11T07:00"),
LocalDateTime.parse("2026-06-12T07:00"),
LocalDateTime.parse("2026-06-13T07:00"),
LocalDateTime.parse("2026-06-14T07:00"),
LocalDateTime.parse("2026-06-15T07:00"),
LocalDateTime.parse("2026-06-16T07:00"),
LocalDateTime.parse("2026-06-17T07:00"),
).inOrder()
}
@Test
fun `a weekend alarm asked on a Wednesday lands on Saturday`() {
val after = instantAt(BERLIN, "2026-06-10T12:00") // a Wednesday
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKENDS, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-13T07:00+02:00"))
}
@Test
fun `the full mask resolves identically to EVERY_DAY`() {
val after = instantAt(BERLIN, "2026-06-10T08:00")
val fromMask = nextFor(7, 0, RepeatDays.fromMask(RepeatDays.ALL_MASK), after)
assertThat(fromMask).isEqualTo(nextFor(7, 0, RepeatDays.EVERY_DAY, after))
}
// --- T12–T13: every repeat configuration ---
@Test
fun `all 128 repeat masks yield increasing, in-mask occurrences at the alarm's local time`() {
val time = TimeOfDay(2, 30) // deliberately inside Berlin's spring-forward gap one night
val after = instantAt(BERLIN, "2026-03-01T12:00")
for (mask in 0..RepeatDays.ALL_MASK) {
val repeatDays = RepeatDays.fromMask(mask)
val occurrences =
AlarmOccurrences.sequence(time, repeatDays, after, BERLIN).take(10).toList()
val locals = occurrences.map { it.localAt(BERLIN) }
if (mask == 0) {
assertThat(locals.map { it.toLocalDate() })
.isEqualTo(generateSequence(locals.first().toLocalDate()) { it.plusDays(1) }.take(10).toList())
} else {
assertThat(locals.map { it.dayOfWeek }.filterNot { it in repeatDays }).isEmpty()
}
assertThat(occurrences).isInStrictOrder()
assertThat(occurrences.filter { it <= after }).isEmpty()
assertThat(
locals.filterNot {
it.toLocalTime() == LocalTime.of(2, 30) ||
hasGapAt(BERLIN, LocalDateTime.of(it.toLocalDate(), LocalTime.of(2, 30)))
},
).isEmpty()
}
}
@Test
fun `a long take is strictly increasing with no duplicates`() {
val after = instantAt(BERLIN, "2026-01-01T00:00")
val occurrences = take(30, hour = 2, minute = 30, repeatDays = RepeatDays.EVERY_DAY, after = after)
assertThat(occurrences).isInStrictOrder()
}
// --- T14–T25: DST ---
@Test
fun `a Berlin 0230 alarm on a spring-forward night rings at 0330 rather than vanishing`() {
val after = instantAt(BERLIN, "2026-03-28T12:00")
val next = nextFor(hour = 2, minute = 30, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
}
@Test
fun `a Berlin 0130 alarm is untouched by the spring-forward gap`() {
val after = instantAt(BERLIN, "2026-03-28T12:00")
val next = nextFor(hour = 1, minute = 30, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-03-29T01:30+01:00"))
}
@Test
fun `0230 and 0330 collapse onto the same moment on a spring-forward night`() {
val after = instantAt(BERLIN, "2026-03-28T12:00")
val halfPastThree = nextFor(hour = 3, minute = 30, after = after)
assertThat(halfPastThree).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
assertThat(halfPastThree).isEqualTo(nextFor(hour = 2, minute = 30, after = after))
}
@Test
fun `a Berlin 0230 alarm on a fall-back night takes the earlier of the two 0230s`() {
val after = instantAt(BERLIN, "2026-10-24T12:00")
val next = nextFor(hour = 2, minute = 30, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-10-25T02:30+02:00"))
}
@Test
fun `the fall-back 0230 is emitted exactly once`() {
val after = instantAt(BERLIN, "2026-10-24T12:00")
val occurrences = take(2, hour = 2, minute = 30, after = after)
assertThat(occurrences).containsExactly(
offsetInstant("2026-10-25T02:30+02:00"),
offsetInstant("2026-10-26T02:30+01:00"),
).inOrder()
}
@Test
fun `wall time is preserved across a transition even though the instant interval is not`() {
val spring = take(2, hour = 12, minute = 0, after = instantAt(BERLIN, "2026-03-28T11:00"))
val fall = take(2, hour = 12, minute = 0, after = instantAt(BERLIN, "2026-10-24T11:00"))
assertThat(spring[1] - spring[0]).isEqualTo(23.hours)
assertThat(fall[1] - fall[0]).isEqualTo(25.hours)
}
@Test
fun `a New York 0230 alarm on a spring-forward night resolves to 0330 minus four`() {
val after = instantAt(NEW_YORK, "2026-03-07T12:00")
val next = nextFor(hour = 2, minute = 30, after = after, zone = NEW_YORK)
assertThat(next).isEqualTo(offsetInstant("2026-03-08T03:30-04:00"))
}
@Test
fun `a New York 0130 alarm on a fall-back night takes the earlier offset`() {
val after = instantAt(NEW_YORK, "2026-10-31T12:00")
val occurrences = take(2, hour = 1, minute = 30, after = after, zone = NEW_YORK)
assertThat(occurrences).containsExactly(
offsetInstant("2026-11-01T01:30-04:00"),
offsetInstant("2026-11-02T01:30-05:00"),
).inOrder()
}
@Test
fun `a thirty-minute gap shifts the alarm by thirty minutes, not by an hour`() {
val after = instantAt(LORD_HOWE, "2026-10-03T12:00")
val next = nextFor(hour = 2, minute = 15, after = after, zone = LORD_HOWE)
assertThat(next).isEqualTo(offsetInstant("2026-10-04T02:45+11:00"))
assertThat(next!!.localAt(LORD_HOWE)).isEqualTo(LocalDateTime.parse("2026-10-04T02:45"))
}
@Test
fun `a skipped calendar day collapses onto the next day without emitting it twice`() {
val after = offsetInstant("2011-12-29T20:00-10:00")
val occurrences = take(2, hour = 7, minute = 0, after = after, zone = APIA)
assertThat(occurrences).containsExactly(
offsetInstant("2011-12-31T07:00+14:00"),
offsetInstant("2012-01-01T07:00+14:00"),
).inOrder()
}
@Test
fun `in UTC every interval is exactly twenty-four hours`() {
val after = instantAt(UTC_ZONE, "2026-01-01T00:00")
val occurrences = take(400, hour = 7, minute = 0, after = after, zone = UTC_ZONE)
assertThat(occurrences.zipWithNext { a, b -> b - a }.distinct()).containsExactly(24.hours)
}
@Test
fun `the same alarm re-resolved in another zone is another moment`() {
val after = instantAt(UTC_ZONE, "2026-06-10T00:00")
val berlin = nextFor(hour = 7, minute = 0, after = after, zone = BERLIN)!!
val newYork = nextFor(hour = 7, minute = 0, after = after, zone = NEW_YORK)!!
val offsetDifference =
(newYork.offsetAt(NEW_YORK).totalSeconds - berlin.offsetAt(BERLIN).totalSeconds).toLong()
assertThat((newYork - berlin).inWholeSeconds).isEqualTo(-offsetDifference)
}
// --- T26–T31: boundaries ---
@Test
fun `the scan terminates within a week for a single-day mask`() {
val epoch = Instant.fromEpochMilliseconds(0L)
val next = nextFor(
hour = 7,
minute = 0,
repeatDays = RepeatDays.of(DayOfWeek.MONDAY),
after = epoch,
zone = UTC_ZONE,
)
assertThat(next!! - epoch).isLessThan(24.hours * 8)
}
@Test
fun `no emitted occurrence is at or before after`() {
val after = instantAt(BERLIN, "2026-03-15T13:37")
val occurrences = take(50, hour = 13, minute = 37, repeatDays = RepeatDays.EVERY_DAY, after = after)
assertThat(occurrences.filter { it <= after }).isEmpty()
}
@Test
fun `a midnight alarm asked a millisecond before midnight fires at the coming midnight`() {
val after = offsetInstant("2026-06-11T00:00+02:00") - 1.milliseconds
val next = nextFor(hour = 0, minute = 0, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-11T00:00+02:00"))
}
@Test
fun `a 2359 alarm asked exactly at 2359 waits for tomorrow`() {
val after = offsetInstant("2026-06-10T23:59+02:00")
val next = nextFor(hour = 23, minute = 59, after = after)
assertThat(next).isEqualTo(offsetInstant("2026-06-11T23:59+02:00"))
}
@Test
fun `the gap primitive maps 0230 and 0330 to the same instant`() {
val springForward = LocalDate.parse("2026-03-29")
val halfPastTwo = AlarmOccurrences.at(springForward, TimeOfDay(2, 30), BERLIN)
assertThat(halfPastTwo).isEqualTo(AlarmOccurrences.at(springForward, TimeOfDay(3, 30), BERLIN))
}
@Test
fun `iterating one sequence twice yields the same elements both times`() {
val after = instantAt(BERLIN, "2026-03-28T12:00")
val sequence = AlarmOccurrences.sequence(TimeOfDay(2, 30), RepeatDays.EVERY_DAY, after, BERLIN)
val first = sequence.take(5).toList()
assertThat(sequence.take(5).toList()).isEqualTo(first)
}
}