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(
|
||||
entities = [CollectionEntity::class, ItemEntity::class],
|
||||
version = 9,
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue