107 lines
4.7 KiB
Kotlin
107 lines
4.7 KiB
Kotlin
package de.jeanlucmakiola.calendula.domain
|
|
|
|
import kotlinx.datetime.DateTimeUnit
|
|
import kotlinx.datetime.DayOfWeek
|
|
import kotlinx.datetime.LocalDate
|
|
import kotlinx.datetime.isoDayNumber
|
|
import kotlinx.datetime.minus
|
|
import kotlinx.datetime.number
|
|
import kotlinx.datetime.plus
|
|
|
|
/**
|
|
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
|
* (DTSTART), for previewing a rule as dates instead of as words. A preview only,
|
|
* kept to the shapes the picker can build; the provider stays the authority.
|
|
*
|
|
* Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when
|
|
* the rule's own picks miss it; a monthly or yearly rule *skips* a period the
|
|
* start day doesn't exist in rather than clamping; a weekly rule repeats in
|
|
* blocks of `interval` weeks beginning on Monday (the default WKST, since
|
|
* [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and
|
|
* [RecurrenceEnd.Until] is inclusive.
|
|
*
|
|
* Returns fewer than [limit] dates when the series ends first, and an empty list
|
|
* only when the rule yields nothing at all (an UNTIL before [start]).
|
|
*/
|
|
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
|
if (limit <= 0) return emptyList()
|
|
val until = (end as? RecurrenceEnd.Until)?.date
|
|
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
|
val wanted = minOf(limit, maxCount)
|
|
if (wanted <= 0) return emptyList()
|
|
if (until != null && start > until) return emptyList()
|
|
|
|
// DTSTART is in the recurrence set whatever the rule picks, so seed with it
|
|
// and let the walk skip anything landing on or before it.
|
|
val result = mutableListOf(start)
|
|
var period = 0
|
|
// Periods can yield nothing (a skipped 31st), so the cap counts periods
|
|
// examined rather than dates found.
|
|
while (result.size < wanted && period < MAX_PERIODS) {
|
|
for (date in occurrencesInPeriod(period, start)) {
|
|
if (date <= start) continue
|
|
if (until != null && date > until) return result
|
|
result += date
|
|
if (result.size == wanted) return result
|
|
}
|
|
period++
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
|
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
|
when (freq) {
|
|
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
|
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
|
RecurrenceFreq.Monthly -> {
|
|
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
|
// plus() clamps into the shorter month, but the rule skips such a
|
|
// period — so a clamped date means "not this month".
|
|
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
|
}
|
|
RecurrenceFreq.Yearly ->
|
|
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
|
}
|
|
|
|
/**
|
|
* One weekly block: every picked weekday inside the week that begins
|
|
* `period * interval` weeks after the start's own week, in weekday order. With
|
|
* no picks the rule simply repeats the start's weekday.
|
|
*/
|
|
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
|
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
|
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
|
DAYS_PER_WEEK
|
|
val weekStart = start
|
|
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
|
.plus(period * interval, DateTimeUnit.WEEK)
|
|
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
|
weekStart.plus(
|
|
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
|
DateTimeUnit.DAY,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a run of [occurrences] starting at [start] leaves its starting year,
|
|
* i.e. whether showing them without a year would be ambiguous — a yearly rule
|
|
* would otherwise read as the same date repeated.
|
|
*/
|
|
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
|
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
|
|
|
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
|
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
|
runCatching { LocalDate(year, month, day) }.getOrNull()
|
|
|
|
private const val DAYS_PER_WEEK = 7
|
|
|
|
/**
|
|
* How many repetitions to examine before giving up: generous enough for the
|
|
* sparsest rule the picker can build, bounded so a rule whose occurrences all
|
|
* fall outside its own UNTIL can't spin.
|
|
*/
|
|
private const val MAX_PERIODS = 2_000
|