mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added timezone field to Base_Data.
This commit is contained in:
@@ -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
|
||||
@@ -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}}]", "<test>", 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}}]", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "[]")
|
||||
}
|
||||
|
||||
+12
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user