From 492af6157e5a0843f495f7227551c664bff23042 Mon Sep 17 00:00:00 2001 From: b-mob-calendar Date: Mon, 13 Jul 2026 07:43:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(calendar):=20BYDAY=3D1MO=20=E2=80=94=20Recu?= =?UTF-8?q?rrenceRule=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82/=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D1=83=D0=B5=D1=82=20=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D0=BB=20(=D0=B1=D1=8B=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8F=D0=BD=20=D0=B2=20toRrule)?= =?UTF-8?q?=20+=20=D1=80=D0=B0=D0=B7=D0=B2=D0=BE=D1=80=D0=BE=D1=82=20n-?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B4=D0=BD=D1=8F=20=D0=BC=D0=B5=D1=81=D1=8F?= =?UTF-8?q?=D1=86=D0=B0=20=D0=B2=20CalendarRecurrence=20+4=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20(P1=20=D0=B4=D0=BE=D0=B1=D0=B8=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0,=20mail/101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/calendar/CalendarRecurrence.kt | 53 +++++++++++++++++-- .../feature/calendar/RecurrenceRule.kt | 17 ++++-- .../calendar/CalendarRecurrenceTest.kt | 25 +++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrence.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrence.kt index 8d827b6..f3da89d 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrence.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrence.kt @@ -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-й (или последний при -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, diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt index ac35ded..fc05961 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt @@ -9,6 +9,8 @@ data class RecurrenceRule( val interval: Int = 1, /** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */ val byDays: Set = emptySet(), + /** Для MONTHLY с BYDAY=<день>: ординал (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 diff --git a/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrenceTest.kt b/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrenceTest.kt index e6d0761..e068f72 100644 --- a/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrenceTest.kt +++ b/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarRecurrenceTest.kt @@ -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"),