fix(agenda): list multi-day events on every day they span

groupAgendaDays keyed each instance by its start day alone, so a
multi-day event surfaced only on its first day and vanished from the
rest of its span in both the Agenda screen and the agenda widget.

Expand each instance across every day from its start (clamped to the
anchor for ongoing events) through its last occupied day, bounded by the
visible window end. An event ending exactly at midnight — including the
exclusive next-midnight all-day events end at — does not reach that
boundary day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 11:16:08 +02:00
parent 60938af9f0
commit 7ed1d89b66
4 changed files with 156 additions and 18 deletions

View File

@@ -2,9 +2,12 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
/** One calendar day with at least one event, for the agenda list. */
data class AgendaDay(
@@ -15,31 +18,46 @@ data class AgendaDay(
/**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
* day, all-day events sort first, then ascending by start time, then title.
* actually carry events). A multi-day event surfaces on *every* day it spans,
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* before the window (ongoing) still lists from the anchor day, and one running
* past the window stops at the last visible day. Within a day, all-day events
* sort first, then ascending by start time, then title.
*
* Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically.
*/
fun groupAgendaDays(
anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>,
zone: TimeZone,
): List<AgendaDay> =
instances
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
.toSortedMap()
.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
): List<AgendaDay> {
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
for (instance in instances) {
val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor)
// The last day the event actually occupies. An event ending exactly at
// midnight (all-day events end at the exclusive next-midnight) does not
// reach into that boundary day, so resolve the instant just before [end].
val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start
val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd)
var day = firstDay
while (day <= lastDay) {
byDay.getOrPut(day) { mutableListOf() }.add(instance)
day = day.plus(1, DateTimeUnit.DAY)
}
}
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
)
}
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no

View File

@@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor(
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,

View File

@@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour,
soften = soften,
weekStart = weekStart,

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class GroupAgendaDaysTest {
private val zone = TimeZone.UTC
private val anchor = LocalDate(2026, 7, 1)
// A wide window so clamping to the window end is out of the way unless tested.
private val windowEnd = LocalDate(2026, 7, 31)
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
LocalDateTime(y, mo, d, h, min).toInstant(zone)
private fun event(
id: Long,
title: String,
start: Instant,
end: Instant,
isAllDay: Boolean = false,
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1,
title = title,
start = start,
end = end,
isAllDay = isAllDay,
color = 0,
location = null,
)
private fun days(instances: List<EventInstance>) =
groupAgendaDays(anchor, windowEnd, instances, zone)
@Test
fun `a timed multi-day event lists on every day it spans`() {
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
result.forEach { assertThat(it.events).containsExactly(e) }
}
@Test
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
// 13 July inclusive: end is the exclusive midnight opening 4 July.
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
val result = days(listOf(e))
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
).inOrder()
}
@Test
fun `a single-day event lists exactly once`() {
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 2))
}
@Test
fun `an event ending exactly at midnight does not reach the next day`() {
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
assertThat(days(listOf(e)).map { it.date })
.containsExactly(LocalDate(2026, 7, 1))
}
@Test
fun `an event begun before the anchor is clamped to the anchor day`() {
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
assertThat(days(listOf(e)).map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
@Test
fun `an event running past the window is clamped to the last visible day`() {
val narrowEnd = LocalDate(2026, 7, 2)
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 1),
LocalDate(2026, 7, 2),
).inOrder()
}
@Test
fun `within a day all-day events sort before timed ones`() {
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
}
}