fix(calendar): BYDAY=1MO — RecurrenceRule хранит/сериализует ординал (был потерян в toRrule) + разворот n-го дня месяца в CalendarRecurrence +4 теста (P1 добивка, mail/101)

This commit is contained in:
b-mob-calendar
2026-07-13 07:43:19 +00:00
parent 2819359b2b
commit 492af6157e
3 changed files with 88 additions and 7 deletions
@@ -83,13 +83,58 @@ object CalendarRecurrence {
upTo: LocalDate, upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean, onOccurrence: (LocalDate) -> Boolean,
) { ) {
if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) { when {
rule.freq == "MONTHLY" && rule.monthlyOrdinal != null && rule.byDays.isNotEmpty() ->
forEachMonthlyNthWeekday(rule, start, upTo, onOccurrence)
rule.freq == "WEEKLY" && rule.byDays.isNotEmpty() ->
forEachWeeklyByDay(rule, start, upTo, onOccurrence) forEachWeeklyByDay(rule, start, upTo, onOccurrence)
} else { else -> forEachSimpleStep(rule, start, upTo, onOccurrence)
forEachSimpleStep(rule, start, upTo, onOccurrence)
} }
} }
/** MONTHLY с BYDAY=<n><день>: n-й (или последний при -1) день недели каждого интервал-месяца. */
private inline fun forEachMonthlyNthWeekday(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
val ordinal = rule.monthlyOrdinal ?: return
val dow = RecurrenceRule.ICS_DAYS.firstOrNull { it in rule.byDays }?.let { icsDayToDow(it) } ?: return
val baseMonth = java.time.YearMonth.from(start)
var emitted = 0
var n = 0L
while (n < MAX_STEPS) {
val month = baseMonth.plusMonths(n * interval)
val occ = nthWeekdayOfMonth(month, dow, ordinal)
n++
if (occ == null || occ.isBefore(start)) {
if (month.atEndOfMonth().isAfter(upTo)) return
continue
}
val pastUntil = until != null && occ.isAfter(until)
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
}
}
/** n-й день недели [dow] месяца [month]; ordinal>0 — с начала, -1 — последний; null если нет. */
private fun nthWeekdayOfMonth(month: java.time.YearMonth, dow: DayOfWeek, ordinal: Int): LocalDate? {
if (ordinal < 0) {
val last = month.atEndOfMonth()
val shift = (last.dayOfWeek.value - dow.value + 7) % 7
val candidate = last.minusDays(shift.toLong())
return if (java.time.YearMonth.from(candidate) == month) candidate else null
}
val first = month.atDay(1)
val shift = (dow.value - first.dayOfWeek.value + 7) % 7
val candidate = first.plusDays(shift.toLong() + (ordinal - 1) * 7L)
return if (java.time.YearMonth.from(candidate) == month) candidate else null
}
/** WEEKLY с BYDAY: раскрываем выбранные дни недели в каждом интервал-блоке недель. */ /** WEEKLY с BYDAY: раскрываем выбранные дни недели в каждом интервал-блоке недель. */
private inline fun forEachWeeklyByDay( private inline fun forEachWeeklyByDay(
rule: RecurrenceRule, rule: RecurrenceRule,
@@ -9,6 +9,8 @@ data class RecurrenceRule(
val interval: Int = 1, val interval: Int = 1,
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */ /** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
val byDays: Set<String> = emptySet(), val byDays: Set<String> = emptySet(),
/** Для MONTHLY с BYDAY=<n><день>: ординал (1..5 = n-й, -1 = последний). null — по числу месяца. */
val monthlyOrdinal: Int? = null,
val endType: EndType = EndType.NEVER, val endType: EndType = EndType.NEVER,
val untilDate: LocalDate? = null, val untilDate: LocalDate? = null,
val count: Int = 10, val count: Int = 10,
@@ -18,7 +20,10 @@ data class RecurrenceRule(
fun toRrule(): String = buildString { fun toRrule(): String = buildString {
append("FREQ=$freq") append("FREQ=$freq")
if (interval > 1) append(";INTERVAL=$interval") if (interval > 1) append(";INTERVAL=$interval")
if (freq == "WEEKLY" && byDays.isNotEmpty()) { if (freq == "MONTHLY" && monthlyOrdinal != null && byDays.isNotEmpty()) {
// Месячный n-й день недели, напр. BYDAY=1MO (первый понедельник) — раньше терялся.
append(";BYDAY=$monthlyOrdinal${ICS_DAYS.first { it in byDays }}")
} else if (freq == "WEEKLY" && byDays.isNotEmpty()) {
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}") append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
} }
when (endType) { when (endType) {
@@ -35,6 +40,7 @@ data class RecurrenceRule(
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс", "FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
) )
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
private val BYDAY_ORDINAL = Regex("^(-?\\d+)(MO|TU|WE|TH|FR|SA|SU)$")
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */ /** Парсинг RRULE-строки; null для пустой/неразбираемой. */
fun parse(rrule: String): RecurrenceRule? { fun parse(rrule: String): RecurrenceRule? {
@@ -50,11 +56,16 @@ data class RecurrenceRule(
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull() runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
} }
val count = parts["COUNT"]?.toIntOrNull() val count = parts["COUNT"]?.toIntOrNull()
val bydayTokens = parts["BYDAY"]?.split(',')?.map { it.trim().uppercase() }.orEmpty()
// Ординал для MONTHLY (BYDAY=1MO/-1FR): берём из первого токена с числовым префиксом.
val monthlyOrdinal = bydayTokens
.takeIf { freq == "MONTHLY" }
?.firstNotNullOfOrNull { BYDAY_ORDINAL.find(it)?.groupValues?.get(1)?.toIntOrNull() }
return RecurrenceRule( return RecurrenceRule(
freq = freq, freq = freq,
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1, interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() } byDays = bydayTokens.map { it.takeLast(2) }.filter { it in ICS_DAYS }.toSet(),
?.filter { it in ICS_DAYS }?.toSet().orEmpty(), monthlyOrdinal = monthlyOrdinal,
endType = when { endType = when {
until != null -> EndType.UNTIL until != null -> EndType.UNTIL
count != null -> EndType.COUNT count != null -> EndType.COUNT
@@ -80,6 +80,31 @@ class CalendarRecurrenceTest {
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-10", span = 2, allDay = true)) assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-10", span = 2, allDay = true))
} }
@Test fun monthly_nth_weekday_first_monday() {
// первый понедельник: июль'26 = 06, авг = 03, сен = 07
val r = "FREQ=MONTHLY;BYDAY=1MO"
assertTrue(occurs(r, "2026-07-06", "2026-07-06"))
assertTrue(occurs(r, "2026-07-06", "2026-08-03"))
assertTrue(occurs(r, "2026-07-06", "2026-09-07"))
assertFalse("второй понедельник — нет", occurs(r, "2026-07-06", "2026-07-13"))
assertFalse("число месяца, не день недели", occurs(r, "2026-07-06", "2026-08-06"))
}
@Test fun monthly_last_friday() {
// последняя пятница: июль'26 = 31, авг = 28
val r = "FREQ=MONTHLY;BYDAY=-1FR"
assertTrue(occurs(r, "2026-07-31", "2026-07-31"))
assertTrue(occurs(r, "2026-07-31", "2026-08-28"))
assertFalse(occurs(r, "2026-07-31", "2026-08-21"))
}
@Test fun byday_ordinal_round_trips_through_rrule() {
// раньше toRrule терял ординал (1MO → MO); теперь сохраняет.
assertEquals("FREQ=MONTHLY;BYDAY=1MO", rule("FREQ=MONTHLY;BYDAY=1MO").toRrule())
assertEquals("FREQ=MONTHLY;BYDAY=-1FR", rule("FREQ=MONTHLY;BYDAY=-1FR").toRrule())
assertEquals(1, rule("FREQ=MONTHLY;BYDAY=1MO").monthlyOrdinal)
}
@Test fun range_listing() { @Test fun range_listing() {
val starts = CalendarRecurrence.occurrenceStartsInRange( val starts = CalendarRecurrence.occurrenceStartsInRange(
rule = rule("FREQ=WEEKLY"), rule = rule("FREQ=WEEKLY"),