fix(agenda): stop yesterday's all-day event surfacing under today (#65)
The agenda window starts at the anchor's local midnight, which east of UTC is the previous day's 22:00 UTC. All-day events live at UTC midnights with an exclusive next-midnight end, so yesterday's all-day event (a birthday, say) still overlaps today's window start and is returned by the provider. groupAgendaDays then clamped its first day up to the anchor and, via a trailing coerceAtLeast(firstDay) on the last day, pulled it onto the anchor's "today" section — the multi-day fix only stopped the forward leak on interior days. Drop instances whose true last day (resolved in UTC for all-day events) falls before the anchor, or whose first day falls past the window end: they occupy no visible day and must not be clamped onto an edge. Adds eastern-zone regression tests for both edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,10 +94,16 @@ fun groupAgendaDays(
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
// Never below firstDay: an instance the query returned always surfaces on
|
||||
// at least its first visible day, even if its end resolves earlier (e.g. a
|
||||
// zero-length or boundary instant) — otherwise the loop would drop it.
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd).coerceAtLeast(firstDay)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
|
||||
@@ -138,6 +138,31 @@ class GroupAgendaDaysTest {
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() {
|
||||
// The agenda window starts at the anchor's local midnight, which in Berlin is
|
||||
// 22:00 UTC the day before — so an all-day event that ended yesterday (whose
|
||||
// exclusive UTC-midnight end sits at that instant) is still returned by the
|
||||
// provider. It must not be clamped onto the anchor's "today" section (#65).
|
||||
val jul19 = LocalDate(2026, 7, 19)
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event that falls entirely past the window end is dropped`() {
|
||||
// The same guard, at the far edge: an instance whose true days are all beyond
|
||||
// windowEnd occupies no visible day and must not be clamped onto the last one.
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
Reference in New Issue
Block a user