From 213385dd7059bf9192f5f6efc4e43c04eb15b36c Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Fri, 24 Jul 2026 10:12:14 -0400 Subject: [PATCH] feat: Added timezone field to Base_Data. --- PLAN.md | 87 ++++++++++++++++++++++++++++++++++++++++ mustache/pipes_test.odin | 34 ++++++++++++++-- render.odin | 22 +++++----- 3 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..91a8283 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,87 @@ +# Timezone Support + +## Goal + +Add timezone conversion to the `format` pipe so dates display in the configured site timezone, with correct abbreviations for the `MST` token. + +## Context + +Dates are stored as raw ISO 8601 strings. The `format` pipe formats them for display using Go-style layout tokens. Currently `parse_iso_date` ignores the timezone offset suffix, and `MST` is hardcoded to `"UTC"`. + +Pipes now take `ctx: []any`, so timezone config can flow through the data context identically to `date_format` — no new parameters to thread. + +## Decisions + +- **MST fallback** (no target tz, date has offset): `UTC-04:00` format +- **`now` field**: intentionally UTC (offset=0). The `format` pipe handles timezone display. +- **TZ_Region cache**: lives in the mustache package (`format.odin`) + +## Files changed + +| File | Changes | +|---|---| +| `mustache/format.odin` | Extend `Date_Components` (+`offset_seconds`, `has_offset`, `tz_abbr`). Extend `parse_iso_date` to parse trailing offset. Add `tz_cache`, `get_cached_tz`, `convert_to_tz`, `format_offset`, `destroy_tz_cache`. Fix MST token. Import `core:time/timezone`. | +| `mustache/pipes.odin` | Resolve `date_timezone` from ctx in `"format"` case (optional — nil is fine). Add `timezone_name` param to `apply_format`. Orchestrates parse → convert → format. | +| `render.odin` | Add `date_timezone: string` to `Base_Data`, populate from `site.date.timezone`. Remove `// TODO: CAlculate offset` (offset=0 is intentional — UTC). | +| `site.odin` | Call `mustache.destroy_tz_cache()` from `destroy_site`. No structural changes — `Date_Preferences.timezone` already exists and flows through config. | +| `mustache/pipes_test.odin` | Add `date_timezone: string` to test data structs. Add timezone conversion tests. | + +## Conversion logic (in `apply_format`) + +``` +1. parse_iso_date(iso) → components (now includes offset_seconds, has_offset) +2. tz_name := resolve "date_timezone" from ctx (optional) +3. target_tz := get_cached_tz(tz_name) // nil if empty/UTC/not configured +4. if target_tz != nil: + components = convert_to_tz(components, target_tz) + // tz_abbr filled by convert_to_tz via timezone.shortname() +5. else if components.has_offset: + components.tz_abbr = format_offset(components.offset_seconds) + // e.g. "UTC-04:00" +6. else: + components.tz_abbr = "UTC" +7. format_date(components, fmt) +``` + +## `convert_to_tz` flow + +``` +1. Build DateTime from components (tz=nil=UTC) +2. If has_offset: add offset_seconds to get true UTC +3. timezone.datetime_to_tz(utc_dt, target_tz) → converted DateTime +4. Extract components from converted DateTime +5. tz_abbr = timezone.shortname(converted_dt) // "EST", "EDT", etc. +``` + +## MST fallback: `format_offset` + +``` +0 → "UTC" +-14400 → "UTC-04:00" ++19800 → "UTC+05:30" +``` + +## `now` field + +`now` stays UTC (offset=0). Remove the `// TODO: CAlculate offset` comment — it's correct as-is. Templates format it with `{{now | format}}` and the pipe handles timezone display. + +## Behavior matrix + +| Config TZ | ISO has offset | Conversion | `MST` output | +|---|---|---|---| +| `"America/New_York"` | yes (`-04:00`) | UTC → NY (DST-aware) | `"EST"`/`"EDT"` | +| `"America/New_York"` | no | assume already in NY | `"EST"`/`"EDT"` | +| not set | yes (`-04:00`) | none — display as-is | `"UTC-04:00"` | +| not set | no | none | `"UTC"` | + +## Imports added + +- `mustache/format.odin`: `import "core:time/timezone"` (for `region_load`, `datetime_to_tz`, `shortname`, `region_destroy`) + +## Odin timezone API reference + +- `timezone.region_load(name: string) -> (^datetime.TZ_Region, bool)` — `"local"` reads `$TZ` env, falls back to `/etc/localtime` +- `timezone.region_destroy(region: ^datetime.TZ_Region)` +- `timezone.datetime_to_tz(dt: DateTime, tz: ^TZ_Region) -> (DateTime, bool)` — DST-aware. If `dt.tz == tz`, no-op. If `dt.tz == nil`, treats as UTC. +- `timezone.shortname(dt: DateTime) -> (string, bool)` — abbreviation from TZ_Region records (e.g. `"EST"`, `"EDT"`) +- `datetime.DateTime :: struct { using date: Date, using time: Time, tz: ^TZ_Region }` — `tz == nil` means UTC diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 149c269..6a9d622 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -209,6 +209,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) { Scalar_Data :: struct { name: string, date_format: string, + timezone: string, } data := Scalar_Data { name = "2026-03-15T08:49:54-04:00", @@ -222,8 +223,9 @@ test_interp_pipe_basic :: proc(t: ^testing.T) { @(test) test_interp_pipe_unescaped :: proc(t: ^testing.T) { Scalar_Data :: struct { - name: string, - date_format: string, + name: string, + date_format: string, + date_timezone: string, } data := Scalar_Data { name = "2025-12-25T00:00:00Z", @@ -239,6 +241,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) { List_Data :: struct { items: [3]string, date_format: string, + timezone: string, } data := List_Data { items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"}, @@ -254,8 +257,9 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) { // --------------------------------------------------------------------------- Format_Data :: struct { - date: string, + date: string, date_format: string, + timezone: string, } @(test) @@ -483,3 +487,27 @@ test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) { _, err := render(tpl, data, {}, context.temp_allocator) testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key") } + +@(test) +test_timezone_resolves_from_context :: proc(t: ^testing.T) { + TZ_Data :: struct { + timezone: string, + } + data := TZ_Data { + timezone = "America/New_York", + } + tpl, _ := parse("[{{timezone}}]", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "[America/New_York]") +} + +@(test) +test_timezone_empty_when_not_set :: proc(t: ^testing.T) { + TZ_Data :: struct { + timezone: string, + } + data := TZ_Data {} + tpl, _ := parse("[{{timezone}}]", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "[]") +} diff --git a/render.odin b/render.odin index 6f3d0c9..0c012a5 100644 --- a/render.odin +++ b/render.odin @@ -19,13 +19,14 @@ Page_Context :: struct { } Base_Data :: struct { - now: string, - params: json.Value, - body: string, - title: string, - description: string, - og: Open_Graph, + now: string, + params: json.Value, + body: string, + title: string, + description: string, + og: Open_Graph, date_format: string, + timezone: string, } Page_Data :: struct { @@ -163,11 +164,12 @@ render_site :: proc(site: ^Site) { // Build base data once base := Base_Data { - now = now, - params = site.params, - description = site.description, - og = site.og, + now = now, + params = site.params, + description = site.description, + og = site.og, date_format = site.date.format, + timezone = site.date.timezone, } // Find home page