fix(day): render unix timestamps in the time zone the caller asked for (#1659) Fixes # ## Proposed Changes - `pkg/day.Format` now applies the `tz` argument it already loaded with `time.Time.In`; before, `time.LoadLocation(tz)` was called and its result dropped, so every rendered date was the server's local zone. - An empty or unresolvable zone keeps the previous local-time behaviour, so nothing shifts when the site time zone is missing. - Adds a table-driven `TestFormatTimezone` (UTC / Tokyo / New York / Kolkata / Los Angeles) plus a fallback test. ## Additional Info Reproducer: `Format(1700000000, "YYYY-MM-DD HH:mm", "Asia/Tokyo")` returned `2023-11-14 22:13` instead of `2023-11-15 07:13`. This is the helper behind the `translatorTimeFormat` email/UI templates and behind the "suspended until" text that passes `"UTC"` explicitly (`internal/schema/user_schema.go:202`). No new dependency; `go mod tidy` and `golangci-lint v2.6.2` (`make lint`) are clean.
diff --git a/pkg/day/day.go b/pkg/day/day.go index 797da68..6c1cb49 100644 --- a/pkg/day/day.go +++ b/pkg/day/day.go
@@ -59,8 +59,13 @@ from = suffix } - _, _ = time.LoadLocation(tz) - formatted = time.Unix(unix, 0).Format(toFormat.String()) + loc, err := time.LoadLocation(tz) + if err != nil || tz == "" { + // An empty or unknown zone must not shift the instant that is being + // rendered: fall back to the server local zone. + loc = time.Local + } + formatted = time.Unix(unix, 0).In(loc).Format(toFormat.String()) return }
diff --git a/pkg/day/day_test.go b/pkg/day/day_test.go index 1ff7576..3dd0f37 100644 --- a/pkg/day/day_test.go +++ b/pkg/day/day_test.go
@@ -35,7 +35,7 @@ tz := "UTC" actual := Format(sec, "MMM D, YYYY [a las] HH:mm", tz) - expected := time.Unix(sec, 0).Format("Jan 2, 2006") + " a las " + time.Unix(sec, 0).Format("15:04") + expected := time.Unix(sec, 0).UTC().Format("Jan 2, 2006") + " a las " + time.Unix(sec, 0).UTC().Format("15:04") assert.Equal(t, expected, actual) } @@ -102,3 +102,44 @@ } } } + +// Format takes an IANA time zone name and must render the instant in that zone. +// Before the fix the loaded location was discarded, so every caller (the UI, +// the email templates and the "account suspended until" notice, which passes +// "UTC" explicitly) got the server's local zone instead. +func TestFormatTimezone(t *testing.T) { + // 2023-11-14 22:13:20 UTC + const sec = int64(1700000000) + + tests := []struct { + name string + format string + tz string + expected string + }{ + {"utc", "YYYY-MM-DD HH:mm", "UTC", "2023-11-14 22:13"}, + {"tokyo_crosses_midnight", "YYYY-MM-DD HH:mm", "Asia/Tokyo", "2023-11-15 07:13"}, + {"new_york", "YYYY-MM-DD HH:mm", "America/New_York", "2023-11-14 17:13"}, + {"kolkata_half_hour_offset", "YYYY-MM-DD HH:mm", "Asia/Kolkata", "2023-11-15 03:43"}, + {"los_angeles_long_date", "MMMM D, YYYY HH:mm", "America/Los_Angeles", "November 14, 2023 14:13"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, Format(sec, tt.format, tt.tz)) + }) + } +} + +// A zone that cannot be resolved must fall back to the server's local zone +// instead of failing or silently shifting the instant. +func TestFormatUnknownTimezoneFallsBackToLocal(t *testing.T) { + const sec = int64(1700000000) + expected := time.Unix(sec, 0).In(time.Local).Format("2006-01-02 15:04") + + for _, tz := range []string{"", "Not/AZone", "UTC+99"} { + t.Run("tz="+tz, func(t *testing.T) { + assert.Equal(t, expected, Format(sec, "YYYY-MM-DD HH:mm", tz)) + }) + } +}