diff --git a/CHANGELOG.md b/CHANGELOG.md index 804dab0..20fd1a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt new file mode 100644 index 0000000..1240c5f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -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 = + compareBy { it.displayPriority(zone) } + .thenBy { it.start } + .thenBy { it.title } + .thenBy { it.instanceId } 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 2c5ec3a..f38a34d 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 @@ -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 { it.isAllDay } - .thenBy { it.start } - .thenBy { it.title }, - ), + events = dayEvents.sortedWith(eventDisplayComparator(zone)), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index b2318d9..cd22771 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 { it.isDeclined } - .thenByDescending { it.isAllDay } - .thenBy { it.start }, - ) + .sortedWith(eventDisplayComparator(zone)) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index df4d21b..ae2e570 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 { 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 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 37d01d5..aee5190 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 @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 2cddeae..a38c258 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -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)