Add monthly-ordinal recurrence support ("the 3rd Thursday", "the last Friday")
Extends the Repeats picker's BYDAY support (which deliberately excluded ordinaled rules) to cover FREQ=MONTHLY with a single ordinaled BYDAY, via RFC 5545's ByDay(num, weekday) - "the 3rd Thursday of the month" or "the last Friday", not just a fixed day-of-month. - CalendarItem.recurrenceByDayOrdinal (1..4, or -1 for "last") pairs with the existing recurrenceByDay field, reused as a singleton for this mode. - RecurrenceUtils gets a new walker using TemporalAdjusters.dayOfWeekInMonth, correctly skipping months where the Nth weekday doesn't exist (RFC 5545: no occurrence that month, not a rollover) and correctly preserving local time-of-day across DST boundaries. - Repeats picker: a 2-item "Monthly on day N" vs "Monthly on the Nth <weekday>" dropdown, weekday always derived from the current Starts/Due date rather than picked separately. Verified live against the real Nextcloud server (created a "3rd Friday" event, confirmed it round-tripped to the correct RRULE and resolved to the correct date next month) and cleaned up afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3d0f6589a9
commit
f3f8d76cda
|
|
@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [CollectionEntity::class, ItemEntity::class],
|
entities = [CollectionEntity::class, ItemEntity::class],
|
||||||
version = 9,
|
version = 10,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ data class ItemEntity(
|
||||||
val recurrenceCount: Int?,
|
val recurrenceCount: Int?,
|
||||||
val recurrenceUntil: Long?,
|
val recurrenceUntil: Long?,
|
||||||
val recurrenceByDay: String, // comma-separated java.time.DayOfWeek names, "" if none (WEEKLY only)
|
val recurrenceByDay: String, // comma-separated java.time.DayOfWeek names, "" if none (WEEKLY only)
|
||||||
|
val recurrenceByDayOrdinal: Int?, // MONTHLY only: 1..4 or -1 ("last") - see CalendarItem
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
val recurrenceMasterStart: Long?,
|
val recurrenceMasterStart: Long?,
|
||||||
val recurrenceMasterEnd: Long?,
|
val recurrenceMasterEnd: Long?,
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,17 @@ data class CalendarItem(
|
||||||
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
|
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
|
||||||
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
|
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
|
||||||
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
|
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
|
||||||
val recurrenceByDay: Set<DayOfWeek> = emptySet(), // WEEKLY only: which days ("every Mon/Wed/Fri") - empty means every occurrence of the interval, i.e. just DTSTART's own weekday
|
val recurrenceByDay: Set<DayOfWeek> = emptySet(), // WEEKLY: which days ("every Mon/Wed/Fri"); MONTHLY (with recurrenceByDayOrdinal set): the single weekday of "the Nth <weekday>"
|
||||||
|
val recurrenceByDayOrdinal: Int? = null, // MONTHLY only: which occurrence of recurrenceByDay's weekday in the month - 1..4, or -1 for "last". Null means plain "day N of the month" (DTSTART's own day-of-month) instead.
|
||||||
val isRecurring: Boolean = false, // true for ANY RRULE, even ones recurrenceFrequency can't represent -
|
val isRecurring: Boolean = false, // true for ANY RRULE, even ones recurrenceFrequency can't represent -
|
||||||
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
|
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
|
||||||
// the master date - see recurrenceMaster* below for that
|
// the master date - see recurrenceMaster* below for that
|
||||||
|
|
||||||
// The *true*, unresolved master DTSTART/DTEND/DUE for a recurring item - only meaningful
|
// The *true*, unresolved master DTSTART/DTEND/DUE for a recurring item - only meaningful
|
||||||
// when isRecurring. The edit screen shows/edits these instead of start/end/due for a
|
// when isRecurring. The edit screen edits [start]/[end]/[due] (this occurrence's own date)
|
||||||
// recurring item, so editing the series' actual start time is possible without the bug
|
// even for a recurring item; saving "all events" then shifts these master fields by however
|
||||||
// where saving would've overwritten the master date with the resolved next occurrence.
|
// far the user moved that occurrence (see ItemEditViewModel.applyMasterDateShift) rather than
|
||||||
|
// exposing a separate master-only date field.
|
||||||
val recurrenceMasterStart: Long? = null,
|
val recurrenceMasterStart: Long? = null,
|
||||||
val recurrenceMasterEnd: Long? = null, // events only
|
val recurrenceMasterEnd: Long? = null, // events only
|
||||||
val recurrenceMasterDue: Long? = null, // tasks only
|
val recurrenceMasterDue: Long? = null, // tasks only
|
||||||
|
|
|
||||||
|
|
@ -507,6 +507,7 @@ class NextcloudRepository(context: Context) {
|
||||||
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
||||||
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
||||||
recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
|
recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
|
||||||
|
recurrenceByDayOrdinal = recurrenceByDayOrdinal,
|
||||||
isRecurring = isRecurring,
|
isRecurring = isRecurring,
|
||||||
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
||||||
occurrenceDate = occurrenceDate,
|
occurrenceDate = occurrenceDate,
|
||||||
|
|
@ -522,7 +523,8 @@ class NextcloudRepository(context: Context) {
|
||||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
||||||
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
|
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
|
||||||
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
||||||
recurrenceByDay = recurrenceByDay.joinToString(",") { it.name }, isRecurring = isRecurring,
|
recurrenceByDay = recurrenceByDay.joinToString(",") { it.name },
|
||||||
|
recurrenceByDayOrdinal = recurrenceByDayOrdinal, isRecurring = isRecurring,
|
||||||
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
||||||
occurrenceDate = occurrenceDate,
|
occurrenceDate = occurrenceDate,
|
||||||
syncStatus = syncStatus.name,
|
syncStatus = syncStatus.name,
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ import com.homelab.ncal.util.priorityLabel
|
||||||
import com.homelab.ncal.util.statusLabel
|
import com.homelab.ncal.util.statusLabel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -624,9 +627,13 @@ private fun RecurrencePicker(
|
||||||
onClick = {
|
onClick = {
|
||||||
onUpdate { cur ->
|
onUpdate { cur ->
|
||||||
if (freq == null) {
|
if (freq == null) {
|
||||||
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null, recurrenceByDay = emptySet())
|
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null, recurrenceByDay = emptySet(), recurrenceByDayOrdinal = null)
|
||||||
} else {
|
} else {
|
||||||
cur.copy(recurrenceFrequency = freq, recurrenceByDay = if (freq == RecurrenceFrequency.WEEKLY) cur.recurrenceByDay else emptySet())
|
cur.copy(
|
||||||
|
recurrenceFrequency = freq,
|
||||||
|
recurrenceByDay = if (freq == RecurrenceFrequency.WEEKLY) cur.recurrenceByDay else emptySet(),
|
||||||
|
recurrenceByDayOrdinal = null // switching frequency always resets to the default "day N of the month" mode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expanded = false
|
expanded = false
|
||||||
|
|
@ -664,6 +671,23 @@ private fun RecurrencePicker(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (freq == RecurrenceFrequency.MONTHLY) {
|
||||||
|
MonthlyModePicker(
|
||||||
|
anchorMillis = item.start ?: item.due,
|
||||||
|
ordinal = item.recurrenceByDayOrdinal,
|
||||||
|
onModeChange = { newOrdinal ->
|
||||||
|
onUpdate { cur ->
|
||||||
|
val anchor = (cur.start ?: cur.due)?.let { millisToLocalDate(it) }
|
||||||
|
if (newOrdinal == null || anchor == null) {
|
||||||
|
cur.copy(recurrenceByDay = emptySet(), recurrenceByDayOrdinal = null)
|
||||||
|
} else {
|
||||||
|
cur.copy(recurrenceByDay = setOf(anchor.dayOfWeek), recurrenceByDayOrdinal = newOrdinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||||
RecurrenceEndPicker(
|
RecurrenceEndPicker(
|
||||||
count = item.recurrenceCount,
|
count = item.recurrenceCount,
|
||||||
|
|
@ -695,6 +719,71 @@ private fun WeekdayChipRow(selected: Set<DayOfWeek>, onToggle: (DayOfWeek) -> Un
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun millisToLocalDate(millis: Long): LocalDate =
|
||||||
|
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
|
||||||
|
/** Which occurrence of its own weekday [date] is within its month - 1..4, or -1 if it's the
|
||||||
|
* *last* occurrence of that weekday that month (preferred over a hard "4"/"5" once it's the
|
||||||
|
* last one, since "last Thursday" reads better than "4th Thursday" and also keeps working in
|
||||||
|
* months where that weekday only occurs 4 times instead of 5). */
|
||||||
|
private fun ordinalOfWeekdayInMonth(date: LocalDate): Int {
|
||||||
|
val ordinal = (date.dayOfMonth - 1) / 7 + 1
|
||||||
|
val isLastOccurrence = date.dayOfMonth + 7 > date.lengthOfMonth()
|
||||||
|
return if (isLastOccurrence) -1 else ordinal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ordinalLabel(ordinal: Int): String = when (ordinal) {
|
||||||
|
1 -> "1st"
|
||||||
|
2 -> "2nd"
|
||||||
|
3 -> "3rd"
|
||||||
|
4 -> "4th"
|
||||||
|
-1 -> "last"
|
||||||
|
else -> "${ordinal}th"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MONTHLY-only: choose between "day N of the month" (the plain default - no BYDAY, DTSTART's own
|
||||||
|
* day-of-month) and "the Nth <weekday>" (an ordinaled BYDAY like "the 3rd Thursday" or "the last
|
||||||
|
* Friday"). The weekday itself always comes from [anchorMillis] (this occurrence's own start/due
|
||||||
|
* date) rather than being picked separately, matching how other calendar apps present this choice.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MonthlyModePicker(anchorMillis: Long?, ordinal: Int?, onModeChange: (Int?) -> Unit) {
|
||||||
|
val anchor = anchorMillis?.let { millisToLocalDate(it) } ?: return
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val dayOfMonthLabel = "Monthly on day ${anchor.dayOfMonth}"
|
||||||
|
val weekdayName = anchor.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, Locale.getDefault())
|
||||||
|
val defaultOrdinal = ordinalOfWeekdayInMonth(anchor)
|
||||||
|
val ordinalOptionLabel = "Monthly on the ${ordinalLabel(ordinal ?: defaultOrdinal)} $weekdayName"
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = if (ordinal == null) dayOfMonthLabel else ordinalOptionLabel,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(dayOfMonthLabel) },
|
||||||
|
onClick = { onModeChange(null); expanded = false }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(ordinalOptionLabel) },
|
||||||
|
onClick = { onModeChange(defaultOrdinal); expanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
|
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
|
||||||
|
|
@ -39,19 +39,24 @@ object IcsMapper {
|
||||||
TaskStatus.CANCELLED -> Status.cancelled()
|
TaskStatus.CANCELLED -> Status.cancelled()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, an
|
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, a
|
||||||
* ordinal BYDAY like "3rd Monday", BYDAY on anything but WEEKLY, or a frequency other than
|
* frequency other than the 4 simple ones, or a BYDAY shape other than the two supported
|
||||||
* the 4 simple ones). A plain WEEKLY rule with a BYDAY of unordinaled days ("every Mon/Wed/
|
* ones below). Two BYDAY shapes *are* representable - see [toByDaySet]/[toByDayOrdinal] -
|
||||||
* Fri") *is* representable - see [recurrenceByDaySet] - so this must never treat "has BYDAY"
|
* so this must never treat "has BYDAY" as automatically unsupported, or an edit to either
|
||||||
* as automatically unsupported like it once did, or a BYDAY edit would keep getting
|
* would keep getting silently dropped back to rawIcs-only on the next unrelated save:
|
||||||
* silently dropped back to rawIcs-only on the next unrelated save. */
|
* - WEEKLY with one or more *unordinaled* days ("every Mon/Wed/Fri")
|
||||||
|
* - MONTHLY with exactly one *ordinaled* day ("the 3rd Thursday", "the last Friday") */
|
||||||
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
|
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
|
||||||
if (this == null) return null
|
if (this == null) return null
|
||||||
val isSimple = byMonth.isEmpty() && byMonthDay.isEmpty() &&
|
val isSimple = byMonth.isEmpty() && byMonthDay.isEmpty() &&
|
||||||
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
|
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
|
||||||
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
||||||
if (!isSimple) return null
|
if (!isSimple) return null
|
||||||
if (byDay.isNotEmpty() && (frequency != Frequency.WEEKLY || byDay.any { it.num != null })) return null
|
if (byDay.isNotEmpty()) {
|
||||||
|
val weeklyPlainDays = frequency == Frequency.WEEKLY && byDay.all { it.num == null }
|
||||||
|
val monthlyOrdinaledDay = frequency == Frequency.MONTHLY && byDay.size == 1 && byDay[0].num != null
|
||||||
|
if (!weeklyPlainDays && !monthlyOrdinaledDay) return null
|
||||||
|
}
|
||||||
return when (frequency) {
|
return when (frequency) {
|
||||||
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
||||||
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
|
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
|
||||||
|
|
@ -61,11 +66,19 @@ object IcsMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only meaningful when [toRecurrenceFrequency] didn't return null - a plain (unordinaled)
|
/** Only meaningful when [toRecurrenceFrequency] didn't return null - the day(s) of week,
|
||||||
* BYDAY list, translated from biweekly's own [biweekly.util.DayOfWeek] to [java.time.DayOfWeek]. */
|
* translated from biweekly's own [biweekly.util.DayOfWeek] to [java.time.DayOfWeek]. For a
|
||||||
|
* WEEKLY rule this can be multiple days; for a MONTHLY ordinaled rule it's always the single
|
||||||
|
* day paired with [toByDayOrdinal]. */
|
||||||
private fun Recurrence?.toByDaySet(): Set<java.time.DayOfWeek> =
|
private fun Recurrence?.toByDaySet(): Set<java.time.DayOfWeek> =
|
||||||
this?.byDay?.mapNotNull { it.day?.toJavaDayOfWeek() }?.toSet() ?: emptySet()
|
this?.byDay?.mapNotNull { it.day?.toJavaDayOfWeek() }?.toSet() ?: emptySet()
|
||||||
|
|
||||||
|
/** Only meaningful for a MONTHLY rule where [toRecurrenceFrequency] didn't return null - the
|
||||||
|
* "Nth" in "the Nth <weekday> of the month" (1..4, or -1 for "last"). Null for a WEEKLY rule
|
||||||
|
* (or a MONTHLY rule with no BYDAY at all, i.e. plain "day N of the month"). */
|
||||||
|
private fun Recurrence?.toByDayOrdinal(): Int? =
|
||||||
|
this?.byDay?.singleOrNull()?.num
|
||||||
|
|
||||||
private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): java.time.DayOfWeek? = when (this) {
|
private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): java.time.DayOfWeek? = when (this) {
|
||||||
biweekly.util.DayOfWeek.MONDAY -> java.time.DayOfWeek.MONDAY
|
biweekly.util.DayOfWeek.MONDAY -> java.time.DayOfWeek.MONDAY
|
||||||
biweekly.util.DayOfWeek.TUESDAY -> java.time.DayOfWeek.TUESDAY
|
biweekly.util.DayOfWeek.TUESDAY -> java.time.DayOfWeek.TUESDAY
|
||||||
|
|
@ -104,6 +117,10 @@ object IcsMapper {
|
||||||
item.recurrenceUntil?.let { builder.until(Date(it), true) }
|
item.recurrenceUntil?.let { builder.until(Date(it), true) }
|
||||||
if (freq == RecurrenceFrequency.WEEKLY && item.recurrenceByDay.isNotEmpty()) {
|
if (freq == RecurrenceFrequency.WEEKLY && item.recurrenceByDay.isNotEmpty()) {
|
||||||
builder.byDay(item.recurrenceByDay.map { it.toBiweeklyDayOfWeek() })
|
builder.byDay(item.recurrenceByDay.map { it.toBiweeklyDayOfWeek() })
|
||||||
|
} else if (freq == RecurrenceFrequency.MONTHLY && item.recurrenceByDayOrdinal != null) {
|
||||||
|
item.recurrenceByDay.singleOrNull()?.let { day ->
|
||||||
|
builder.byDay(item.recurrenceByDayOrdinal, day.toBiweeklyDayOfWeek())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +259,7 @@ object IcsMapper {
|
||||||
recurrenceCount = recurrence?.count,
|
recurrenceCount = recurrence?.count,
|
||||||
recurrenceUntil = recurrence?.until?.time,
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
recurrenceByDay = recurrence.toByDaySet(),
|
recurrenceByDay = recurrence.toByDaySet(),
|
||||||
|
recurrenceByDayOrdinal = recurrence.toByDayOrdinal(),
|
||||||
isRecurring = recurrence != null,
|
isRecurring = recurrence != null,
|
||||||
recurrenceMasterStart = originalStart,
|
recurrenceMasterStart = originalStart,
|
||||||
recurrenceMasterEnd = originalEnd,
|
recurrenceMasterEnd = originalEnd,
|
||||||
|
|
@ -310,6 +328,7 @@ object IcsMapper {
|
||||||
recurrenceCount = recurrence?.count,
|
recurrenceCount = recurrence?.count,
|
||||||
recurrenceUntil = recurrence?.until?.time,
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
recurrenceByDay = recurrence.toByDaySet(),
|
recurrenceByDay = recurrence.toByDaySet(),
|
||||||
|
recurrenceByDayOrdinal = recurrence.toByDayOrdinal(),
|
||||||
isRecurring = recurrence != null,
|
isRecurring = recurrence != null,
|
||||||
recurrenceMasterStart = originalStart,
|
recurrenceMasterStart = originalStart,
|
||||||
recurrenceMasterDue = originalDue,
|
recurrenceMasterDue = originalDue,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import biweekly.util.Frequency
|
||||||
import biweekly.util.Recurrence
|
import biweekly.util.Recurrence
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.YearMonth
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
@ -15,13 +16,15 @@ import java.time.temporal.TemporalAdjusters
|
||||||
* occurrences for us in the calendar-data payload. Without this, a weekly meeting or a
|
* occurrences for us in the calendar-data payload. Without this, a weekly meeting or a
|
||||||
* yearly birthday would show once, forever, on the date it was first created.
|
* yearly birthday would show once, forever, on the date it was first created.
|
||||||
*
|
*
|
||||||
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
|
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT, UNTIL,
|
||||||
* UNTIL, (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
|
* (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
|
||||||
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), and EXDATE (single-occurrence
|
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), (MONTHLY only) a single ordinaled
|
||||||
* deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It intentionally
|
* BYDAY ("the 3rd Thursday", "the last Friday" - see
|
||||||
* does not implement BYMONTH/BYMONTHDAY/BYSETPOS/ordinaled BYDAY/etc - those cover a small
|
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDayOrdinal]), and EXDATE (single-
|
||||||
* minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday"). Anything using
|
* occurrence deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It
|
||||||
* those rule parts still round-trips correctly on save/edit (the raw ICS is preserved via
|
* intentionally does not implement BYMONTH/BYMONTHDAY/BYSETPOS/multi-day-ordinaled-BYDAY/etc -
|
||||||
|
* those cover a small minority of everyday personal-calendar recurrences. Anything using those
|
||||||
|
* rule parts still round-trips correctly on save/edit (the raw ICS is preserved via
|
||||||
* [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be *re-dated* for
|
* [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be *re-dated* for
|
||||||
* display, and will show at its original occurrence like before.
|
* display, and will show at its original occurrence like before.
|
||||||
*/
|
*/
|
||||||
|
|
@ -29,6 +32,7 @@ object RecurrenceUtils {
|
||||||
|
|
||||||
private const val MAX_ITERATIONS = 10_000
|
private const val MAX_ITERATIONS = 10_000
|
||||||
private const val MAX_BYDAY_DAYS = 3660 // ~10 years of daily steps, an upper bound on the walk-forward search
|
private const val MAX_BYDAY_DAYS = 3660 // ~10 years of daily steps, an upper bound on the walk-forward search
|
||||||
|
private const val MAX_MONTHLY_ORDINAL_MONTHS = 1200 // 100 years of monthly steps
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis]
|
* Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis]
|
||||||
|
|
@ -57,6 +61,14 @@ object RecurrenceUtils {
|
||||||
} else {
|
} else {
|
||||||
emptySet()
|
emptySet()
|
||||||
}
|
}
|
||||||
|
val monthlyOrdinalDay: Pair<Int, DayOfWeek>? = if (freq == Frequency.MONTHLY && recurrence.byDay.size == 1) {
|
||||||
|
val bd = recurrence.byDay.first()
|
||||||
|
val num = bd.num
|
||||||
|
val day = bd.day?.toJavaDayOfWeek()
|
||||||
|
if (num != null && day != null) num to day else null
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
val originalStart = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
|
val originalStart = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
|
||||||
val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
|
val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
|
||||||
|
|
@ -64,6 +76,9 @@ object RecurrenceUtils {
|
||||||
if (byDayOfWeek.isNotEmpty()) {
|
if (byDayOfWeek.isNotEmpty()) {
|
||||||
return nextWeeklyByDayOccurrence(originalStart, reference, interval, byDayOfWeek, until, maxCount, exceptionDates)
|
return nextWeeklyByDayOccurrence(originalStart, reference, interval, byDayOfWeek, until, maxCount, exceptionDates)
|
||||||
}
|
}
|
||||||
|
if (monthlyOrdinalDay != null) {
|
||||||
|
return nextMonthlyOrdinalOccurrence(originalStart, reference, interval, monthlyOrdinalDay.second, monthlyOrdinalDay.first, until, maxCount, exceptionDates)
|
||||||
|
}
|
||||||
|
|
||||||
var current = originalStart
|
var current = originalStart
|
||||||
var occurrenceIndex = 1
|
var occurrenceIndex = 1
|
||||||
|
|
@ -129,6 +144,46 @@ object RecurrenceUtils {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks forward month-by-month (in steps of [interval]) from [originalStart]'s month,
|
||||||
|
* resolving "the Nth [weekday] of the month" via [TemporalAdjusters.dayOfWeekInMonth] -
|
||||||
|
* [ordinal] positive counts from the start of the month (1 = first), negative counts from
|
||||||
|
* the end (-1 = last). A month where the Nth occurrence doesn't exist (e.g. a "5th Monday"
|
||||||
|
* request in a month with only 4) contributes no occurrence and is skipped entirely, matching
|
||||||
|
* RFC 5545 rather than rolling over into a neighboring month.
|
||||||
|
*/
|
||||||
|
private fun nextMonthlyOrdinalOccurrence(
|
||||||
|
originalStart: ZonedDateTime,
|
||||||
|
reference: ZonedDateTime,
|
||||||
|
interval: Int,
|
||||||
|
weekday: DayOfWeek,
|
||||||
|
ordinal: Int,
|
||||||
|
until: ZonedDateTime?,
|
||||||
|
maxCount: Int?,
|
||||||
|
exceptionDates: Set<Long>
|
||||||
|
): Long? {
|
||||||
|
var monthCursor = YearMonth.from(originalStart.toLocalDate())
|
||||||
|
var occurrenceIndex = 0
|
||||||
|
var iterations = 0
|
||||||
|
|
||||||
|
while (iterations++ < MAX_MONTHLY_ORDINAL_MONTHS) {
|
||||||
|
val candidateDate = runCatching {
|
||||||
|
monthCursor.atDay(1).with(TemporalAdjusters.dayOfWeekInMonth(ordinal, weekday))
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (candidateDate != null && YearMonth.from(candidateDate) == monthCursor) {
|
||||||
|
occurrenceIndex++
|
||||||
|
if (maxCount != null && occurrenceIndex > maxCount) return null
|
||||||
|
val occurrence = originalStart.withLocalDate(candidateDate)
|
||||||
|
if (until != null && occurrence.isAfter(until)) return null
|
||||||
|
val occurrenceMillis = occurrence.toInstant().toEpochMilli()
|
||||||
|
if (occurrenceMillis !in exceptionDates && !occurrence.isBefore(reference)) return occurrenceMillis
|
||||||
|
}
|
||||||
|
monthCursor = monthCursor.plusMonths(interval.toLong())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun ZonedDateTime.withLocalDate(date: java.time.LocalDate): ZonedDateTime =
|
private fun ZonedDateTime.withLocalDate(date: java.time.LocalDate): ZonedDateTime =
|
||||||
date.atTime(toLocalTime()).atZone(zone)
|
date.atTime(toLocalTime()).atZone(zone)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ class IcsMapperTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an ordinaled BYDAY like 'the 2nd Monday' is still left completely untouched`() {
|
fun `a single ordinaled BYDAY like 'the 2nd Monday' is representable by the picker`() {
|
||||||
val ordinaledIcs = ics(
|
val ordinaledIcs = ics(
|
||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
|
|
@ -126,12 +126,38 @@ class IcsMapperTest {
|
||||||
"END:VCALENDAR"
|
"END:VCALENDAR"
|
||||||
)
|
)
|
||||||
val item = IcsMapper.parse(ordinaledIcs, href = "https://example.com/e6.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
val item = IcsMapper.parse(ordinaledIcs, href = "https://example.com/e6.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
assertEquals("an ordinaled BYDAY isn't representable by the picker", null, item.recurrenceFrequency)
|
assertEquals(RecurrenceFrequency.MONTHLY, item.recurrenceFrequency)
|
||||||
|
assertEquals(2, item.recurrenceByDayOrdinal)
|
||||||
|
assertEquals(setOf(java.time.DayOfWeek.MONDAY), item.recurrenceByDay)
|
||||||
|
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
|
||||||
|
|
||||||
|
assertTrue("BYDAY=2MO must survive the round-trip", rebuiltIcs.contains("BYDAY=2MO"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple ordinaled BYDAY entries (e_g_ 1st and 3rd Monday) are still left completely untouched`() {
|
||||||
|
val multiOrdinaledIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-multi-ordinaled@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DTSTART:20200106T090000Z",
|
||||||
|
"DTEND:20200106T100000Z",
|
||||||
|
"SUMMARY:Twice-monthly board meeting",
|
||||||
|
"RRULE:FREQ=MONTHLY;BYDAY=1MO,3MO",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(multiOrdinaledIcs, href = "https://example.com/e7.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertEquals("more than one ordinaled BYDAY isn't representable by the picker", null, item.recurrenceFrequency)
|
||||||
assertTrue("but the item is still recognized as recurring", item.isRecurring)
|
assertTrue("but the item is still recognized as recurring", item.isRecurring)
|
||||||
|
|
||||||
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
|
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
|
||||||
|
|
||||||
assertTrue("original ordinaled BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=2MO"))
|
assertTrue("original multi-ordinaled BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=1MO,3MO"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -163,6 +189,60 @@ class IcsMapperTest {
|
||||||
assertTrue(rebuiltIcs.contains("DTSTART:20260101T090000Z"))
|
assertTrue(rebuiltIcs.contains("DTSTART:20260101T090000Z"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adding a monthly-ordinal recurrence via the picker (the 3rd Thursday) builds a correct RRULE`() {
|
||||||
|
val plainEventIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-monthly-ordinal@example.com",
|
||||||
|
"DTSTAMP:20260101T090000Z",
|
||||||
|
"DTSTART:20260101T090000Z",
|
||||||
|
"DTEND:20260101T100000Z",
|
||||||
|
"SUMMARY:Board meeting",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(plainEventIcs, href = "https://example.com/e-mo.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
|
||||||
|
val madeRecurring = item.copy(
|
||||||
|
recurrenceFrequency = RecurrenceFrequency.MONTHLY,
|
||||||
|
recurrenceByDay = setOf(java.time.DayOfWeek.THURSDAY),
|
||||||
|
recurrenceByDayOrdinal = 3
|
||||||
|
)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(madeRecurring)
|
||||||
|
|
||||||
|
assertTrue(rebuiltIcs.contains("FREQ=MONTHLY"))
|
||||||
|
assertTrue(rebuiltIcs.contains("BYDAY=3TH"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a monthly-ordinal recurrence back to plain 'day N of the month' clears the BYDAY`() {
|
||||||
|
val ordinaledIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-ordinaled-clear@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DTSTART:20200106T090000Z",
|
||||||
|
"DTEND:20200106T100000Z",
|
||||||
|
"SUMMARY:Board meeting",
|
||||||
|
"RRULE:FREQ=MONTHLY;BYDAY=1MO",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(ordinaledIcs, href = "https://example.com/e-mo2.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertEquals(1, item.recurrenceByDayOrdinal)
|
||||||
|
|
||||||
|
val switchedToPlain = item.copy(recurrenceByDay = emptySet(), recurrenceByDayOrdinal = null)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(switchedToPlain)
|
||||||
|
|
||||||
|
assertTrue(rebuiltIcs.contains("FREQ=MONTHLY"))
|
||||||
|
assertFalse("BYDAY must be gone once switched back to plain day-of-month mode", rebuiltIcs.contains("BYDAY"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `changing recurrence interval on a simple recurring event updates RRULE and keeps master DTSTART`() {
|
fun `changing recurrence interval on a simple recurring event updates RRULE and keeps master DTSTART`() {
|
||||||
val simpleRecurringIcs = ics(
|
val simpleRecurringIcs = ics(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,17 @@ class RecurrenceUtilsTest {
|
||||||
|
|
||||||
private fun millis(iso: String): Long = ZonedDateTime.parse(iso).withZoneSameInstant(zone).toInstant().toEpochMilli()
|
private fun millis(iso: String): Long = ZonedDateTime.parse(iso).withZoneSameInstant(zone).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
/** Like [millis], but expresses the expected instant as "[referenceMillis]'s local
|
||||||
|
* time-of-day, on [localDate]" - mirrors how the production code (RecurrenceUtils.
|
||||||
|
* withLocalDate) advances a recurring time, which is by design DST-aware (a 9am-local
|
||||||
|
* meeting stays 9am-local, not a fixed UTC offset, across a DST boundary). A test crossing
|
||||||
|
* a DST boundary must use this instead of hardcoding a "...Z" UTC string, or the two won't
|
||||||
|
* agree on the day the clocks actually change. */
|
||||||
|
private fun millisAtLocalTimeOf(referenceMillis: Long, localDate: String): Long {
|
||||||
|
val localTime = java.time.Instant.ofEpochMilli(referenceMillis).atZone(zone).toLocalTime()
|
||||||
|
return java.time.LocalDate.parse(localDate).atTime(localTime).atZone(zone).toInstant().toEpochMilli()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `weekly MonWedFri rule advances to the next selected weekday, not just 7 days later`() {
|
fun `weekly MonWedFri rule advances to the next selected weekday, not just 7 days later`() {
|
||||||
// DTSTART is a Monday 2024-01-01T09:00. Reference is the following Tuesday - the next
|
// DTSTART is a Monday 2024-01-01T09:00. Reference is the following Tuesday - the next
|
||||||
|
|
@ -96,4 +107,91 @@ class RecurrenceUtilsTest {
|
||||||
val afterWednesday = millis("2024-01-03T10:00:00Z")
|
val afterWednesday = millis("2024-01-03T10:00:00Z")
|
||||||
assertEquals(millis("2024-01-05T09:00:00Z"), RecurrenceUtils.nextOccurrence(start, recurrence, afterWednesday)) // Friday
|
assertEquals(millis("2024-01-05T09:00:00Z"), RecurrenceUtils.nextOccurrence(start, recurrence, afterWednesday)) // Friday
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- MONTHLY + ordinaled BYDAY ("the 3rd Thursday", "the last Friday") ----------
|
||||||
|
// 2024-01 Thursdays: 4, 11, 18, 25. 2024-02 Thursdays: 1, 8, 15, 22, 29 (leap year, 5 of them).
|
||||||
|
// 2024-03 Thursdays: 7, 14, 21, 28. 2024-01 Fridays: 5, 12, 19, 26 (last = 26).
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule resolves to the Nth weekday within DTSTART's own month`() {
|
||||||
|
// DTSTART is the 1st Thursday of January, but the rule wants the 3rd - RFC 5545 still
|
||||||
|
// generates the 3rd Thursday of DTSTART's own month as the first occurrence.
|
||||||
|
val start = millis("2024-01-04T09:00:00Z") // 1st Thursday of Jan
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(3, DayOfWeek.THURSDAY).build()
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, start)
|
||||||
|
|
||||||
|
assertEquals(millis("2024-01-18T09:00:00Z"), next) // 3rd Thursday of January
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule advances to the following month once this month's occurrence has passed`() {
|
||||||
|
val start = millis("2024-01-04T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(3, DayOfWeek.THURSDAY).build()
|
||||||
|
val reference = millis("2024-01-19T00:00:00Z") // just after January's 3rd Thursday
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
|
||||||
|
|
||||||
|
assertEquals(millis("2024-02-15T09:00:00Z"), next) // 3rd Thursday of February
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule skips a month where the Nth weekday doesn't exist`() {
|
||||||
|
// January 2024 only has 4 Thursdays - "the 5th Thursday" contributes no occurrence that
|
||||||
|
// month at all (not rolled into February), so the first real occurrence is Feb's 5th
|
||||||
|
// Thursday (Feb 2024 is a leap-year February with 5 Thursdays).
|
||||||
|
val start = millis("2024-01-04T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(5, DayOfWeek.THURSDAY).build()
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, start)
|
||||||
|
|
||||||
|
assertEquals(millis("2024-02-29T09:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule supports 'the last' weekday via a negative ordinal`() {
|
||||||
|
val start = millis("2024-01-01T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(-1, DayOfWeek.FRIDAY).build()
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, start)
|
||||||
|
|
||||||
|
assertEquals(millis("2024-01-26T09:00:00Z"), next) // last Friday of January
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule with INTERVAL 2 skips the off month`() {
|
||||||
|
val start = millis("2024-01-04T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).interval(2).byDay(3, DayOfWeek.THURSDAY).build()
|
||||||
|
val reference = millis("2024-01-19T00:00:00Z") // just after January's (active month's) occurrence
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
|
||||||
|
|
||||||
|
// February is skipped (inactive); March 21 is a DST boundary away from January in this
|
||||||
|
// JVM's default zone, so the expectation is expressed via the same local-time-preserving
|
||||||
|
// logic the implementation itself uses, not a fixed UTC offset.
|
||||||
|
assertEquals(millisAtLocalTimeOf(start, "2024-03-21"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule respects COUNT`() {
|
||||||
|
val start = millis("2024-01-04T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(3, DayOfWeek.THURSDAY).count(2).build()
|
||||||
|
|
||||||
|
val secondOccurrence = millis("2024-02-15T09:00:00Z")
|
||||||
|
assertEquals(secondOccurrence, RecurrenceUtils.nextOccurrence(start, recurrence, secondOccurrence))
|
||||||
|
|
||||||
|
val pastTheEnd = millis("2024-02-16T00:00:00Z")
|
||||||
|
assertNull("the series only has 2 occurrences", RecurrenceUtils.nextOccurrence(start, recurrence, pastTheEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly ordinal rule skips an EXDATE'd occurrence`() {
|
||||||
|
val start = millis("2024-01-04T09:00:00Z")
|
||||||
|
val recurrence = Recurrence.Builder(Frequency.MONTHLY).byDay(3, DayOfWeek.THURSDAY).build()
|
||||||
|
val exceptionDates = setOf(millis("2024-01-18T09:00:00Z"))
|
||||||
|
|
||||||
|
val next = RecurrenceUtils.nextOccurrence(start, recurrence, start, exceptionDates)
|
||||||
|
|
||||||
|
assertEquals(millis("2024-02-15T09:00:00Z"), next) // January's occurrence is excluded, skip to February
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue