fix(agenda): show multi-day events on every day they span #83
@@ -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,22 +18,36 @@ 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) ->
|
||||
): 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(
|
||||
@@ -40,6 +57,7 @@ fun groupAgendaDays(
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`() {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user