From 4148196a360c366a8396eb198758d7f1059f8e9e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:05:36 +0200 Subject: [PATCH] fix(agenda): stop yesterday's all-day event surfacing under today (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 10 ++++++++ .../calendula/ui/agenda/AgendaUiState.kt | 14 ++++++++--- .../ui/agenda/GroupAgendaDaysTest.kt | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e901b4..c3ce563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 headers right below it, in the widest string in the bar. A week that straddles two months keeps the outgoing month until it is fully gone ([#60]). +### Fixed +- An all-day event no longer shows up again the day after it happened. In time + zones east of UTC, an all-day event — a birthday, say — set for one day also + appeared under the *next* day's heading in the Agenda (and the agenda widget), + because all-day events are anchored to UTC midnight and the following day's + window reached back across that boundary and pulled the event forward onto + "today". Each all-day event now lists only on the day it actually falls on + ([#65]). + ## [2.15.0] — 2026-07-15 ### Added @@ -1040,3 +1049,4 @@ automatically, with zero telemetry and no internet permission. [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 +[#65]: https://codeberg.org/jlmakiola/calendula/issues/65 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 7f20d44..b88b1a7 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 @@ -94,10 +94,16 @@ fun groupAgendaDays( val byDay = sortedMapOf>() 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) 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 7cfd97f..179d93f 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 @@ -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)