diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ebea45d..0f5b5c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -367,10 +367,14 @@ private fun AgendaList( } else { itemsIndexed( items = day.events, - key = { _, event -> event.instanceId }, + // Scope the key by day: a multi-day event appears under every + // day it spans, so its instanceId alone is not unique across + // the list (LazyColumn requires unique keys). + key = { _, event -> "${day.date}-${event.instanceId}" }, ) { index, event -> AgendaEventRow( event = event, + day = day.date, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -449,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { @Composable private fun AgendaEventRow( event: EventInstance, + day: LocalDate, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -460,7 +465,7 @@ private fun AgendaEventRow( GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, - summary = agendaTimeSummary(event), + summary = agendaTimeSummary(event, day), position = position, minHeight = 64.dp, leading = { @@ -554,16 +559,29 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { return if (relative != null) "$relative · $formatted" else formatted } -/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */ +/** + * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. + * + * A multi-day event shows only the part relevant to [day], spelled out so each + * day reads on its own: its first day names the start ("Starts 14:00"), its last + * day the end ("Ends 10:00"), and any whole day in between reads as "All day". + * An all-day multi-day event is simply "All day" on every day it covers. + */ @Composable -private fun agendaTimeSummary(event: EventInstance): String { - val time = if (event.isAllDay) { - stringResource(R.string.event_detail_all_day) - } else { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - "${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}" +private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = when (val label = agendaTimeLabel(event, day, zone)) { + AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> + stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale)) + is AgendaTimeLabel.Ends -> + stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale)) + is AgendaTimeLabel.Range -> + "${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}" } + val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } 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 8e1b5dd..7f20d44 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 @@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * The zone the event's calendar dates live in. Timed events are resolved in the + * device [zone]; all-day events live at UTC midnights with an exclusive end, so + * resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks + * a one-day event onto its next day. Matches the Week view and detail card. + */ +private fun EventInstance.dateZone(zone: TimeZone): TimeZone = + if (isAllDay) TimeZone.UTC else zone + +/** The first calendar day this event occupies. */ +fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate = + start.toLocalDateTime(dateZone(zone)).date + +/** + * The last calendar day this event actually occupies. An event ending exactly at + * midnight (all-day events end at the exclusive next-midnight) does not reach + * into that boundary day, so resolve the instant just before [end]. + */ +fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { + val lastInstant = if (end > start) end - 1.milliseconds else start + return lastInstant.toLocalDateTime(dateZone(zone)).date +} + +/** Whether this event occupies more than one calendar day in [zone]. */ +fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = + spanFirstDay(zone) != spanLastDay(zone) + +/** + * What an agenda row's time line should convey for an event on a given day — + * the part of a multi-day span that [day] falls in. Pure and shared so the + * agenda screen and the agenda widget label multi-day events identically; each + * surface only formats the instants into its own locale/24h string. + */ +sealed interface AgendaTimeLabel { + /** An all-day event, or a whole in-between day of a multi-day span. */ + data object AllDay : AgendaTimeLabel + /** The first day of a multi-day timed event: when it begins. */ + data class Starts(val start: Instant) : AgendaTimeLabel + /** The last day of a multi-day timed event: when it ends. */ + data class Ends(val end: Instant) : AgendaTimeLabel + /** A single-day timed event: its start–end range. */ + data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel +} + +/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */ +fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel { + if (event.isAllDay) return AgendaTimeLabel.AllDay + val firstDay = event.spanFirstDay(zone) + val lastDay = event.spanLastDay(zone) + return when { + firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end) + day <= firstDay -> AgendaTimeLabel.Starts(event.start) + day >= lastDay -> AgendaTimeLabel.Ends(event.end) + else -> AgendaTimeLabel.AllDay // a full in-between day + } +} /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( @@ -15,31 +76,45 @@ data class AgendaDay( /** * Group flat [instances] into forward-looking [AgendaDay]s (only days that - * actually carry events). An event that began before [anchor] (ongoing or - * multi-day) is clamped to the anchor day so it still surfaces on top. Within a - * day, all-day events sort first, then ascending by start time, then title. + * actually carry events). A multi-day event surfaces on *every* day it spans, + * not just its first — clamped to [[anchor], [windowEnd]] so an event that began + * before the window (ongoing) still lists from the anchor day, and one running + * past the window stops at the last visible day. Within a day, all-day events + * sort first, then ascending by start time, then title. * * Shared by the Agenda screen and the agenda home-screen widget so both group * and order identically. */ fun groupAgendaDays( anchor: LocalDate, + windowEnd: LocalDate, instances: List, zone: TimeZone, -): List = - instances - .groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } - .toSortedMap() - .map { (date, dayEvents) -> - AgendaDay( - date = date, - events = dayEvents.sortedWith( - compareByDescending { it.isAllDay } - .thenBy { it.start } - .thenBy { it.title }, - ), - ) +): List { + 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) + var day = firstDay + while (day <= lastDay) { + byDay.getOrPut(day) { mutableListOf() }.add(instance) + day = day.plus(1, DateTimeUnit.DAY) } + } + return byDay.map { (date, dayEvents) -> + AgendaDay( + date = date, + events = dayEvents.sortedWith( + compareByDescending { it.isAllDay } + .thenBy { it.start } + .thenBy { it.title }, + ), + ) + } +} /** * Ensure [today] surfaces as the first agenda day even when it carries no diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 0f8ce1a..924f6b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor( return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) } val anchor = params.anchor - val days = groupAgendaDays(anchor, instances, zone) val rangeEnd = anchor.plus( params.range.dayCount(anchor, params.weekStart) - 1, DateTimeUnit.DAY, ) + val days = groupAgendaDays(anchor, rangeEnd, instances, zone) return AgendaUiState.Success( anchor = anchor, today = todayDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 0d4f8c3..df0f4bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { // the composition from Glance state, so changing the range is a plain // recomposition and never depends on the widget session restarting. val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) + val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY) val instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) val soften = prefs.softenCalendarColors.first() return AgendaWidgetData.Ready( today = anchor, - days = groupAgendaDays(anchor, instances, zone), + days = groupAgendaDays(anchor, windowEnd, instances, zone), is24Hour = is24Hour, soften = soften, weekStart = weekStart, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 2b048fc..198a21a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -49,6 +49,8 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel +import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange @@ -127,7 +129,7 @@ class RefreshAgendaAction : ActionCallback { /** Flat row model so the [LazyColumn] can mix day headers and events. */ private sealed interface AgendaRow { data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow - data class Event(val event: EventInstance) : AgendaRow + data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow /** "Nothing left today" line under an anchored, event-less today (#35). */ data class Placeholder(val date: LocalDate) : AgendaRow } @@ -185,7 +187,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { if (day.events.isEmpty()) { add(AgendaRow.Placeholder(day.date)) } else { - day.events.forEach { add(AgendaRow.Event(it)) } + day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } } @@ -196,6 +198,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is AgendaRow.Placeholder -> PlaceholderRow(row.date) is AgendaRow.Event -> EventRow( event = row.event, + day = row.date, dark = dark, soften = data.soften, is24Hour = data.is24Hour, @@ -312,6 +315,7 @@ private fun PlaceholderRow(date: LocalDate) { @Composable private fun EventRow( event: EventInstance, + day: LocalDate, dark: Boolean, soften: Boolean, is24Hour: Boolean, @@ -357,7 +361,7 @@ private fun EventRow( style = TextStyle(color = titleColor, fontSize = 14.sp), ) Text( - text = eventTimeSummary(context, event, is24Hour), + text = eventTimeSummary(context, event, day, is24Hour), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), ) @@ -396,11 +400,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } -private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String { - val time = if (event.isAllDay) { - context.getString(R.string.event_detail_all_day) - } else { - "${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}" +private fun eventTimeSummary( + context: Context, + event: EventInstance, + day: LocalDate, + is24Hour: Boolean, +): String { + val time = when (val label = agendaTimeLabel(event, day, zone())) { + AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> + context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour)) + is AgendaTimeLabel.Ends -> + context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour)) + is AgendaTimeLabel.Range -> + "${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}" } val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb03dd9..43e3bdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,9 @@ Tomorrow You\'re all caught up No more events today + + Starts %1$s + Ends %1$s Search diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt new file mode 100644 index 0000000..25c71ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class AgendaTimeLabelTest { + + private val zone = TimeZone.UTC + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance( + instanceId = 1, + eventId = 1, + calendarId = 1, + title = "e", + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) = + agendaTimeLabel(event, LocalDate(y, mo, d), zone) + + @Test + fun `a single-day timed event is a start-end range`() { + val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(labelOn(2026, 7, 2, e)) + .isEqualTo(AgendaTimeLabel.Range(e.start, e.end)) + } + + @Test + fun `a single-day all-day event is all-day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `a multi-day timed event names the start, middle, and end days`() { + val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start)) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } + + @Test + fun `a multi-day all-day event is all-day on every day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `an event begun before the shown day is not labelled as starting`() { + // Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends. + val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } +} 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 new file mode 100644 index 0000000..7cfd97f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -0,0 +1,150 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class GroupAgendaDaysTest { + + private val zone = TimeZone.UTC + private val anchor = LocalDate(2026, 7, 1) + // A wide window so clamping to the window end is out of the way unless tested. + private val windowEnd = LocalDate(2026, 7, 31) + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event( + id: Long, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean = false, + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1, + title = title, + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun days(instances: List) = + groupAgendaDays(anchor, windowEnd, instances, zone) + + @Test + fun `a timed multi-day event lists on every day it spans`() { + val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0)) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + result.forEach { assertThat(it.events).containsExactly(e) } + } + + @Test + fun `an all-day multi-day event stops before its exclusive next-midnight end`() { + // 1–3 July inclusive: end is the exclusive midnight opening 4 July. + val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + } + + @Test + fun `a single-day event lists exactly once`() { + val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 2)) + } + + @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)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 1)) + } + + @Test + fun `an event begun before the anchor is clamped to the anchor day`() { + val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(days(listOf(e)).map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + @Test + fun `an event running past the window is clamped to the last visible day`() { + val narrowEnd = LocalDate(2026, 7, 2) + val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0)) + + val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + // All-day events are stored at UTC midnights; resolving them in a device + // zone east of UTC would leak them onto the following day (see spanLastDay). + private val berlin = TimeZone.of("Europe/Berlin") + + private fun utcMidnight(y: Int, mo: Int, d: Int): Instant = + LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC) + + @Test + fun `a single-day all-day event does not leak onto the next day in an eastern zone`() { + val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2)) + assertThat(e.spansMultipleDays(berlin)).isFalse() + } + + @Test + fun `an all-day multi-day event spans its true days in an eastern zone`() { + // 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July. + val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + LocalDate(2026, 7, 4), + ).inOrder() + } + + @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) + val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0)) + + val dayOne = days(listOf(timed, allDay)).first { it.date == anchor } + + assertThat(dayOne.events).containsExactly(allDay, timed).inOrder() + } +}