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 33438b7..2e93d30 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 @@ -9,18 +9,27 @@ import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime 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 = - start.toLocalDateTime(zone).date + start.toLocalDateTime(dateZone(zone)).date /** - * The last calendar day this event actually occupies, in [zone]. 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]. + * The last calendar day this 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]. */ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { 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]. */ 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 index 2c1eae9..7cfd97f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -107,6 +107,37 @@ class GroupAgendaDaysTest { ).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`() { + // 2–4 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 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)