Merge pull request 'fix(agenda): stop yesterday's all-day event surfacing under today (#65)' (!85) from fix/agenda-allday-today into release/v2.16.0

Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
2026-07-19 14:14:49 +00:00
3 changed files with 45 additions and 4 deletions

View File

@@ -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 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]). 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 ## [2.15.0] — 2026-07-15
### Added ### Added
@@ -1040,3 +1049,4 @@ automatically, with zero telemetry and no internet permission.
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65

View File

@@ -94,10 +94,16 @@ fun groupAgendaDays(
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>() val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
for (instance in instances) { for (instance in instances) {
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
// Never below firstDay: an instance the query returned always surfaces on val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
// at least its first visible day, even if its end resolves earlier (e.g. a // Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
// zero-length or boundary instant) — otherwise the loop would drop it. // The provider returns an event whenever its instant span overlaps the query
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd).coerceAtLeast(firstDay) // 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 var day = firstDay
while (day <= lastDay) { while (day <= lastDay) {
byDay.getOrPut(day) { mutableListOf() }.add(instance) byDay.getOrPut(day) { mutableListOf() }.add(instance)

View File

@@ -138,6 +138,31 @@ class GroupAgendaDaysTest {
).inOrder() ).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 @Test
fun `within a day all-day events sort before timed ones`() { 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) val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)