From f2cc7fa9ad583dc7072996c97254bd47e5f8f40c Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:44:44 -0400 Subject: [PATCH 1/3] feat: add fixed DateTime formatting --- src/pkg.generated.mbti | 1 + src/tempo.mbt | 15 +++++++++++++++ src/tempo_test.mbt | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/pkg.generated.mbti b/src/pkg.generated.mbti index 530726e..371c499 100644 --- a/src/pkg.generated.mbti +++ b/src/pkg.generated.mbti @@ -94,6 +94,7 @@ pub fn DateTime::end_of_month(Self) -> Self pub fn DateTime::end_of_year(Self) -> Self pub fn DateTime::epoch() -> Self pub fn DateTime::format(Self) -> String +pub fn DateTime::format_fixed(Self) -> String pub fn DateTime::from_unix_micros(Int64) -> Self pub fn DateTime::from_unix_millis(Int64) -> Self pub fn DateTime::from_unix_nanos(Int64) -> Self diff --git a/src/tempo.mbt b/src/tempo.mbt index 602a130..73200d0 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -2345,6 +2345,21 @@ pub fn DateTime::format(self : DateTime) -> String { format_datetime_without_zone(self) + "Z" } +///| +/// Format this DateTime as a fixed-width UTC timestamp: +/// `YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`. +/// +/// The fractional-second field is always present with exactly 9 nanosecond +/// digits. For years 0..9999, byte-for-byte string comparison of two +/// `format_fixed` outputs matches chronological order. Outside that year range, +/// expanded or negative year text (especially a leading `-`) does not preserve +/// that lexicographic ordering guarantee. +pub fn DateTime::format_fixed(self : DateTime) -> String { + let d = self.date + let t = self.time + "\{pad4_year(d.year)}-\{pad2(d.month)}-\{pad2(d.day)}T\{pad2(t.hour)}:\{pad2(t.minute)}:\{pad2(t.second)}.\{pad9(t.nanosecond)}Z" +} + ///| fn format_datetime_without_zone(dt : DateTime) -> String { let d = dt.date diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index c7bf5bb..2011ef3 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -1522,6 +1522,41 @@ test "DateTime::format nanosecond precision" { assert_eq(dt.format(), "2024-03-15T12:34:56.123456789Z") } +///| +test "DateTime::format_fixed whole second keeps nanoseconds" { + let dt = @tempo.DateTime::from_unix_seconds(0L) + assert_eq(dt.format_fixed(), "1970-01-01T00:00:00.000000000Z") +} + +///| +test "DateTime::format_fixed pads parsed fractional seconds" { + let dt = @tempo.DateTime::parse("2024-07-21T17:11:00.5Z") + assert_eq(dt.format_fixed(), "2024-07-21T17:11:00.500000000Z") +} + +///| +test "DateTime::format_fixed sorts lexicographically for in-range years" { + let t1 = @tempo.DateTime::parse("2024-07-21T17:11:00.499999999Z") + let t2 = @tempo.DateTime::parse("2024-07-21T17:11:00.5Z") + assert_eq(t1 < t2, true) + assert_eq(t1.format_fixed() < t2.format_fixed(), true) +} + +///| +test "DateTime::format_fixed in-range outputs share width" { + let first = @tempo.DateTime::new( + @tempo.Date::new(0, 1, 1), + @tempo.Time::new(0, 0, 0, 0), + ).format_fixed() + let middle = @tempo.DateTime::parse("2024-07-21T17:11:00.5Z").format_fixed() + let last = @tempo.DateTime::new( + @tempo.Date::new(9999, 12, 31), + @tempo.Time::new(23, 59, 59, 999_999_999), + ).format_fixed() + assert_eq(first.length(), middle.length()) + assert_eq(middle.length(), last.length()) +} + ///| test "DateTime::format negative year" { let d = @tempo.Date::new(-500, 6, 15) From 515fcc8e223c721556dba04b1eb3d5bec3330bf3 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:48:14 -0400 Subject: [PATCH 2/3] docs: clarify fixed DateTime format width --- src/tempo.mbt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tempo.mbt b/src/tempo.mbt index 73200d0..7fcaf10 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -2346,7 +2346,8 @@ pub fn DateTime::format(self : DateTime) -> String { } ///| -/// Format this DateTime as a fixed-width UTC timestamp: +/// Format this DateTime as a UTC timestamp with a fixed-width fractional +/// second. For years 0..9999, the whole output is fixed-width: /// `YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`. /// /// The fractional-second field is always present with exactly 9 nanosecond From 72b02c9ff7849a0a7ae2036dbd045f83d414e42e Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:50:03 -0400 Subject: [PATCH 3/3] test: cover fixed DateTime date-boundary ordering --- src/tempo.mbt | 4 ++-- src/tempo_test.mbt | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tempo.mbt b/src/tempo.mbt index 7fcaf10..8767320 100644 --- a/src/tempo.mbt +++ b/src/tempo.mbt @@ -2347,8 +2347,8 @@ pub fn DateTime::format(self : DateTime) -> String { ///| /// Format this DateTime as a UTC timestamp with a fixed-width fractional -/// second. For years 0..9999, the whole output is fixed-width: -/// `YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`. +/// second; for years 0..9999, the whole output is fixed-width and +/// lexicographically sortable: `YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`. /// /// The fractional-second field is always present with exactly 9 nanosecond /// digits. For years 0..9999, byte-for-byte string comparison of two diff --git a/src/tempo_test.mbt b/src/tempo_test.mbt index 2011ef3..f7b8ce9 100644 --- a/src/tempo_test.mbt +++ b/src/tempo_test.mbt @@ -1538,8 +1538,12 @@ test "DateTime::format_fixed pads parsed fractional seconds" { test "DateTime::format_fixed sorts lexicographically for in-range years" { let t1 = @tempo.DateTime::parse("2024-07-21T17:11:00.499999999Z") let t2 = @tempo.DateTime::parse("2024-07-21T17:11:00.5Z") + let year_end = @tempo.DateTime::parse("2024-12-31T23:59:59.999999999Z") + let next_year = @tempo.DateTime::parse("2025-01-01T00:00:00.000000000Z") assert_eq(t1 < t2, true) assert_eq(t1.format_fixed() < t2.format_fixed(), true) + assert_eq(year_end < next_year, true) + assert_eq(year_end.format_fixed() < next_year.format_fixed(), true) } ///|