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:
RomanNum3ral 2026-07-18 05:56:25 -04:00
parent 3d0f6589a9
commit f3f8d76cda
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
9 changed files with 373 additions and 27 deletions

View File

@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
@Database(
entities = [CollectionEntity::class, ItemEntity::class],
version = 9,
version = 10,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {

View File

@ -33,6 +33,7 @@ data class ItemEntity(
val recurrenceCount: Int?,
val recurrenceUntil: Long?,
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 recurrenceMasterStart: Long?,
val recurrenceMasterEnd: Long?,

View File

@ -55,15 +55,17 @@ data class CalendarItem(
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
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 -
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
// the master date - see recurrenceMaster* below for that
// 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
// recurring item, so editing the series' actual start time is possible without the bug
// where saving would've overwritten the master date with the resolved next occurrence.
// when isRecurring. The edit screen edits [start]/[end]/[due] (this occurrence's own date)
// even for a recurring item; saving "all events" then shifts these master fields by however
// far the user moved that occurrence (see ItemEditViewModel.applyMasterDateShift) rather than
// exposing a separate master-only date field.
val recurrenceMasterStart: Long? = null,
val recurrenceMasterEnd: Long? = null, // events only
val recurrenceMasterDue: Long? = null, // tasks only

View File

@ -507,6 +507,7 @@ class NextcloudRepository(context: Context) {
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
recurrenceByDayOrdinal = recurrenceByDayOrdinal,
isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
occurrenceDate = occurrenceDate,
@ -522,7 +523,8 @@ class NextcloudRepository(context: Context) {
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
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,
occurrenceDate = occurrenceDate,
syncStatus = syncStatus.name,

View File

@ -61,6 +61,9 @@ import com.homelab.ncal.util.priorityLabel
import com.homelab.ncal.util.statusLabel
import java.text.SimpleDateFormat
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.WeekFields
import java.util.Calendar
import java.util.Date
@ -624,9 +627,13 @@ private fun RecurrencePicker(
onClick = {
onUpdate { cur ->
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 {
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
@ -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))
RecurrenceEndPicker(
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 }
@OptIn(ExperimentalMaterial3Api::class)

View File

@ -39,19 +39,24 @@ object IcsMapper {
TaskStatus.CANCELLED -> Status.cancelled()
}
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, an
* ordinal BYDAY like "3rd Monday", BYDAY on anything but WEEKLY, or a frequency other than
* the 4 simple ones). A plain WEEKLY rule with a BYDAY of unordinaled days ("every Mon/Wed/
* Fri") *is* representable - see [recurrenceByDaySet] - so this must never treat "has BYDAY"
* as automatically unsupported like it once did, or a BYDAY edit would keep getting
* silently dropped back to rawIcs-only on the next unrelated save. */
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, a
* frequency other than the 4 simple ones, or a BYDAY shape other than the two supported
* ones below). Two BYDAY shapes *are* representable - see [toByDaySet]/[toByDayOrdinal] -
* so this must never treat "has BYDAY" as automatically unsupported, or an edit to either
* would keep getting 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? {
if (this == null) return null
val isSimple = byMonth.isEmpty() && byMonthDay.isEmpty() &&
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
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) {
Frequency.DAILY -> RecurrenceFrequency.DAILY
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
@ -61,11 +66,19 @@ object IcsMapper {
}
}
/** Only meaningful when [toRecurrenceFrequency] didn't return null - a plain (unordinaled)
* BYDAY list, translated from biweekly's own [biweekly.util.DayOfWeek] to [java.time.DayOfWeek]. */
/** Only meaningful when [toRecurrenceFrequency] didn't return null - the day(s) of week,
* 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> =
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) {
biweekly.util.DayOfWeek.MONDAY -> java.time.DayOfWeek.MONDAY
biweekly.util.DayOfWeek.TUESDAY -> java.time.DayOfWeek.TUESDAY
@ -104,6 +117,10 @@ object IcsMapper {
item.recurrenceUntil?.let { builder.until(Date(it), true) }
if (freq == RecurrenceFrequency.WEEKLY && item.recurrenceByDay.isNotEmpty()) {
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()
}
@ -242,6 +259,7 @@ object IcsMapper {
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
recurrenceByDay = recurrence.toByDaySet(),
recurrenceByDayOrdinal = recurrence.toByDayOrdinal(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterEnd = originalEnd,
@ -310,6 +328,7 @@ object IcsMapper {
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
recurrenceByDay = recurrence.toByDaySet(),
recurrenceByDayOrdinal = recurrence.toByDayOrdinal(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterDue = originalDue,

View File

@ -4,6 +4,7 @@ import biweekly.util.Frequency
import biweekly.util.Recurrence
import java.time.DayOfWeek
import java.time.Instant
import java.time.YearMonth
import java.time.ZoneId
import java.time.ZonedDateTime
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
* 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,
* UNTIL, (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), and EXDATE (single-occurrence
* deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It intentionally
* does not implement BYMONTH/BYMONTHDAY/BYSETPOS/ordinaled BYDAY/etc - those cover a small
* minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday"). Anything using
* those rule parts still round-trips correctly on save/edit (the raw ICS is preserved via
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT, UNTIL,
* (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), (MONTHLY only) a single ordinaled
* BYDAY ("the 3rd Thursday", "the last Friday" - see
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDayOrdinal]), and EXDATE (single-
* occurrence deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It
* 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
* 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_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]
@ -57,6 +61,14 @@ object RecurrenceUtils {
} else {
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 reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
@ -64,6 +76,9 @@ object RecurrenceUtils {
if (byDayOfWeek.isNotEmpty()) {
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 occurrenceIndex = 1
@ -129,6 +144,46 @@ object RecurrenceUtils {
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 =
date.atTime(toLocalTime()).atZone(zone)

View File

@ -110,7 +110,7 @@ class IcsMapperTest {
}
@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(
"BEGIN:VCALENDAR",
"VERSION:2.0",
@ -126,12 +126,38 @@ class IcsMapperTest {
"END:VCALENDAR"
)
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)
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
@ -163,6 +189,60 @@ class IcsMapperTest {
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
fun `changing recurrence interval on a simple recurring event updates RRULE and keeps master DTSTART`() {
val simpleRecurringIcs = ics(

View File

@ -21,6 +21,17 @@ class RecurrenceUtilsTest {
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
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
@ -96,4 +107,91 @@ class RecurrenceUtilsTest {
val afterWednesday = millis("2024-01-03T10:00:00Z")
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
}
}