feat(calendar): P1 разворот повторений — CalendarRecurrence (RRULE-оккуренции на лету) + eventOccursOnDay RRULE-осознан +11 тестов; refresh detekt-baseline (v0.5.144-долг)

This commit is contained in:
b-mob-calendar
2026-07-13 07:36:19 +00:00
parent 1c1e9c89e2
commit e7d0a5a9ee
6 changed files with 442 additions and 94 deletions
@@ -0,0 +1,163 @@
package ru.forbion.f7cloud.feature.calendar
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.ChronoField
import java.time.temporal.ChronoUnit
/**
* Локальный разворот повторяющихся событий (P1, mail/072/093).
*
* Генерирует даты-старты оккуренций правила RRULE в заданном диапазоне БЕЗ обращения к серверу
* (`<c:expand>` не используем — это правка core, mail/072). Поддерживается то же подмножество,
* что умеет создавать нативный редактор ([RecurrenceRule]): FREQ=DAILY|WEEKLY|MONTHLY|YEARLY,
* INTERVAL, BYDAY (для WEEKLY), окончание UNTIL/COUNT/никогда.
*
* Развёртка делается на КАЖДЫЙ показ (не кэшируется — иначе окно диапазона «вмораживается»,
* mail/072). Экземпляры различимы парой (uid, дата-старт оккуренции).
*/
object CalendarRecurrence {
/** Жёсткий предел числа шагов генератора — страховка от бесконечных/патологических правил. */
private const val MAX_STEPS = 3660
/**
* Покрывает ли событие [day] с учётом повторения.
*
* @param start дата-старт мастер-события (в нужной зоне).
* @param spanDays длительность события в днях для all-day (>=1); для timed игнорируется.
* @param allDay all-day событие занимает [occStart, occStart+spanDays); timed — только occStart.
*/
fun occursOnDay(
rule: RecurrenceRule,
start: LocalDate,
day: LocalDate,
spanDays: Long,
allDay: Boolean,
): Boolean {
if (day.isBefore(start)) return false
val span = if (allDay) spanDays.coerceAtLeast(1) else 1L
// Оккуренция покрывает day, если её старт в диапазоне (day - (span-1) .. day).
val earliestStart = day.minusDays(span - 1)
var found = false
forEachOccurrenceStart(rule, start, upTo = day) { occStart ->
if (!occStart.isBefore(earliestStart) && !occStart.isAfter(day)) {
found = true
false // стоп
} else {
true // продолжаем
}
}
return found
}
/** Список дат-стартов оккуренций в [rangeStart, rangeEnd] включительно (для списковых видов). */
fun occurrenceStartsInRange(
rule: RecurrenceRule,
start: LocalDate,
rangeStart: LocalDate,
rangeEnd: LocalDate,
): List<LocalDate> {
val out = mutableListOf<LocalDate>()
forEachOccurrenceStart(rule, start, upTo = rangeEnd) { occStart ->
if (!occStart.isBefore(rangeStart)) out.add(occStart)
true
}
return out
}
/** Границы окончания правила: (UNTIL-дата или null, максимум оккуренций по COUNT). */
private fun bounds(rule: RecurrenceRule): Pair<LocalDate?, Int> {
val until = if (rule.endType == RecurrenceRule.EndType.UNTIL) rule.untilDate else null
val maxCount = if (rule.endType == RecurrenceRule.EndType.COUNT) rule.count.coerceAtLeast(1) else Int.MAX_VALUE
return until to maxCount
}
/**
* Перебирает даты-старты оккуренций от [start], пока не превышена [upTo], не исчерпан
* UNTIL/COUNT или лимит шагов. [onOccurrence] возвращает false, чтобы прервать перебор.
*/
private inline fun forEachOccurrenceStart(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
forEachWeeklyByDay(rule, start, upTo, onOccurrence)
} else {
forEachSimpleStep(rule, start, upTo, onOccurrence)
}
}
/** WEEKLY с BYDAY: раскрываем выбранные дни недели в каждом интервал-блоке недель. */
private inline fun forEachWeeklyByDay(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
val weeklyDows = RecurrenceRule.ICS_DAYS.filter { it in rule.byDays }.map { icsDayToDow(it).value.toLong() }
val weekBase = mondayOf(start)
var emitted = 0
var block = 0L
while (block < MAX_STEPS) {
val weekStart = weekBase.plusWeeks(block * interval)
if (weekStart.isAfter(upTo)) return // все дни этой и следующих недель уже за upTo
for (dowValue in weeklyDows) {
val occ = weekStart.with(ChronoField.DAY_OF_WEEK, dowValue)
if (occ.isBefore(start) || occ.isAfter(upTo)) continue
if (until != null && occ.isAfter(until)) return
if (emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
}
block++
}
}
/** DAILY / WEEKLY(без BYDAY) / MONTHLY / YEARLY: простой шаг по мастер-старту. */
private inline fun forEachSimpleStep(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
var emitted = 0
var n = 0L
while (n < MAX_STEPS) {
val occ = when (rule.freq) {
"DAILY" -> start.plusDays(n * interval)
"WEEKLY" -> start.plusWeeks(n * interval)
"MONTHLY" -> start.plusMonths(n * interval)
"YEARLY" -> start.plusYears(n * interval)
else -> return
}
val pastUntil = until != null && occ.isAfter(until)
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
n++
}
}
private fun mondayOf(date: LocalDate): LocalDate = date.with(ChronoField.DAY_OF_WEEK, 1L)
private fun icsDayToDow(ics: String): DayOfWeek = when (ics) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
else -> DayOfWeek.SUNDAY
}
/** Число дней-длительности для all-day по границам [start,end) (в датах). */
fun allDaySpanDays(start: LocalDate, end: LocalDate): Long =
ChronoUnit.DAYS.between(start, end).coerceAtLeast(1)
}
@@ -430,6 +430,13 @@ class CalendarRepository(
fun eventOccursOnDay(event: CalendarEventItem, day: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Boolean {
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
// Повторяющееся событие: разворачиваем оккуренции на лету (P1, mail/072/093).
val rule = RecurrenceRule.parse(event.rrule)
if (rule != null) {
val spanDays = if (event.allDay) CalendarRecurrence.allDaySpanDays(start, end) else 1L
return CalendarRecurrence.occursOnDay(rule, start, day, spanDays, event.allDay)
}
// Одиночное событие: прежнее поведение (all-day занимает [start, end)).
return if (event.allDay) !day.isBefore(start) && day.isBefore(end) else day == start
}
@@ -0,0 +1,92 @@
package ru.forbion.f7cloud.feature.calendar
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Разворот повторений (P1): [CalendarRecurrence.occursOnDay] / [occurrenceStartsInRange].
*/
class CalendarRecurrenceTest {
private fun rule(s: String) = RecurrenceRule.parse(s)!!
private fun d(s: String) = LocalDate.parse(s)
private fun occurs(rrule: String, start: String, day: String, span: Long = 1, allDay: Boolean = false) =
CalendarRecurrence.occursOnDay(rule(rrule), d(start), d(day), span, allDay)
@Test fun daily_hits_every_day() {
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-01"))
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-15"))
assertFalse("до старта — нет", occurs("FREQ=DAILY", "2026-07-10", "2026-07-05"))
}
@Test fun daily_interval_2_skips_odd_offsets() {
assertTrue(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-03"))
assertFalse(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-02"))
}
@Test fun weekly_same_weekday() {
// 2026-07-01 — среда; +неделя = 08, 15…
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-08"))
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-29"))
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09"))
}
@Test fun weekly_byday_mon_wed_fri() {
// старт среда 2026-07-01; BYDAY=MO,WE,FR
val r = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
assertTrue("среда старта", occurs(r, "2026-07-01", "2026-07-01"))
assertTrue("пятница той же недели", occurs(r, "2026-07-01", "2026-07-03"))
assertTrue("понедельник след. недели", occurs(r, "2026-07-01", "2026-07-06"))
assertFalse("вторник — нет", occurs(r, "2026-07-01", "2026-07-07"))
assertFalse("понедельник ДО старта той же недели — нет", occurs(r, "2026-07-01", "2026-06-29"))
}
@Test fun weekly_byday_interval_2_skips_middle_week() {
val r = "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"
// старт среда 01.07; понедельники: блок0 нед. 29.06 (до старта, нет), блок2 нед. 13.07 → 13
assertTrue(occurs(r, "2026-07-01", "2026-07-13"))
assertFalse("промежуточная неделя 06.07 пропущена", occurs(r, "2026-07-01", "2026-07-06"))
}
@Test fun monthly_same_day_of_month() {
assertTrue(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-15"))
assertFalse(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-16"))
}
@Test fun yearly() {
assertTrue(occurs("FREQ=YEARLY", "2024-02-29", "2028-02-29"))
assertTrue(occurs("FREQ=YEARLY", "2026-03-10", "2030-03-10"))
assertFalse(occurs("FREQ=YEARLY", "2026-03-10", "2027-03-11"))
}
@Test fun count_limits_occurrences() {
// COUNT=3 → 01, 02, 03; 04 уже нет
assertTrue(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-03"))
assertFalse(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-04"))
}
@Test fun until_bounds_occurrences() {
assertTrue(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-05"))
assertFalse(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-06"))
}
@Test fun allday_multiday_occurrence_covers_span() {
// 2-дневное all-day (span=2), еженедельно: оккуренция 08.07 покрывает 08 и 09
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09", span = 2, allDay = true))
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-10", span = 2, allDay = true))
}
@Test fun range_listing() {
val starts = CalendarRecurrence.occurrenceStartsInRange(
rule = rule("FREQ=WEEKLY"),
start = d("2026-07-01"),
rangeStart = d("2026-07-05"),
rangeEnd = d("2026-07-25"),
)
assertEquals(listOf(d("2026-07-08"), d("2026-07-15"), d("2026-07-22")), starts)
}
}