Merge pull request 'fix(core-time): render all-day values in UTC, not the device zone' (#1) from fix/all-day-utc-formatting into main

Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/1
This commit is contained in:
Jean-Luc Makiola
2026-08-02 17:47:19 +02:00
2 changed files with 94 additions and 4 deletions
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
/**
* The zone an instant should be read in to recover the date it denotes.
*
* All-day items are date-only in iCalendar and are stored as **UTC midnight** of
* their calendar date (the convention the tasks/calendar providers read back).
* Rendering one in the device zone shows the previous day for anyone west of
* UTC — so all-day values resolve against UTC, timed values against the device.
*/
private fun Instant.atDisplayZone(allDay: Boolean) =
if (allDay) java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(ZoneOffset.UTC)
else atSystemZone()
/** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
fun Instant.formatDate(allDay: Boolean = false): String =
atDisplayZone(allDay).format(dateFormatter)
/** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime()}"
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" /
// "Jun 20" — derived from the pattern so day/month order still follows the locale.
@@ -49,10 +63,10 @@ private val dateNoYearFormatter: DateTimeFormatter = run {
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
*/
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
val zdt = atSystemZone()
val zdt = atDisplayZone(allDay)
val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate()
else formatDate(allDay)
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
else "$datePart · ${formatTime()}"
}
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.floret.time
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.TimeZone
import kotlin.time.Instant
class DateTimeFormatTest {
private val originalZone: TimeZone = TimeZone.getDefault()
@AfterEach
fun restoreZone() = TimeZone.setDefault(originalZone)
private val july20: LocalDate = LocalDate.of(2026, 7, 20)
/** The all-day storage form: UTC midnight of the calendar date. */
private val allDayJuly20: Instant = Instant.fromEpochMilliseconds(
july20.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
/** What the locale renders for a given date — so the test doesn't pin a format. */
private fun medium(date: LocalDate): String =
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
@Test
fun `an all-day date reads the same whatever the device zone`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")) // UTC-4 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin")) // UTC+2 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland")) // UTC+12
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `reading an all-day value as timed slips a day west of UTC`() {
// Guards the regression directly: this is what the all-day flag prevents.
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
assertThat(allDayJuly20.formatDate()).isEqualTo(medium(july20.minusDays(1)))
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `timed values still render in the device zone`() {
// 2026-07-20T23:30Z — the next day already, in Auckland.
val lateUtc = Instant.fromEpochMilliseconds(
july20.atTime(23, 30).atZone(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland"))
assertThat(lateUtc.formatDate()).isEqualTo(medium(july20.plusDays(1)))
}
@Test
fun `formatDateTime drops the time for all-day and keeps it otherwise`() {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"))
assertThat(allDayJuly20.formatDateTime(allDay = true)).isEqualTo(medium(july20))
assertThat(allDayJuly20.formatDateTime(allDay = false)).contains("·")
}
@Test
fun `compact form is zone-correct for all-day too`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
val compact = allDayJuly20.formatDateTimeCompact(allDay = true)
// No time part, and the day must not have slipped to the 19th.
assertThat(compact).doesNotContain("·")
assertThat(compact).contains("20")
}
}