From 4f09312daf72604f6b7042279ffece8709780818 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:22:00 -0400 Subject: [PATCH] feat(mustache): `apply_format` now accepts timezone info. --- TODOS.md | 3 +- mustache/pipes.odin | 28 +++++++++++++-- mustache/pipes_test.odin | 73 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/TODOS.md b/TODOS.md index 9784ee3..ba5ed2a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -32,7 +32,8 @@ ## Dates - [x] Accept "strings" - [x] Accept keys -- [ ] handle timezones +- [x] handle timezones +- [ ] Fix TZ cache leak in parallel tests: `tz_cache` uses `context.allocator` (tracking allocator in tests), but it's global state shared across parallel test threads. Race causes leak warnings. Fix: use heap allocator for TZ cache. - [ ] 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" diff --git a/mustache/pipes.odin b/mustache/pipes.odin index c3b3d9f..9d63e28 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -228,7 +228,15 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> date_format = df } - str2, err := apply_format(str, filter.args[:], pos, date_format) + // Resolve timezone (optional — empty is fine, means display as-is) + tz_name := "" + if raw := resolve_name("timezone", ctx); raw != nil { + if s, s_ok := reflect.as_string(raw); s_ok { + tz_name = s + } + } + + str2, err := apply_format(str, filter.args[:], pos, date_format, tz_name) if err != nil { return value, err } else { @@ -249,6 +257,7 @@ apply_format :: proc( args: []string, pos: int, date_format: string, + timezone_name: string, ) -> ( result: string, err: Error, @@ -270,7 +279,22 @@ apply_format :: proc( } } - log.debugf("date: '%s' format: '%s'", iso, date_format) + target_tz, tz_ok := get_cached_tz(timezone_name) + if !tz_ok { + return "", Error_Body { + msg = fmt.tprintf("unable to load timezone '%s'", timezone_name), + pos = pos, + kind = .Data, + } + } + + if target_tz != nil { + components, _ = convert_to_tz(components, target_tz) + } else if components.has_offset { + components.tz_abbr = format_offset(components.offset_seconds) + } + + log.debugf("date: '%s' format: '%s' tz: '%s'", iso, date_format, timezone_name) return format_date(components, fmt_str), nil } diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 6a9d622..0a29d9b 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -511,3 +511,76 @@ test_timezone_empty_when_not_set :: proc(t: ^testing.T) { result, _ := render(tpl, data, {}, context.temp_allocator) testing.expect_value(t, result, "[]") } + +// --------------------------------------------------------------------------- +// format pipe with timezone conversion +// --------------------------------------------------------------------------- + +@(test) +test_format_with_timezone_summer :: proc(t: ^testing.T) { + tz, tz_ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !tz_ok || tz == nil do return + + data := Format_Data { + date = "2026-03-15T12:49:54Z", + date_format = "15:04 MST", + timezone = "America/New_York", + } + tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "08:49 EDT") +} + +@(test) +test_format_with_timezone_winter :: proc(t: ^testing.T) { + tz, tz_ok := get_cached_tz("America/New_York") + defer destroy_tz_cache() + if !tz_ok || tz == nil do return + + data := Format_Data { + date = "2026-01-15T12:49:54Z", + date_format = "15:04 MST", + timezone = "America/New_York", + } + tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "07:49 EST") +} + +@(test) +test_format_mst_offset_no_timezone :: proc(t: ^testing.T) { + data := Format_Data { + date = "2026-03-15T08:49:54-04:00", + date_format = "MST", + } + tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "UTC-04:00") +} + +@(test) +test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) { + data := Format_Data { + date = "2026-03-15", + date_format = "MST", + } + tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "UTC") +} + +@(test) +test_format_invalid_timezone_errors :: proc(t: ^testing.T) { + defer destroy_tz_cache() + + data := Format_Data { + date = "2026-03-15", + date_format = "2 Jan 2006", + timezone = "Invalid/Zone", + } + tpl, _ := parse("{{date | format}}", "", allocator = context.temp_allocator) + defer delete_template(&tpl) + _, err := render(tpl, data, {}, context.temp_allocator) + testing.expect(t, err != nil, "invalid timezone should error") +}