diff --git a/TODOS.md b/TODOS.md index 34f90cc..9784ee3 100644 --- a/TODOS.md +++ b/TODOS.md @@ -36,6 +36,7 @@ - [ ] display an error when no part of the date appears in the output. - [x] use `date.format` as the default format. - [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2" +- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime? ## General - [ ] Integrity hash diff --git a/mustache/format.odin b/mustache/format.odin index deaa7b3..d120da7 100644 --- a/mustache/format.odin +++ b/mustache/format.odin @@ -5,6 +5,7 @@ import "core:log" import "core:strings" import "core:time" import "core:time/datetime" +import "core:time/timezone" Date_Components :: struct { year: int, @@ -98,7 +99,7 @@ parse_offset :: proc(iso: string, c: ^Date_Components) { c.offset_seconds = sign * (hours * 3600 + minutes * 60) c.has_offset = true case: - // no recognizable offset + // no recognizable offset } } @@ -226,3 +227,101 @@ emit_am_pm_lower :: proc(b: ^strings.Builder, dt: Date_Components) { strings.write_string(b, "pm" if dt.hour >= 12 else "am") } +// --------------------------------------------------------------------------- +// Timezone conversion infrastructure +// --------------------------------------------------------------------------- + +format_offset :: proc(offset_seconds: int) -> string { + if offset_seconds == 0 do return "UTC" + sign := "+" if offset_seconds > 0 else "-" + abs_val := abs(offset_seconds) + hours := abs_val / 3600 + minutes := (abs_val % 3600) / 60 + return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes) +} + +// tz_cache caches loaded TZ_Region pointers by timezone name. +// Entries persist until destroy_tz_cache is called. +tz_cache: map[string]^datetime.TZ_Region + +get_cached_tz :: proc(name: string) -> (^datetime.TZ_Region, bool) { + if name == "" || name == "UTC" { + return nil, true + } + if cached, ok := tz_cache[name]; ok { + return cached, true + } + tz, ok := timezone.region_load(name) + if !ok { + return nil, false + } + tz_cache[name] = tz + return tz, true +} + +destroy_tz_cache :: proc() { + for _, tz in tz_cache { + if tz != nil { + timezone.region_destroy(tz) + } + } + delete(tz_cache) + tz_cache = nil +} + +// convert_to_tz converts date components from their source timezone to a +// target timezone. +// +// If the source has no offset (has_offset=false), the components are +// assumed to be in the target timezone — only the abbreviation is resolved. +// +// If the source has an offset, the components are first adjusted to true +// UTC, then converted to the target timezone. +// +// Precondition: target_tz != nil. +convert_to_tz :: proc( + c: Date_Components, + target_tz: ^datetime.TZ_Region, +) -> ( + result: Date_Components, + ok: bool, +) { + result = c + dt := datetime.DateTime { + year = i64(c.year), + month = i8(c.month), + day = i8(c.day), + hour = i8(c.hour), + minute = i8(c.minute), + second = i8(c.second), + } + + if !c.has_offset { + dt.tz = target_tz + abbr, _ := timezone.shortname(dt) + result.tz_abbr = abbr + return result, true + } + + tm := time.datetime_to_time(dt) or_return + + secs := time.time_to_unix(tm) - i64(c.offset_seconds) + tm = time.unix(secs, 0) + + dt_utc := time.time_to_datetime(tm) or_return + dt_out := timezone.datetime_to_tz(dt_utc, target_tz) or_return + + abbr, _ := timezone.shortname(dt_out) + + return { + year = int(dt_out.year), + month = int(dt_out.month), + day = int(dt_out.day), + hour = int(dt_out.hour), + minute = int(dt_out.minute), + second = int(dt_out.second), + tz_abbr = abbr, + }, + true +} + diff --git a/mustache/format_test.odin b/mustache/format_test.odin index e8319dd..3695ee5 100644 --- a/mustache/format_test.odin +++ b/mustache/format_test.odin @@ -209,3 +209,104 @@ test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) { result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006") testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023") } + +// --------------------------------------------------------------------------- +// format_offset +// --------------------------------------------------------------------------- + +@(test) +test_format_offset_zero :: proc(t: ^testing.T) { + testing.expect_value(t, format_offset(0), "UTC") +} + +@(test) +test_format_offset_negative :: proc(t: ^testing.T) { + testing.expect_value(t, format_offset(-14400), "UTC-04:00") +} + +@(test) +test_format_offset_positive :: proc(t: ^testing.T) { + testing.expect_value(t, format_offset(19800), "UTC+05:30") +} + +// --------------------------------------------------------------------------- +// get_cached_tz / convert_to_tz (require system zoneinfo) +// --------------------------------------------------------------------------- + +@(test) +test_get_cached_tz_utc_returns_nil :: proc(t: ^testing.T) { + tz, ok := get_cached_tz("UTC") + testing.expect_value(t, ok, true) + testing.expect_value(t, tz == nil, true) +} + +@(test) +test_get_cached_tz_empty_returns_nil :: proc(t: ^testing.T) { + tz, ok := get_cached_tz("") + testing.expect_value(t, ok, true) + testing.expect_value(t, tz == nil, true) +} + +@(test) +test_get_cached_tz_loads_named_region :: proc(t: ^testing.T) { + tz, ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !ok do return + testing.expect(t, tz != nil, "should load TZ_Region for America/New_York") +} + +@(test) +test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) { + tz, tz_ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !tz_ok do return + + c := Date_Components{ + year = 2026, month = 3, day = 15, + hour = 8, minute = 49, second = 54, + } + result, ok := convert_to_tz(c, tz) + testing.expect_value(t, ok, true) + testing.expect_value(t, result.hour, 8) + testing.expect_value(t, result.minute, 49) + testing.expect(t, len(result.tz_abbr) > 0, "should resolve abbreviation") +} + +@(test) +test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) { + tz, tz_ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !tz_ok do return + + // 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4) + c := Date_Components{ + year = 2026, month = 3, day = 15, + hour = 12, minute = 49, second = 54, + offset_seconds = 0, + has_offset = true, + } + result, ok := convert_to_tz(c, tz) + testing.expect_value(t, ok, true) + testing.expect_value(t, result.hour, 8) + testing.expect_value(t, result.minute, 49) + testing.expect_value(t, result.tz_abbr, "EDT") +} + +@(test) +test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) { + tz, tz_ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !tz_ok do return + + // 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54 + c := Date_Components{ + year = 2026, month = 3, day = 15, + hour = 8, minute = 49, second = 54, + offset_seconds = -14400, + has_offset = true, + } + result, ok := convert_to_tz(c, tz) + testing.expect_value(t, ok, true) + testing.expect_value(t, result.hour, 8) + testing.expect_value(t, result.tz_abbr, "EDT") +}