feat(calendar): P1 разворот повторений — CalendarRecurrence (RRULE-оккуренции на лету) + eventOccursOnDay RRULE-осознан +11 тестов; refresh detekt-baseline (v0.5.144-долг)
This commit is contained in:
+163
@@ -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)
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user