From 7ed1d89b66743b05f31fac8243553a21881b8185 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:16:08 +0200 Subject: [PATCH] fix(agenda): list multi-day events on every day they span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../calendula/ui/agenda/AgendaUiState.kt | 50 +++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 2 +- .../calendula/widget/WidgetData.kt | 3 +- .../ui/agenda/GroupAgendaDaysTest.kt | 119 ++++++++++++++++++ 4 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 8e1b5dd..6419101 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -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, zone: TimeZone, -): List = - instances - .groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } - .toSortedMap() - .map { (date, dayEvents) -> - AgendaDay( - date = date, - events = dayEvents.sortedWith( - compareByDescending { it.isAllDay } - .thenBy { it.start } - .thenBy { it.title }, - ), - ) +): List { + val byDay = sortedMapOf>() + 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 { it.isAllDay } + .thenBy { it.start } + .thenBy { it.title }, + ), + ) + } +} /** * Ensure [today] surfaces as the first agenda day even when it carries no diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 0f8ce1a..924f6b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 0d4f8c3..df0f4bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -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, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt new file mode 100644 index 0000000..2c1eae9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -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) = + 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`() { + // 1–3 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() + } +}