diff --git a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt index 3c10bf3..bb5367a 100644 --- a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt +++ b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt @@ -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()}" } diff --git a/core-time/src/test/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormatTest.kt b/core-time/src/test/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormatTest.kt new file mode 100644 index 0000000..e9cbf62 --- /dev/null +++ b/core-time/src/test/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormatTest.kt @@ -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") + } +}