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:
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user