perf(month): bucket events by day before laying out the scrolling grids (#38)

Opening Continuous stayed slow, and the slowness tracked the number of enabled
calendars rather than the window size — which pointed past the provider query to
the layout behind it.

layoutCalendarWeek opens by filtering the instances it is handed down to the
ones touching its seven days. For a single month's grid that is nothing; for the
scrolling styles it was a scan of every instance in an eleven-month window,
repeated for each of a hundred-odd rows, with a time-zone conversion per check.
The work grew with events × rows, so switching every calendar on multiplied it.

- DayIndex buckets the window's events by the dates they cover, once, and each
  row takes the handful on its own seven days. Membership is decided by
  coversDay itself rather than re-derived from the timestamps — the all-day and
  timed cases have enough edge cases between them (UTC anchoring, exclusive
  ends, zero-length events at midnight) that a second implementation would
  drift. DayIndexTest pins row-for-row equality with the old full scan, in a
  zone east of UTC.
- Only the style on screen is laid out. Adding Dense had quietly doubled the
  work, since both layouts were built from every load and one was always thrown
  away.
- The paged flow no longer queries under a scrolling style, mirroring the gate
  the scrolling flow already had.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:41:03 +02:00
parent 95f4d28bbb
commit b417900ddd
2 changed files with 206 additions and 14 deletions

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
* is that cheapness changes *nothing*: a row laid out from the index must be the
* row that would have come from scanning every instance.
*
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
* disagree — the case that has bitten this codebase before (#65).
*/
class DayIndexTest {
private val zone = TimeZone.of("Europe/Berlin")
private val mon = LocalDate(2026, 6, 8)
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
private var nextId = 0L
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = from.atTime(startHour, 0).toInstant(zone),
end = to.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
}
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
}
/** Every shape the layout distinguishes, plus the boundary cases around them. */
private fun awkwardEvents(): List<EventInstance> = listOf(
timed(mon, 9, mon, 10),
// Crosses midnight into the next day.
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
// Ends exactly at midnight: covers its own day, not the next.
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
// Zero length, at midnight: covers nothing at all.
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
// Runs in from before the row and out past its end.
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
// Wholly outside the row, both sides.
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
// Two on one day, to pin the ordering the index hands back.
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
)
@Test
fun `a row from the index is the row from a full scan`() {
val events = awkwardEvents()
val scanned = layoutCalendarWeek(week, events, zone)
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
assertThat(indexed).isEqualTo(scanned)
}
@Test
fun `a whole month agrees, row for row`() {
val events = awkwardEvents()
val jun = YearMonth(2026, Month.JUNE)
DayOfWeek.entries.forEach { ws ->
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
}
}
@Test
fun `an event covering several of a row's days is listed once`() {
// It is bucketed on each date it covers, so the row would otherwise see
// it repeatedly and lay out a lane per copy.
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
val events = DayIndex(listOf(long), zone).eventsOn(week)
assertThat(events).containsExactly(long)
}
@Test
fun `days no event touches come back empty`() {
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
assertThat(index.eventsOn(emptyList())).isEmpty()
}
@Test
fun `an empty calendar indexes to nothing`() {
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
}
}