diff --git a/CHANGELOG.md b/CHANGELOG.md index 543bf84..ff5845e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- Search results now show an all-day event's real date. West of UTC — anywhere in + the Americas, say — a search hit was dated one day early, disagreeing with the + day the month, week and agenda views file the same event under ([#82]). - Reminders now arrive for every calendar you have switched on. A calendar that was hidden at system level — switched off in another calendar app, or never switched on after being added — still showed its events and listed their @@ -1135,3 +1138,4 @@ automatically, with zero telemetry and no internet permission. [#44]: https://codeberg.org/jlmakiola/calendula/issues/44 [#70]: https://codeberg.org/jlmakiola/calendula/issues/70 [#75]: https://codeberg.org/jlmakiola/calendula/issues/75 +[#82]: https://codeberg.org/jlmakiola/calendula/issues/82 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 311d590..a01bf7c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -1,5 +1,9 @@ package de.jeanlucmakiola.calendula.domain +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant data class CalendarSource( @@ -72,6 +76,35 @@ data class EventInstance( */ fun EventInstance.hasEnded(now: Instant): Boolean = end <= now +/** + * The zone this 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 else shifts the day boundaries — east of UTC the end + * leaks onto the following day (#65), west of UTC the start pulls back onto the + * previous one (#82). Every surface that has to name an all-day event's date + * goes through here, so grid, agenda, detail and search cannot disagree. + */ +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 [EventInstance.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) + data class EventDetail( val instance: EventInstance, val description: String?, 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 8bd3be8..2c5ec3a 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,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.spanFirstDay +import de.jeanlucmakiola.calendula.domain.spanLastDay 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 505e753..b1caa35 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -57,9 +57,12 @@ import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.floret.components.positionOf +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDate import java.time.Instant as JavaInstant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -220,9 +223,14 @@ private fun searchSummary(event: EventInstance): String { val start = remember(event.start, zone) { JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) } - val dateText = remember(locale) { + // The date comes from the shared span rule, not from [start]: an all-day + // event sits at UTC midnight, so reading its date in the device zone names + // the day before west of UTC (#82). The clock time below stays in the device + // zone — it is only ever rendered for timed events. + val dateText = remember(event.start, event.end, event.isAllDay, locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) - }.format(start) + .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) + } val use24Hour = LocalUse24HourFormat.current val timeText = if (event.isAllDay) { stringResource(R.string.event_detail_all_day) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt new file mode 100644 index 0000000..6591a75 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +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 + +/** + * The all-day day-boundary rule every surface shares. All-day events are stored + * at UTC midnights with an exclusive end, so their dates must be resolved in UTC + * whatever the device zone is — reading them in the device zone names the wrong + * day on both sides of the meridian (#65 east, #82 west). + */ +class EventInstanceSpanTest { + + private val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in July + private val newYork = TimeZone.of("America/New_York") // UTC-4 in July + + /** 19 July 2026, all day: UTC midnight to the exclusive next UTC midnight. */ + private fun allDayJul19(): EventInstance = instance( + start = utc(2026, 7, 19), + end = utc(2026, 7, 20), + isAllDay = true, + ) + + private fun utc(y: Int, mo: Int, d: Int, h: Int = 0): Instant = + LocalDateTime(y, mo, d, h, 0).toInstant(TimeZone.UTC) + + private fun instance(start: Instant, end: Instant, isAllDay: Boolean) = EventInstance( + instanceId = 1L, + eventId = 1L, + calendarId = 1L, + title = "Event", + start = start, + end = end, + isAllDay = isAllDay, + color = 0xFF000000.toInt(), + location = null, + ) + + @Test + fun `all-day event keeps its date west of UTC`() { + // Regression for #82: 00:00 UTC on the 19th is 20:00 on the 18th in New + // York, so resolving in the device zone would name the 18th. + val event = allDayJul19() + assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19)) + assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19)) + assertThat(event.spansMultipleDays(newYork)).isFalse() + } + + @Test + fun `all-day event keeps its date east of UTC`() { + // Regression for #65: the exclusive end dips past local midnight in + // Berlin, which would leak the event onto the 20th. + val event = allDayJul19() + assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 19)) + assertThat(event.spanLastDay(berlin)).isEqualTo(LocalDate(2026, 7, 19)) + assertThat(event.spansMultipleDays(berlin)).isFalse() + } + + @Test + fun `multi-day all-day event ends on its last covered day`() { + val event = instance(utc(2026, 7, 19), utc(2026, 7, 22), isAllDay = true) + assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19)) + assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 21)) + assertThat(event.spansMultipleDays(newYork)).isTrue() + } + + @Test + fun `timed event resolves in the device zone`() { + // 23:30 UTC on the 19th is already the 20th in Berlin and still the 19th + // in New York — a timed event follows the device zone, unlike all-day. + val event = instance(utc(2026, 7, 19, 23), utc(2026, 7, 20, 1), isAllDay = false) + assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 20)) + assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19)) + } + + @Test + fun `zero-length event occupies its start day`() { + val event = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 12), isAllDay = false) + assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19)) + } + + @Test + fun `dateZone pins all-day events to UTC and leaves timed events alone`() { + assertThat(allDayJul19().dateZone(newYork)).isEqualTo(TimeZone.UTC) + val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false) + assertThat(timed.dateZone(newYork)).isEqualTo(newYork) + } +} 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 179d93f..37d01d5 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.agenda import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.spansMultipleDays import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone