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
}