fix(core-time): render all-day values in UTC, not the device zone
All-day items are date-only in iCalendar and are stored as UTC midnight of their calendar date — the convention the tasks and calendar providers read back (OpenTasks resolves them through DateTime.toAllDay(), which discards the time and resolves the remaining date against UTC). formatDate/formatDateTime/formatDateTimeCompact resolved every instant against ZoneId.systemDefault(), so an all-day value rendered as the *previous* day for anyone west of UTC: a task stored as 2026-07-20T00:00Z showed as 19 Jul in New York. East of UTC it happened to land on the right day, which is why this went unnoticed. formatDate now takes an allDay flag (defaulting to false, so existing call sites are unaffected) and all-day values resolve against UTC. Callers must pass allDay at the call site — agendula and calendula both have sites that need updating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
|
|||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.time.chrono.IsoChronology
|
import java.time.chrono.IsoChronology
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.DateTimeFormatterBuilder
|
import java.time.format.DateTimeFormatterBuilder
|
||||||
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
|
|||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||||
.atZone(ZoneId.systemDefault())
|
.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". */
|
/** 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". */
|
/** Short localized time, e.g. "14:30". */
|
||||||
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
||||||
|
|
||||||
/** Date alone for all-day items, otherwise date + time. */
|
/** Date alone for all-day items, otherwise date + time. */
|
||||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
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" /
|
// 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.
|
// "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".
|
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
|
||||||
*/
|
*/
|
||||||
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
|
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
|
||||||
val zdt = atSystemZone()
|
val zdt = atDisplayZone(allDay)
|
||||||
val datePart =
|
val datePart =
|
||||||
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
|
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
|
||||||
else formatDate()
|
else formatDate(allDay)
|
||||||
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
|
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
|
||||||
else "$datePart · ${formatTime()}"
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user