fix(views): stop single-day all-day events leaking into the next day

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 14:48:34 +02:00
parent 233a9b03a3
commit 90b219bdad
2 changed files with 29 additions and 2 deletions

View File

@@ -181,6 +181,18 @@ internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
/** True if this event overlaps the calendar [day] in [zone] (any portion). */ /** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { 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 dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart return start < dayEnd && end > dayStart

View File

@@ -62,13 +62,28 @@ class WeekLayoutTest {
assertThat(ev.coversDay(wed, zone)).isTrue() assertThat(ev.coversDay(wed, zone)).isTrue()
assertThat(ev.coversDay(mon, zone)).isFalse() 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(mon, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), 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() 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 @Test
fun `single timed event gets one lane`() { fun `single timed event gets one lane`() {
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone) val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)