From 90b219bdad2998bbf8aeade3dbacaa44d9d395db Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 14:48:34 +0200 Subject: [PATCH] fix(views): stop single-day all-day events leaking into the next day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All-day events live at UTC midnights with an exclusive end, but coversDay sliced each day in the device timezone. East of UTC the exclusive end landed a few hours into the next local day, so a one-day all-day event (e.g. a birthday) rendered on two days in the day/week/month views — while the detail and edit screens, which work in UTC, showed it correctly. Compare all-day coverage in UTC and step the exclusive end back to the last covered day, mirroring the detail/edit views. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/week/WeekViewModel.kt | 12 ++++++++++++ .../calendula/ui/week/WeekLayoutTest.kt | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index a4418c1..3a73e5d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange { /** True if this event overlaps the calendar [day] in [zone] (any portion). */ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { + if (isAllDay) { + // All-day events live at UTC midnights with an exclusive end. Compare + // calendar dates in UTC and step the exclusive end back to the last + // covered day (mirroring the detail/edit views), so a one-day event + // covers exactly its single date. Slicing the day in the device zone + // would push the exclusive end a few hours into the next local day + // east of UTC, making the event leak onto day + 1. + val startDate = start.toLocalDateTime(TimeZone.UTC).date + val endExclusive = end.toLocalDateTime(TimeZone.UTC).date + val lastDay = maxOf(startDate, endExclusive.minus(1, DateTimeUnit.DAY)) + return day in startDate..lastDay + } val dayStart = day.atStartOfDayIn(zone) val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) return start < dayEnd && end > dayStart diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 43fa21c..32c6c37 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -62,13 +62,28 @@ class WeekLayoutTest { assertThat(ev.coversDay(wed, zone)).isTrue() assertThat(ev.coversDay(mon, zone)).isFalse() - val multiDay = event(at(mon, 22), at(wed, 2), allDay = true) + // All-day: UTC midnights, end exclusive. Mon..Tue covers Mon and Tue + // but not Wed (the Wed-midnight end is exclusive). + val multiDay = event(at(mon, 0), at(wed, 0), allDay = true) assertThat(multiDay.coversDay(mon, zone)).isTrue() assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue() - assertThat(multiDay.coversDay(wed, zone)).isTrue() + assertThat(multiDay.coversDay(wed, zone)).isFalse() assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse() } + @Test + fun `single-day all-day event does not leak into the next day east of UTC`() { + // A birthday on Wed: the provider stores UTC midnights with an exclusive + // end (Thu 00:00 UTC). In a zone east of UTC the device-local day must + // still resolve to Wed only — never Thu. Regression for the all-day + // event appearing on two days in the views. + val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in June + val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true) + assertThat(ev.coversDay(wed, berlin)).isTrue() + assertThat(ev.coversDay(wed.plusDays(1), berlin)).isFalse() + assertThat(ev.coversDay(wed.plusDays(-1), berlin)).isFalse() + } + @Test fun `single timed event gets one lane`() { val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)