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
2 changed files with 46 additions and 6 deletions
Showing only changes of commit 3ec46c8631 - Show all commits

View File

@@ -9,18 +9,27 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
/** The first calendar day this event occupies, in [zone]. */ /**
* The zone the event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
* a one-day event onto its next day. Matches the Week view and detail card.
*/
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate = fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(zone).date start.toLocalDateTime(dateZone(zone)).date
/** /**
* The last calendar day this event actually occupies, in [zone]. An event ending * The last calendar day this event actually occupies. An event ending exactly at
* exactly at midnight (all-day events end at the exclusive next-midnight) does * midnight (all-day events end at the exclusive next-midnight) does not reach
* not reach into that boundary day, so resolve the instant just before [end]. * into that boundary day, so resolve the instant just before [end].
*/ */
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(zone).date return lastInstant.toLocalDateTime(dateZone(zone)).date
} }
/** Whether this event occupies more than one calendar day in [zone]. */ /** Whether this event occupies more than one calendar day in [zone]. */

View File

@@ -107,6 +107,37 @@ class GroupAgendaDaysTest {
).inOrder() ).inOrder()
} }
// All-day events are stored at UTC midnights; resolving them in a device
// zone east of UTC would leak them onto the following day (see spanLastDay).
private val berlin = TimeZone.of("Europe/Berlin")
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
@Test
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
assertThat(e.spansMultipleDays(berlin)).isFalse()
}
@Test
fun `an all-day multi-day event spans its true days in an eastern zone`() {
// 24 July inclusive: exclusive end is the UTC midnight opening 5 July.
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
assertThat(result.map { it.date }).containsExactly(
LocalDate(2026, 7, 2),
LocalDate(2026, 7, 3),
LocalDate(2026, 7, 4),
).inOrder()
}
@Test @Test
fun `within a day all-day events sort before timed ones`() { 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 allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)