Skip to main content

Date and time

Day-of-month suffix​

1.dayOfMonthSuffix() // "st"
2.dayOfMonthSuffix() // "nd"
3.dayOfMonthSuffix() // "rd"
4.dayOfMonthSuffix() // "th"
11.dayOfMonthSuffix() // "th"

LocalDate.of(2026, 3, 1)
.formatWithDaySuffix("EEEE, MMMM d yyyy")
// "Sunday, March 1st 2026"

LocalDateTime.of(2026, 3, 1, 9, 15)
.formatWithDaySuffix("d MMMM yyyy HH:mm")
// "1st March 2026 09:15"

dayOfMonthSuffix, patternWithDaySuffix and formatWithDaySuffix all take a ZoneId on Instant, because an instant is a point on the timeline and only a zone gives it a calendar day.

val dispatchedAt = Instant.parse("2026-08-31T23:02:00Z")

dispatchedAt.formatWithDaySuffix("d MMMM yyyy", ZoneId.of("UTC"))
// "31st August 2026"

dispatchedAt.formatWithDaySuffix("d MMMM yyyy", ZoneId.of("Europe/London"))
// "1st September 2026"

A matching Pebble filter is registered as dateWithDaySuffix.

Date ranges​

formatDateRange renders a pair of dates as one phrase that says each part only once: a null to (how a single-day entry is modelled) or a to equal to from is that one day, a range inside one month says the month and year once and joins the two days with an unspaced en dash (6th–10th December 2026), a range inside one calendar year carries the year on its closing date alone, and a range crossing years spells both out. Both ends are formatted with formatWithDaySuffix.

formatDateRange(LocalDate.of(2026, 11, 5), null)
// "5th November 2026"

formatDateRange(LocalDate.of(2026, 11, 5), LocalDate.of(2026, 12, 5))
// "5th November – 5th December 2026"

formatDateRange(LocalDate.of(2026, 12, 30), LocalDate.of(2027, 1, 2))
// "30th December 2026 – 2nd January 2027"

The rule needs three patterns, since no single DateTimeFormatter pattern can express it: pattern is the full date, yearlessPattern drops the year, and dayPattern is the day alone. All three are the caller's, as are separator and daySeparator, the one between two days of the same month. A pattern with fields of its own, such as a weekday, needs a matching yearlessPattern and dayPattern for the leading date to carry them too.

formatDateRange(
from = LocalDate.of(2026, 11, 5),
to = LocalDate.of(2026, 12, 5),
pattern = "d MMM yyyy",
yearlessPattern = "d MMM",
separator = " to "
)
// "5th Nov to 5th Dec 2026"

Use yyyy for the year. YYYY is the week-based year, and it renders a plausible but wrong value on the days around new year: the 30th of December 2026 falls in ISO week-year 2026 along with the 2nd of January 2027, so the range above would read as one year.

A matching Pebble function is registered as dateRange.

Parsing a date that might not be one​

toLocalDateOrNull reads an ISO day out of somewhere that holds whatever was last written to it - a half-typed value in an autosaved draft, a query parameter, an imported row - where "not a date" is an answer rather than a failure. The receiver is nullable and the result never throws.

"2026-03-01".toLocalDateOrNull() // LocalDate.of(2026, 3, 1)
" 2026-03-01 ".toLocalDateOrNull() // LocalDate.of(2026, 3, 1)
null.toLocalDateOrNull() // null
"".toLocalDateOrNull() // null
"01/03/2026".toLocalDateOrNull() // null
"2026-02-30".toLocalDateOrNull() // null

Instant arithmetic​

Instant.now().plusDays(7)
Instant.now().minusDays(7)

Human-readable durations​

Duration.ofMillis(0).toHumanReadableString() // "0ms"
Duration.ofSeconds(125).toHumanReadableString() // "2m 5s"
Duration.ofMillis(3_600_500).toHumanReadableString() // "1h 0m 0s 500ms"
1.5.seconds.toHumanReadableString() // "1s 500ms"

Trailing and leading zero units are stripped, so a 90-second duration formats as 1m 30s rather than 0h 1m 30s 0ms.