fix(calendar): BYDAY=1MO — RecurrenceRule хранит/сериализует ординал (был потерян в toRrule) + разворот n-го дня месяца в CalendarRecurrence +4 теста (P1 добивка, mail/101)
This commit is contained in:
+49
-4
@@ -83,13 +83,58 @@ object CalendarRecurrence {
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
|
||||
forEachWeeklyByDay(rule, start, upTo, onOccurrence)
|
||||
} else {
|
||||
forEachSimpleStep(rule, start, upTo, onOccurrence)
|
||||
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)
|
||||
else -> 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: раскрываем выбранные дни недели в каждом интервал-блоке недель. */
|
||||
private inline fun forEachWeeklyByDay(
|
||||
rule: RecurrenceRule,
|
||||
|
||||
+14
-3
@@ -9,6 +9,8 @@ data class RecurrenceRule(
|
||||
val interval: Int = 1,
|
||||
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
|
||||
val byDays: Set<String> = emptySet(),
|
||||
/** Для MONTHLY с BYDAY=<n><день>: ординал (1..5 = n-й, -1 = последний). null — по числу месяца. */
|
||||
val monthlyOrdinal: Int? = null,
|
||||
val endType: EndType = EndType.NEVER,
|
||||
val untilDate: LocalDate? = null,
|
||||
val count: Int = 10,
|
||||
@@ -18,7 +20,10 @@ data class RecurrenceRule(
|
||||
fun toRrule(): String = buildString {
|
||||
append("FREQ=$freq")
|
||||
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(",")}")
|
||||
}
|
||||
when (endType) {
|
||||
@@ -35,6 +40,7 @@ data class RecurrenceRule(
|
||||
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
|
||||
)
|
||||
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
|
||||
private val BYDAY_ORDINAL = Regex("^(-?\\d+)(MO|TU|WE|TH|FR|SA|SU)$")
|
||||
|
||||
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
|
||||
fun parse(rrule: String): RecurrenceRule? {
|
||||
@@ -50,11 +56,16 @@ data class RecurrenceRule(
|
||||
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
|
||||
}
|
||||
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(
|
||||
freq = freq,
|
||||
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
|
||||
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
|
||||
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
|
||||
byDays = bydayTokens.map { it.takeLast(2) }.filter { it in ICS_DAYS }.toSet(),
|
||||
monthlyOrdinal = monthlyOrdinal,
|
||||
endType = when {
|
||||
until != null -> EndType.UNTIL
|
||||
count != null -> EndType.COUNT
|
||||
|
||||
+25
@@ -80,6 +80,31 @@ class CalendarRecurrenceTest {
|
||||
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() {
|
||||
val starts = CalendarRecurrence.occurrenceStartsInRange(
|
||||
rule = rule("FREQ=WEEKLY"),
|
||||
|
||||
Reference in New Issue
Block a user