Merge consistent event ordering (#291)

This commit is contained in:
Jean-Luc Makiola
2026-09-30 13:27:27 +02:00
7 changed files with 55 additions and 12 deletions
+3
View File
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
### Fixed
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
## [2.21.0] — 2026-09-23
### Added
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone
/** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -86,11 +87,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -506,11 +507,7 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
.sortedWith(eventDisplayComparator(zone))
}
/**
@@ -9,9 +9,9 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -228,7 +228,13 @@ internal fun layoutAllDay(
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
.sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
.thenBy { it.event.instanceId },
)
// What each lane already holds, rather than just how far right it reaches.
// A single "last occupied column" only answers correctly while spans arrive
@@ -77,6 +77,16 @@ class GroupAgendaDaysTest {
.containsExactly(LocalDate(2026, 7, 2))
}
@Test
fun `multi-day events precede single all-day and timed events`() {
val multi = event(1, "Multi", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
val single = event(2, "Single", at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
val timed = event(3, "Timed", at(2026, 7, 2, 8), at(2026, 7, 2, 9))
val second = days(listOf(timed, single, multi)).first { it.date == LocalDate(2026, 7, 2) }
assertThat(second.events.map { it.title })
.containsExactly("Multi", "Single", "Timed").inOrder()
}
@Test
fun `an event ending exactly at midnight does not reach the next day`() {
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
@@ -163,6 +163,18 @@ class MonthLayoutTest {
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
}
@Test
fun `multi-day bar is above a one-day all-day bar on the same date`() {
val day = LocalDate(2026, 6, 10)
val single = allDay(day, id = 1L, title = "Single")
val multi = allDay(day, day.plus(2, DateTimeUnit.DAY), id = 2L, title = "Multi")
val week = layoutCalendarWeek(weekOf8th, listOf(single, multi), zone)
val lanes = week.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("Multi")).isLessThan(lanes.getValue("Single"))
assertThat(instancesByDay(listOf(day), listOf(single, multi), zone).getValue(day).map { it.title })
.containsExactly("Multi", "Single").inOrder()
}
@Test
fun `single-day timed events stay pills and sort by start`() {
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)