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( @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() {

View File

@ -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?,

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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(

View File

@ -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
}
} }