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.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,22 +18,36 @@ 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
|
||||||
|
// 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(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(
|
||||||
@@ -40,6 +57,7 @@ fun groupAgendaDays(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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