fix(search): date all-day results in UTC, like every other view (#82)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m32s

Search formatted a result's date with ZoneId.systemDefault(), while the
month, week, day, agenda and detail surfaces all resolve an all-day event's
dates in UTC. All-day events are stored at UTC midnight with an exclusive
end, so west of UTC that difference is a whole day: an event on the 19th
came back from search dated the 18th, contradicting the grid it was filed
in. East of UTC the offset happens to land on the same date, which is why
this went unnoticed.

The rule was already written down twice — once as a private helper in
AgendaUiState, once inline in coversDay and formatWhen — so rather than
adding a third copy, dateZone/spanFirstDay/spanLastDay/spansMultipleDays
move into domain/Models.kt, where they are pure date logic rather than
agenda UI state, and search reads its date through spanFirstDay. The clock
time stays in the device zone; it is only ever rendered for timed events.

Covered by EventInstanceSpanTest, which pins both directions: the west-of-
UTC case from this issue and the east-of-UTC leak from #65.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:48:48 +02:00
parent bf6415c023
commit 7c94425e41
6 changed files with 143 additions and 31 deletions

View File

@@ -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

View File

@@ -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?,

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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