From 6e40b72c508835150849b7d72a4a314755d73d97 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 16:04:40 +0200 Subject: [PATCH] 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 --- .../clockula/domain/alarm/AlarmOccurrences.kt | 74 ++++ .../clockula/domain/alarm/AlarmRing.kt | 29 ++ .../clockula/domain/alarm/AlarmRingState.kt | 22 + .../clockula/domain/alarm/UpcomingAlarm.kt | 39 ++ .../domain/alarm/AlarmOccurrencesTest.kt | 389 ++++++++++++++++++ 5 files changed, 553 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRingState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt new file mode 100644 index 0000000..f543137 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrences.kt @@ -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 = 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt new file mode 100644 index 0000000..2c716ff --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRingState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRingState.kt new file mode 100644 index 0000000..ecef693 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRingState.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt new file mode 100644 index 0000000..73f8bc7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/UpcomingAlarm.kt @@ -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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesTest.kt new file mode 100644 index 0000000..6449fc5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmOccurrencesTest.kt @@ -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 = + 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) + } +}