From f3f8d76cda465a6cbc9cf3cfb559f529c05b74f5 Mon Sep 17 00:00:00 2001 From: RomanNum3ral Date: Sat, 18 Jul 2026 05:56:25 -0400 Subject: [PATCH] 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 " 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 --- .../com/homelab/ncal/data/db/AppDatabase.kt | 2 +- .../com/homelab/ncal/data/db/ItemEntity.kt | 1 + .../homelab/ncal/data/model/CalendarItem.kt | 10 +- .../data/repository/NextcloudRepository.kt | 4 +- .../homelab/ncal/ui/detail/ItemEditScreen.kt | 93 +++++++++++++++++- .../java/com/homelab/ncal/util/IcsMapper.kt | 37 +++++-- .../com/homelab/ncal/util/RecurrenceUtils.kt | 69 +++++++++++-- .../com/homelab/ncal/util/IcsMapperTest.kt | 86 +++++++++++++++- .../homelab/ncal/util/RecurrenceUtilsTest.kt | 98 +++++++++++++++++++ 9 files changed, 373 insertions(+), 27 deletions(-) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt index 3c20558..ff891f7 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt @@ -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() { diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt index f6c96aa..36ed142 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt @@ -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?, diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt index 8cae10b..e735f19 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt @@ -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 = 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 = emptySet(), // WEEKLY: which days ("every Mon/Wed/Fri"); MONTHLY (with recurrenceByDayOrdinal set): the single weekday of "the Nth " + 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 diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt index 3e3d1e3..af2d952 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt @@ -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, diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt index bf9b3ff..7e13641 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt @@ -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, 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 " (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) diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt index 6749339..7b7d1ff 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt @@ -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 = 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 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, diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt index a8e1def..f28b4c3 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt @@ -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? = 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? { + 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) diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt index d14323f..890156e 100644 --- a/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt +++ b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt @@ -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( diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt index 7dc1f7e..7f21016 100644 --- a/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt +++ b/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt @@ -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 + } }