fix(search): date all-day results in UTC, like every other view (#82) (#88)

Fixes [#82](https://codeberg.org/jlmakiola/calendula/issues/82) — found while investigating [#67](https://codeberg.org/jlmakiola/calendula/issues/67).

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
lands on the same date, which is why it went unnoticed.

### What changed

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

### Testing

New `domain/EventInstanceSpanTest` pins both directions: the west-of-UTC case
from this issue and the east-of-UTC leak from #65, plus multi-day spans, timed
events following the device zone, and zero-length events.

Local sweep green: `test` (541), `lint`, `assembleDebug`, `check_translations.py`.

On-device reviewed on the Pixel 10 with the device timezone set to New York
(fix confirmed) and back to Berlin (no regression in search, month, week, day,
agenda or the agenda widget).

---
_Recreated on Codeberg from Gitea PR #102 (same head `7c94425`, unchanged) as part of the forge migration. Already on-device signed off._

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/88
This commit is contained in:
Jean-Luc Makiola
2026-07-29 21:47:08 +02:00
parent d037492cf6
commit 1d07b64a28
6 changed files with 143 additions and 31 deletions

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)