fix(agenda): show multi-day events on every day they span #83

Merged
makiolaj merged 7 commits from fix/agenda-multiday into release/v2.16.0 2026-07-19 10:16:57 +00:00
4 changed files with 156 additions and 18 deletions
Showing only changes of commit 7ed1d89b66 - Show all commits

View File

@@ -2,9 +2,12 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
/** One calendar day with at least one event, for the agenda list. */ /** One calendar day with at least one event, for the agenda list. */
data class AgendaDay( data class AgendaDay(
@@ -15,31 +18,46 @@ data class AgendaDay(
/** /**
* Group flat [instances] into forward-looking [AgendaDay]s (only days that * Group flat [instances] into forward-looking [AgendaDay]s (only days that
* actually carry events). An event that began before [anchor] (ongoing or * actually carry events). A multi-day event surfaces on *every* day it spans,
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a * not just its first — clamped to [[anchor], [windowEnd]] so an event that began
* day, all-day events sort first, then ascending by start time, then title. * 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 * Shared by the Agenda screen and the agenda home-screen widget so both group
* and order identically. * and order identically.
*/ */
fun groupAgendaDays( fun groupAgendaDays(
anchor: LocalDate, anchor: LocalDate,
windowEnd: LocalDate,
instances: List<EventInstance>, instances: List<EventInstance>,
zone: TimeZone, zone: TimeZone,
): List<AgendaDay> = ): List<AgendaDay> {
instances val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } for (instance in instances) {
.toSortedMap() val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor)
.map { (date, dayEvents) -> // The last day the event actually occupies. An event ending exactly at
AgendaDay( // midnight (all-day events end at the exclusive next-midnight) does not
date = date, // reach into that boundary day, so resolve the instant just before [end].
events = dayEvents.sortedWith( val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start
compareByDescending<EventInstance> { it.isAllDay } val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd)
.thenBy { it.start } var day = firstDay
.thenBy { it.title }, 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 * 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) return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val anchor = params.anchor val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, 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 // the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting. // recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) 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 instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first() val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this)) .is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first() val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = is24Hour, is24Hour = is24Hour,
soften = soften, soften = soften,
weekStart = weekStart, 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()
}
}