feat(recurrence): expand a series in memory over lib-recur

Phase 2 of docs/OWN-STORE.md, the engine half. RecurrenceExpander turns a
stored rule set into its occurrences at read time — no materialised
instances table, so none of its staleness bugs exist. Each occurrence is
returned as its RECURRENCE-ID anchor, which is what the seam now
addresses occurrences by.

Expansion is bounded two ways: the window end, and a hard occurrence
ceiling. The iterator is fast-forwarded to the window start first, so a
FREQ=MINUTELY series anchored years back doesn't scan millions of
instances to emit one.

Three things lib-recur 0.12.2 forced. RecurrenceSet.iterator injects the
start itself, so DTSTART is in the set for free and EXDATE can remove it
(RFC 5545 §3.8.5.3). Its window end is exclusive. And a floating UNTIL
against a zoned start throws, so the UNTIL's local fields are re-read in
the series zone — the vendored provider worked around the same thing via
TimeZone.getDefault(), which isn't deterministic.

Malformed RRULE/RDATE/EXDATE values are dropped, not thrown: a task with
an unparseable stored rule still has to appear.

38 tests. Multi-occurrence expansion has no provider behaviour to compare
against, so the reference is RFC 5545 directly — daily/weekly/monthly/
yearly, COUNT, UNTIL, a Europe/Berlin DST boundary, all-day series pinned
to UTC midnight, RDATE, EXDATE, and an unbounded rule hitting both bounds.
This commit is contained in:
2026-08-13 16:09:30 +02:00
parent fd8363e356
commit 829a27da82
3 changed files with 611 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import org.dmfs.rfc5545.DateTime
import org.dmfs.rfc5545.recur.RecurrenceRule
import org.dmfs.rfc5545.recurrenceset.RecurrenceList
import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter
import org.dmfs.rfc5545.recurrenceset.RecurrenceSet
import java.time.ZoneId
import java.util.TimeZone
import kotlin.time.Instant
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
/** The rule set of one task series, as stored. All strings are raw iCalendar values. */
data class RecurrenceSpec(
val rrule: String?,
val rdate: String?,
val exdate: String?,
/** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */
val anchor: Instant,
val isAllDay: Boolean,
/** IANA zone id the anchor is expressed in; null means floating/local. */
val timeZone: String?,
)
/**
* The window expansion is bounded to: [from] inclusive, [until] exclusive, and
* never more than [maxOccurrences] results — so an unbounded `RRULE` terminates.
*/
data class ExpansionWindow(
val from: Instant,
val until: Instant,
val maxOccurrences: Int = 500,
)
/**
* Expands a task series into its occurrences in memory, over `lib-recur`.
*
* There is no materialised instances table behind this: the repository already
* filters and sorts in Kotlin, so occurrences are computed at read time and the
* whole class of staleness bugs a cached table brings never exists.
*/
object RecurrenceExpander {
/**
* Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor —
* the instant identifying that occurrence within the series. Ascending,
* deduplicated, `EXDATE` applied.
*
* The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART`
* is the first instance), so a spec with no rule and no `RDATE` expands to
* exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped
* rather than thrown — a task whose stored rule cannot be parsed still has
* to appear.
*
* [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a
* parameter rather than a `TimeZone.getDefault()` lookup so expansion is
* deterministic under test.
*/
fun expand(
spec: RecurrenceSpec,
window: ExpansionWindow,
floatingZone: ZoneId = ZoneId.systemDefault(),
): List<Instant> {
val zone = zoneOf(spec, floatingZone)
val anchorMillis = anchorMillis(spec)
val set = RecurrenceSet()
spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } }
spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) }
spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) }
val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds())
iterator.fastForward(window.from.toEpochMilliseconds())
val occurrences = ArrayList<Instant>()
var previous = Long.MIN_VALUE
while (occurrences.size < window.maxOccurrences && iterator.hasNext()) {
val millis = iterator.next()
if (millis == previous) continue
previous = millis
occurrences += Instant.fromEpochMilliseconds(millis)
}
return occurrences
}
/**
* Index of the current occurrence in an ascending [occurrences] list: the
* first one at or after [now], or the last one when the whole series is in
* the past. `-1` when there are no occurrences at all.
*/
fun currentOccurrenceIndex(occurrences: List<Instant>, now: Instant): Int {
if (occurrences.isEmpty()) return -1
val next = occurrences.indexOfFirst { it >= now }
return if (next >= 0) next else occurrences.lastIndex
}
/**
* Each occurrence's distance from the current one, index-aligned with
* [occurrences]. `0` is the current occurrence, negative counts back into the
* past and positive counts forward — the convention `Task.distanceFromCurrent`
* carries and the data sources pick the current occurrence by.
*
* Purely positional: unlike the dmfs provider, which drove the same number off
* each instance's closed state, this knows only times. Completion-aware
* refinement belongs where overrides carry their status.
*/
fun distancesFromCurrent(occurrences: List<Instant>, now: Instant): List<Int> {
val current = currentOccurrenceIndex(occurrences, now)
if (current < 0) return emptyList()
return occurrences.indices.map { it - current }
}
private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone {
if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC"))
val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return TimeZone.getTimeZone(stored ?: floatingZone)
}
/** All-day series are date-anchored: pin the anchor to UTC midnight, as it is stored. */
private fun anchorMillis(spec: RecurrenceSpec): Long {
val millis = spec.anchor.toEpochMilliseconds()
return if (!spec.isAllDay) millis else Math.floorDiv(millis, MILLIS_PER_DAY) * MILLIS_PER_DAY
}
private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching {
RecurrenceRule(value).also { rule ->
// lib-recur refuses to iterate a floating UNTIL against a zoned start,
// and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it
// anyway. Re-read the UNTIL's local fields in the series zone.
val until = rule.until
if (until != null && until.isFloating) {
rule.until = DateTime(
zone,
until.year,
until.month,
until.dayOfMonth,
until.hours,
until.minutes,
until.seconds,
)
}
}
}.getOrNull()
private fun datesOf(value: String, zone: TimeZone): RecurrenceList? =
runCatching { RecurrenceList(value, zone) }.getOrNull()
private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null }
}

View File

@@ -0,0 +1,59 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class DistanceFromCurrentTest {
private fun at(text: String) = Instant.parse(text)
private val occurrences = listOf(
at("2025-01-05T09:00:00Z"),
at("2025-01-06T09:00:00Z"),
at("2025-01-07T09:00:00Z"),
at("2025-01-08T09:00:00Z"),
)
@Test
fun `the current occurrence is the first one at or after now`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(index).isEqualTo(2)
}
@Test
fun `an occurrence exactly at now is the current one`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z"))
assertThat(index).isEqualTo(1)
}
@Test
fun `past occurrences count down and later ones count up`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder()
}
@Test
fun `a series entirely in the future is current at its first occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z"))
assertThat(distances).containsExactly(0, 1, 2, 3).inOrder()
}
@Test
fun `a series entirely in the past is current at its last occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z"))
assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder()
}
@Test
fun `exactly one occurrence is ever the current one`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z"))
assertThat(distances.count { it == 0 }).isEqualTo(1)
}
@Test
fun `an empty series has no distances`() {
assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty()
assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1)
}
}

View File

@@ -0,0 +1,402 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
/**
* The provider only ever materialised the single next occurrence, so there is no
* provider behaviour to compare multi-occurrence expansion against. These cases
* assert against RFC 5545 §3.8.5 directly.
*/
class RecurrenceExpanderTest {
private val berlin = "Europe/Berlin"
private val newYork = ZoneId.of("America/New_York")
private fun at(text: String) = Instant.parse(text)
private fun spec(
rrule: String? = null,
rdate: String? = null,
exdate: String? = null,
anchor: String,
isAllDay: Boolean = false,
timeZone: String? = berlin,
) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone)
private fun window(
from: String = "2000-01-01T00:00:00Z",
until: String = "2100-01-01T00:00:00Z",
max: Int = 500,
) = ExpansionWindow(at(from), at(until), max)
private fun expand(
spec: RecurrenceSpec,
window: ExpansionWindow = window(),
floatingZone: ZoneId = newYork,
) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() }
// --- frequencies ---------------------------------------------------------
@Test
fun `daily rule yields consecutive days at the same local time`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `interval skips the intervening days`() {
val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
"2025-01-11T08:00:00Z",
).inOrder()
}
@Test
fun `weekly by day expands to the named weekdays`() {
// The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is
// the first instance of the set regardless (RFC 5545 §3.8.5.3), so the
// Tuesday leads and the pattern takes over from there.
val result = expand(
spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z", // Tue, the anchor
"2025-01-08T08:00:00Z", // Wed
"2025-01-10T08:00:00Z", // Fri
"2025-01-13T08:00:00Z", // Mon
"2025-01-15T08:00:00Z", // Wed
).inOrder()
}
@Test
fun `monthly by day expands to the nth weekday of the month`() {
// 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the
// anchor still leads.
val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-14T08:00:00Z",
"2025-02-11T08:00:00Z",
).inOrder()
}
@Test
fun `monthly by month day skips months that lack the day`() {
val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-31T08:00:00Z",
"2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST
"2025-05-31T07:00:00Z",
).inOrder()
}
@Test
fun `yearly rule repeats on the anniversary`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2026-01-07T08:00:00Z",
"2027-01-07T08:00:00Z",
).inOrder()
}
@Test
fun `yearly rule on a leap day only recurs in leap years`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z"))
assertThat(result).containsExactly(
"2024-02-29T08:00:00Z",
"2028-02-29T08:00:00Z",
"2032-02-29T08:00:00Z",
).inOrder()
}
// --- limits --------------------------------------------------------------
@Test
fun `COUNT limits the series`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).hasSize(2)
}
@Test
fun `UNTIL includes an occurrence falling exactly on it`() {
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a floating UNTIL is read in the series zone`() {
// RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and
// lib-recur throws outright on the mismatch. Stored rules break the rule
// anyway, so 09:00 floating has to mean 09:00 in Berlin.
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an unbounded rule stops at the occurrence ceiling`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(max = 4),
)
assertThat(result).hasSize(4)
assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z")
}
@Test
fun `an unbounded rule stops at the window end`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-10T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `the window end is exclusive`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-09T08:00:00Z"),
)
assertThat(result).doesNotContain("2025-01-09T08:00:00Z")
}
@Test
fun `occurrences before the window start are skipped`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-06-01T07:00:00Z",
"2025-06-02T07:00:00Z",
"2025-06-03T07:00:00Z",
).inOrder()
}
// --- DST and all-day -----------------------------------------------------
@Test
fun `a daily series keeps its local time across a DST boundary`() {
// Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from
// 08:00Z to 07:00Z while the wall-clock time the user set stays put.
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z"))
assertThat(result).containsExactly(
"2025-03-28T08:00:00Z",
"2025-03-29T08:00:00Z",
"2025-03-30T07:00:00Z",
"2025-03-31T07:00:00Z",
).inOrder()
}
@Test
fun `an all-day series pins every occurrence to UTC midnight`() {
// Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and
// the device zone are both irrelevant, and no DST shift reaches it.
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
anchor = "2025-03-29T22:45:00Z",
isAllDay = true,
timeZone = berlin,
),
)
assertThat(result).containsExactly(
"2025-03-29T00:00:00Z",
"2025-03-30T00:00:00Z",
"2025-03-31T00:00:00Z",
).inOrder()
}
@Test
fun `an all-day weekly series stays on the same weekday`() {
val result = expand(
spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-22T00:00:00Z",
"2025-01-29T00:00:00Z",
).inOrder()
}
@Test
fun `a series with no zone expands in the floating zone`() {
val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null)
// 09:00 in New York, over the 2025-03-09 US DST switch.
assertThat(expand(spec, floatingZone = newYork)).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
// --- RDATE / EXDATE ------------------------------------------------------
@Test
fun `RDATE adds occurrences the rule does not produce`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=2",
rdate = "20250115T140000",
anchor = "2025-01-07T08:00:00Z",
),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-15T13:00:00Z", // 14:00 Berlin
).inOrder()
}
@Test
fun `an RDATE before the anchor is part of the set`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z")
}
@Test
fun `an RDATE repeating a rule instance is not emitted twice`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `multiple RDATEs are comma-separated`() {
val result = expand(
spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-10T08:00:00Z",
"2025-01-12T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE removes an occurrence`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE can remove the anchor itself`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a UTC EXDATE matches a zoned occurrence at the same instant`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an all-day EXDATE removes the matching date`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
exdate = "20250116",
anchor = "2025-01-15T00:00:00Z",
isAllDay = true,
),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-17T00:00:00Z",
).inOrder()
}
// --- degenerate input ----------------------------------------------------
@Test
fun `a spec with no rule expands to just its anchor`() {
assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed rule degrades to the anchor instead of throwing`() {
assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed RDATE is dropped and the rule still expands`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
).inOrder()
}
@Test
fun `an unknown zone id falls back to the floating zone`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"),
floatingZone = newYork,
)
assertThat(result).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
@Test
fun `a window that ends before the anchor yields nothing`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2024-01-01T00:00:00Z"),
)
assertThat(result).isEmpty()
}
}