feat: Added timezone infrastructure.

This commit is contained in:
Spencer Brower
2026-07-24 10:45:00 -04:00
parent 0d1e9ba352
commit 4d847d95d3
3 changed files with 202 additions and 1 deletions
+1
View File
@@ -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
+99
View File
@@ -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,
@@ -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
}
+101
View File
@@ -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")
}