From 7ed1d89b66743b05f31fac8243553a21881b8185 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:16:08 +0200 Subject: [PATCH 1/6] fix(agenda): list multi-day events on every day they span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groupAgendaDays keyed each instance by its start day alone, so a multi-day event surfaced only on its first day and vanished from the rest of its span in both the Agenda screen and the agenda widget. Expand each instance across every day from its start (clamped to the anchor for ongoing events) through its last occupied day, bounded by the visible window end. An event ending exactly at midnight — including the exclusive next-midnight all-day events end at — does not reach that boundary day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaUiState.kt | 50 +++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 2 +- .../calendula/widget/WidgetData.kt | 3 +- .../ui/agenda/GroupAgendaDaysTest.kt | 119 ++++++++++++++++++ 4 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt 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..6419101 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,12 @@ 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 /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( @@ -15,31 +18,46 @@ 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.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) + // The last day the 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]. + val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start + val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd) + 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/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..2c1eae9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -0,0 +1,119 @@ +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() + } + + @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() + } +} From 19baed9291e9f2060e5ed60793eba7fa2e39d2cd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:30:00 +0200 Subject: [PATCH 2/6] fix(agenda): scope LazyColumn key by day for spanning events A multi-day event now appears under every day it spans, so keying its row by instanceId alone repeated the key across days and crashed the LazyColumn ("Key already used") on scroll. Scope the key by day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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..45e5f03 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,7 +367,10 @@ 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, From 3feafe38f213b0e5db8d5da62853847557143fe8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:39:01 +0200 Subject: [PATCH 3/6] feat(agenda): day-aware time line for multi-day events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-day event repeated the same "start – end" on every day it spans ("14:00 – 14:00"), which reads as meaningless. Show only the part relevant to each day, with a "→" marking that it carries past the day's boundary: the first day shows the start ("14:00 →"), the last day the end ("→ 10:00"), and whole days in between an all-day span arriving from and continuing into their neighbours ("→ All day →"). Single-day rows are unchanged. Factors the span first/last-day resolution into shared EventInstance helpers reused by groupAgendaDays and the label. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 41 +++++++++++++++---- .../calendula/ui/agenda/AgendaUiState.kt | 26 +++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) 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 45e5f03..b512f9d 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 @@ -374,6 +374,7 @@ private fun AgendaList( ) { index, event -> AgendaEventRow( event = event, + day = day.date, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), modifier = animateItemMotion(), @@ -452,6 +453,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) { @Composable private fun AgendaEventRow( event: EventInstance, + day: LocalDate, position: Position, dimmed: Boolean, modifier: Modifier = Modifier, @@ -463,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 = { @@ -557,16 +559,41 @@ 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], with a "→" marking + * that it carries past this day's boundary: its first day shows the start + * ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between + * reads as an all-day span arriving from and continuing into its neighbours + * ("→ All day →"). + */ @Composable -private fun agendaTimeSummary(event: EventInstance): String { - val time = if (event.isAllDay) { - stringResource(R.string.event_detail_all_day) +private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { + val allDayLabel = stringResource(R.string.event_detail_all_day) + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = if (event.spansMultipleDays(zone)) { + val continuesBefore = day > event.spanFirstDay(zone) + val continuesAfter = day < event.spanLastDay(zone) + val core = when { + event.isAllDay -> allDayLabel + !continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day + !continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day + else -> allDayLabel // a full middle day + } + buildString { + if (continuesBefore) append("→ ") + append(core) + if (continuesAfter) append(" →") + } + } else if (event.isAllDay) { + allDayLabel } else { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() "${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.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 6419101..33438b7 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 @@ -9,6 +9,24 @@ import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds +/** The first calendar day this event occupies, in [zone]. */ +fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate = + start.toLocalDateTime(zone).date + +/** + * The last calendar day this event actually occupies, in [zone]. 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(zone).date +} + +/** Whether this event occupies more than one calendar day in [zone]. */ +fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = + spanFirstDay(zone) != spanLastDay(zone) + /** One calendar day with at least one event, for the agenda list. */ data class AgendaDay( val date: LocalDate, @@ -35,12 +53,8 @@ fun groupAgendaDays( ): List { val byDay = sortedMapOf>() for (instance in instances) { - val firstDay = instance.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) - // The last day the 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]. - val lastInstant = if (instance.end > instance.start) instance.end - 1.milliseconds else instance.start - val lastDay = lastInstant.toLocalDateTime(zone).date.coerceAtMost(windowEnd) + val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) + val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd) var day = firstDay while (day <= lastDay) { byDay.getOrPut(day) { mutableListOf() }.add(instance) From b5c2930609150f5341abfecf5e369ca1346a601a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:42:15 +0200 Subject: [PATCH 4/6] feat(agenda): spell out multi-day time line instead of arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "→" arrow convention read as unclear. Spell each day out instead: the first day names the start ("Starts 14:00"), the last day the end ("Ends 10:00"), and whole days in between read as "All day". All-day multi-day events stay "All day" on every day. Single-day rows unchanged. Adds agenda_span_starts / agenda_span_ends (owes Weblate backfill). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 26 +++++++------------ app/src/main/res/values/strings.xml | 3 +++ 2 files changed, 13 insertions(+), 16 deletions(-) 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 b512f9d..697daf5 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 @@ -562,11 +562,10 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { /** * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. * - * A multi-day event shows only the part relevant to [day], with a "→" marking - * that it carries past this day's boundary: its first day shows the start - * ("14:00 →"), its last day the end ("→ 10:00"), and any whole day in between - * reads as an all-day span arriving from and continuing into its neighbours - * ("→ All day →"). + * 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, day: LocalDate): String { @@ -575,18 +574,13 @@ private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { val locale = currentLocale() val time = if (event.spansMultipleDays(zone)) { - val continuesBefore = day > event.spanFirstDay(zone) - val continuesAfter = day < event.spanLastDay(zone) - val core = when { + val isFirstDay = day <= event.spanFirstDay(zone) + val isLastDay = day >= event.spanLastDay(zone) + when { event.isAllDay -> allDayLabel - !continuesBefore -> formatTime(event.start, is24Hour, locale) // the first day - !continuesAfter -> formatTime(event.end, is24Hour, locale) // the last day - else -> allDayLabel // a full middle day - } - buildString { - if (continuesBefore) append("→ ") - append(core) - if (continuesAfter) append(" →") + isFirstDay -> stringResource(R.string.agenda_span_starts, formatTime(event.start, is24Hour, locale)) + isLastDay -> stringResource(R.string.agenda_span_ends, formatTime(event.end, is24Hour, locale)) + else -> allDayLabel // a full middle day } } else if (event.isAllDay) { allDayLabel diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f995d..5c22fb3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -257,6 +257,9 @@ Tomorrow You\'re all caught up No more events today + + Starts %1$s + Ends %1$s Search From 3ec46c86315a9f99e880ce8b5d2a3e694fcddac2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 11:53:21 +0200 Subject: [PATCH 5/6] fix(agenda): resolve all-day span days in UTC, not the device zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. spanFirstDay/spanLastDay resolved every event in the device zone, but all-day events live at UTC midnights with an exclusive end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the next day, so a single-day all-day event reported spansMultipleDays and leaked onto a second agenda day. Resolve all-day dates in UTC, matching the Week view and detail card. Adds eastern-zone regression tests that the prior UTC-only tests could not catch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaUiState.kt | 21 +++++++++---- .../ui/agenda/GroupAgendaDaysTest.kt | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) 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 33438b7..2e93d30 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 @@ -9,18 +9,27 @@ import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds -/** The first calendar day this event occupies, in [zone]. */ +/** + * 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(zone).date + start.toLocalDateTime(dateZone(zone)).date /** - * The last calendar day this event actually occupies, in [zone]. 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]. + * 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(zone).date + return lastInstant.toLocalDateTime(dateZone(zone)).date } /** Whether this event occupies more than one calendar day in [zone]. */ 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 2c1eae9..7cfd97f 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 @@ -107,6 +107,37 @@ class GroupAgendaDaysTest { ).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) From 7de9b2f81b946b354be81558bca4bb4b6e1ce31d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 12:06:40 +0200 Subject: [PATCH 6/6] fix(agenda): share the day-aware time label with the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up. - The multi-day expansion in groupAgendaDays is shared with the agenda widget, but only the screen's summary was made day-aware — so the widget rendered the raw "start – end" on every spanned day, the very bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day, zone) into the shared agenda layer and resolve strings from it in both the screen and the widget, so they label identically. (findings 1, 2) - groupAgendaDays could silently drop an instance whose clamped span was empty (firstDay > lastDay); floor lastDay at firstDay so a returned instance always surfaces on at least its first visible day. (finding 3) - agendaTimeLabel resolves the span days once instead of the summary recomputing them 2–3× per row. (finding 5) Finding 4 (within-day sort) needs no change: sorting by absolute start already places a still-running multi-day event at the top of each day it continues into, which is chronologically correct (it is ongoing from that day's midnight), and at its real start time on its first day. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 22 ++---- .../calendula/ui/agenda/AgendaUiState.kt | 36 ++++++++- .../calendula/widget/agenda/AgendaWidget.kt | 29 +++++-- .../ui/agenda/AgendaTimeLabelTest.kt | 76 +++++++++++++++++++ 4 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt 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 697daf5..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 @@ -569,23 +569,17 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { */ @Composable private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String { - val allDayLabel = stringResource(R.string.event_detail_all_day) val is24Hour = LocalUse24HourFormat.current val locale = currentLocale() - val time = if (event.spansMultipleDays(zone)) { - val isFirstDay = day <= event.spanFirstDay(zone) - val isLastDay = day >= event.spanLastDay(zone) - when { - event.isAllDay -> allDayLabel - isFirstDay -> stringResource(R.string.agenda_span_starts, formatTime(event.start, is24Hour, locale)) - isLastDay -> stringResource(R.string.agenda_span_ends, formatTime(event.end, is24Hour, locale)) - else -> allDayLabel // a full middle day - } - } else if (event.isAllDay) { - allDayLabel - } else { - "${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}" + 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() } 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 2e93d30..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 @@ -8,6 +8,7 @@ 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 @@ -36,6 +37,36 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { 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( val date: LocalDate, @@ -63,7 +94,10 @@ fun groupAgendaDays( val byDay = sortedMapOf>() for (instance in instances) { val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor) - val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd) + // 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) 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/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)) + } +}