diff --git a/console/public/templates/users.csv b/console/public/templates/users.csv
index 705171c0c..81bd17cec 100644
--- a/console/public/templates/users.csv
+++ b/console/public/templates/users.csv
@@ -1 +1,11 @@
-external_id,email,phone,timezone,locale
\ No newline at end of file
+external_id,email,phone,timezone,locale
+user-1,user1@example.com,+1234567890,Europe/Amsterdam,en
+user-2,user2@example.com,+1234567891,America/New_York,en
+user-3,user3@example.com,+1234567892,GMT+2,en
+user-4,user4@example.com,+1234567893,GMT-5,en
+user-5,user5@example.com,+1234567894,UTC+8,en
+user-6,user6@example.com,+1234567895,EST,en
+user-7,user7@example.com,+1234567896,PST,en
+user-8,user8@example.com,+1234567897,CET,en
+user-9,user9@example.com,+1234567898,IST,en
+user-10,user10@example.com,+1234567899,Asia/Tokyo,en
\ No newline at end of file
diff --git a/console/src/views/users/Users.tsx b/console/src/views/users/Users.tsx
index cf147a69c..dc51903cc 100644
--- a/console/src/views/users/Users.tsx
+++ b/console/src/views/users/Users.tsx
@@ -11,12 +11,13 @@ import {
Upload,
Mail,
Database,
+ Check,
} from "lucide-react"
import { UserImportDialog } from "@/components/ui/user-import-dialog"
import { NIL } from "uuid"
import { useRoute } from "@/hooks/use-route"
import { useResolver } from "../../hooks"
-import { formatDate } from "../../utils"
+import { formatDate, cn } from "../../utils"
import { getRandomColor } from "@/lib/colors"
import { getUserDisplayName, getUserInitials, getPrimaryExternalId } from "@/lib/name"
import { PreferencesContext } from "@/contexts/PreferencesContext"
@@ -45,6 +46,15 @@ import {
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
import { AttributeEditor } from "@/components/ui/attribute-editor"
import { LocalePicker } from "@/components/locale/picker"
@@ -86,6 +96,7 @@ export default function Users() {
const [newUserExternalId, setNewUserExternalId] = useState("")
const [newUserEmail, setNewUserEmail] = useState("")
const [newUserPhone, setNewUserPhone] = useState("")
+ const [isNewUserTimezoneOpen, setIsNewUserTimezoneOpen] = useState(false)
const [newUserTimezone, setNewUserTimezone] = useState(
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
)
@@ -449,13 +460,50 @@ export default function Users() {
-
-
setNewUserTimezone(e.target.value)}
- />
+
+
+
+
+
+
+
+
+
+
+ {t("no_timezone_found", "No timezone found.")}
+
+
+ {Intl.supportedValuesOf("timeZone").map((tz) => (
+ {
+ setNewUserTimezone(tz)
+ setIsNewUserTimezoneOpen(false)
+ }}
+ >
+
+ {tz}
+
+ ))}
+
+
+
+
+
diff --git a/docs/content/docs/lists.mdx b/docs/content/docs/lists.mdx
index 1a43ec815..8e9da8f94 100644
--- a/docs/content/docs/lists.mdx
+++ b/docs/content/docs/lists.mdx
@@ -137,6 +137,14 @@ user_003,carol@example.com,Carol,Initech,organic
All other columns become custom properties on the user.
+
+ **Timezone auto-conversion**: Values in the `timezone` column are automatically
+ resolved to IANA timezone names. Abbreviations like `EST`, `PST`, `CET` and
+ GMT/UTC offsets like `GMT-5` or `+02:00` are converted to their IANA equivalents
+ (e.g., `America/New_York`, `America/Los_Angeles`). IANA names like
+ `America/New_York` are used as-is.
+
+
### Updating via API
For newsletter signups or other integrations, use the Admin API to add users to a static list:
diff --git a/docs/content/docs/users.mdx b/docs/content/docs/users.mdx
index 1a7c09ceb..30c68e586 100644
--- a/docs/content/docs/users.mdx
+++ b/docs/content/docs/users.mdx
@@ -266,6 +266,14 @@ The `identifier` column is required and is used as the `external_id` with the `d
All other columns (like `plan` and `company` above) become custom properties.
+
+ **Timezone auto-conversion**: Values in the `timezone` column are automatically
+ resolved to IANA timezone names. Abbreviations like `EST`, `PST`, `CET` and
+ GMT/UTC offsets like `GMT-5` or `+02:00` are converted to their IANA equivalents
+ (e.g., `America/New_York`, `America/Los_Angeles`). IANA names like
+ `America/New_York` are used as-is.
+
+
Importing via a [static list](/lists#static-lists) also creates users, but
adds them to the list. Direct CSV imports only create or update profiles.
diff --git a/go.mod b/go.mod
index 326f96df9..4fefd5665 100644
--- a/go.mod
+++ b/go.mod
@@ -309,6 +309,7 @@ require (
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect
+ github.com/tkuchiki/go-timezone v0.2.3 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index 8889c4329..3d3366613 100644
--- a/go.sum
+++ b/go.sum
@@ -793,6 +793,8 @@ github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY=
+github.com/tkuchiki/go-timezone v0.2.3 h1:D3TVdIPrFsu9lxGxqNX2wsZwn1MZtTqTW0mdevMozHc=
+github.com/tkuchiki/go-timezone v0.2.3/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY=
github.com/tomarrell/wrapcheck/v2 v2.10.0 h1:SzRCryzy4IrAH7bVGG4cK40tNUhmVmMDuJujy4XwYDg=
github.com/tomarrell/wrapcheck/v2 v2.10.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
diff --git a/internal/http/controllers/v1/management/test/users/out-of-order.csv b/internal/http/controllers/v1/management/test/users/out-of-order.csv
index 5d1a0fe6d..ace6e0243 100644
--- a/internal/http/controllers/v1/management/test/users/out-of-order.csv
+++ b/internal/http/controllers/v1/management/test/users/out-of-order.csv
@@ -1,4 +1,4 @@
email,phone,timezone,locale,external_id
-user1@example.com,+1234567890,UTC,en,user-1
-user2@example.com,+1234567891,UTC,en,user-2
-user3@example.com,+1234567892,UTC,en,user-3
+user1@example.com,+1234567890,GMT+2,en,user-1
+user2@example.com,+1234567891,UTC-5,en,user-2
+user3@example.com,+1234567892,PST,en,user-3
\ No newline at end of file
diff --git a/internal/http/controllers/v1/management/test/users/valid.csv b/internal/http/controllers/v1/management/test/users/valid.csv
index e6e93ca0a..a03f50e11 100644
--- a/internal/http/controllers/v1/management/test/users/valid.csv
+++ b/internal/http/controllers/v1/management/test/users/valid.csv
@@ -1,4 +1,4 @@
external_id,email,phone,timezone,locale
-user-1,user1@example.com,+1234567890,UTC,en
-user-2,user2@example.com,+1234567891,UTC,en
-user-3,user3@example.com,+1234567892,UTC,en
+user-1,user1@example.com,+1234567890,Europe/Amsterdam,en
+user-2,user2@example.com,+1234567891,GMT-5,en
+user-3,user3@example.com,+1234567892,EST,en
\ No newline at end of file
diff --git a/internal/importer/users.go b/internal/importer/users.go
index 5633a8860..003048c48 100644
--- a/internal/importer/users.go
+++ b/internal/importer/users.go
@@ -4,6 +4,7 @@ import (
"errors"
"strings"
+ "github.com/lunogram/platform/internal/pkg/timezone"
"github.com/lunogram/platform/internal/store/subjects"
)
@@ -13,10 +14,16 @@ var UserFieldMap = map[string]func(*subjects.UpsertUserParams, string){
"external_id": func(u *subjects.UpsertUserParams, v string) {
u.Identifiers = append(u.Identifiers, subjects.ExternalIDParam{Source: "default", ExternalID: v})
},
- "email": func(u *subjects.UpsertUserParams, v string) { u.Email = &v },
- "phone": func(u *subjects.UpsertUserParams, v string) { u.Phone = &v },
- "timezone": func(u *subjects.UpsertUserParams, v string) { u.Timezone = &v },
- "locale": func(u *subjects.UpsertUserParams, v string) { u.Locale = &v },
+ "email": func(u *subjects.UpsertUserParams, v string) { u.Email = &v },
+ "phone": func(u *subjects.UpsertUserParams, v string) { u.Phone = &v },
+ "timezone": func(u *subjects.UpsertUserParams, v string) {
+ if resolved, err := timezone.Resolve(v); err == nil {
+ u.Timezone = &resolved
+ } else {
+ u.Timezone = &v
+ }
+ },
+ "locale": func(u *subjects.UpsertUserParams, v string) { u.Locale = &v },
}
func NewUsers(headers []string) (*UserMapper, error) {
diff --git a/internal/importer/users_test.go b/internal/importer/users_test.go
index 82e614487..94babf6d1 100644
--- a/internal/importer/users_test.go
+++ b/internal/importer/users_test.go
@@ -153,6 +153,26 @@ func TestUserMapperMapRecord(t *testing.T) {
require.Empty(t, user.Data)
},
},
+ "timezone GMT offset conversion": {
+ headers: []string{"external_id", "timezone"},
+ record: []string{"user-gmt", "GMT+2"},
+ validate: func(t *testing.T, user subjects.UpsertUserParams) {
+ require.Len(t, user.Identifiers, 1)
+ require.Equal(t, "user-gmt", user.Identifiers[0].ExternalID)
+ require.NotNil(t, user.Timezone)
+ require.Equal(t, "Europe/Amsterdam", *user.Timezone)
+ },
+ },
+ "timezone IANA passthrough": {
+ headers: []string{"external_id", "timezone"},
+ record: []string{"user-iana", "America/New_York"},
+ validate: func(t *testing.T, user subjects.UpsertUserParams) {
+ require.Len(t, user.Identifiers, 1)
+ require.Equal(t, "user-iana", user.Identifiers[0].ExternalID)
+ require.NotNil(t, user.Timezone)
+ require.Equal(t, "America/New_York", *user.Timezone)
+ },
+ },
"empty values": {
headers: []string{"external_id", "email", "phone"},
record: []string{"user-empty", "", ""},
diff --git a/internal/pkg/timezone/resolver.go b/internal/pkg/timezone/resolver.go
new file mode 100644
index 000000000..79e3ebd68
--- /dev/null
+++ b/internal/pkg/timezone/resolver.go
@@ -0,0 +1,196 @@
+package timezone
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ gotz "github.com/tkuchiki/go-timezone"
+)
+
+var offsetOverride = map[int]string{
+ 0: "Europe/London",
+ 3600: "Europe/Paris",
+ 7200: "Europe/Amsterdam",
+ 10800: "Europe/Moscow",
+ 14400: "Asia/Dubai",
+ 18000: "Asia/Karachi",
+ 21600: "Asia/Dhaka",
+ 25200: "Asia/Bangkok",
+ 28800: "Asia/Shanghai",
+ 32400: "Asia/Tokyo",
+ 36000: "Australia/Sydney",
+ 39600: "Pacific/Noumea",
+ 43200: "Pacific/Auckland",
+ -3600: "Atlantic/Azores",
+ -7200: "Etc/GMT+2",
+ -10800: "America/Sao_Paulo",
+ -14400: "America/Halifax",
+ -18000: "America/New_York",
+ -21600: "America/Chicago",
+ -25200: "America/Denver",
+ -28800: "America/Los_Angeles",
+ -32400: "America/Anchorage",
+ -36000: "Pacific/Honolulu",
+ -39600: "Pacific/Pago_Pago",
+ -43200: "Etc/GMT+12",
+ -34200: "Pacific/Marquesas",
+ -16200: "America/Caracas",
+ -12600: "America/St_Johns",
+ 12600: "Asia/Tehran",
+ 16200: "Asia/Kabul",
+ 19800: "Asia/Kolkata",
+ 20700: "Asia/Kathmandu",
+ 23400: "Asia/Yangon",
+ 31500: "Australia/Eucla",
+ 34200: "Australia/Darwin",
+ 37800: "Australia/Lord_Howe",
+ 45900: "Pacific/Chatham",
+}
+
+var abbrMap = map[string]string{
+ "est": "America/New_York",
+ "edt": "America/New_York",
+ "cst": "America/Chicago",
+ "cdt": "America/Chicago",
+ "mst": "America/Denver",
+ "mdt": "America/Denver",
+ "pst": "America/Los_Angeles",
+ "pdt": "America/Los_Angeles",
+ "cet": "Europe/Paris",
+ "cest": "Europe/Paris",
+ "eet": "Europe/Helsinki",
+ "eest": "Europe/Helsinki",
+ "bst": "Europe/London",
+ "ist": "Asia/Kolkata",
+ "jst": "Asia/Tokyo",
+ "aest": "Australia/Sydney",
+ "aedt": "Australia/Sydney",
+ "awst": "Australia/Perth",
+ "nzst": "Pacific/Auckland",
+ "nzdt": "Pacific/Auckland",
+ "hst": "Pacific/Honolulu",
+ "akst": "America/Anchorage",
+ "akdt": "America/Anchorage",
+ "wet": "Europe/Lisbon",
+ "west": "Europe/Lisbon",
+ "cat": "Africa/Harare",
+ "eat": "Africa/Nairobi",
+ "wat": "Africa/Lagos",
+ "sast": "Africa/Johannesburg",
+}
+
+var offsetToIANA map[int]string
+
+func init() {
+ tz := gotz.New()
+ offsetToIANA = make(map[int]string, len(tz.TzInfos())+len(offsetOverride))
+
+ for k, v := range offsetOverride {
+ offsetToIANA[k] = v
+ }
+
+ names := make([]string, 0, len(tz.TzInfos()))
+ for name := range tz.TzInfos() {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ info := tz.TzInfos()[name]
+ if info.IsDeprecated() {
+ continue
+ }
+ for _, off := range []int{info.StandardOffset(), info.DaylightOffset()} {
+ if _, exists := offsetToIANA[off]; !exists {
+ offsetToIANA[off] = name
+ }
+ }
+ }
+}
+
+var (
+ gmtPrefix = regexp.MustCompile(`^(?i)(?:GMT|UTC|UT)\s*`)
+ offsetWithColon = regexp.MustCompile(`^([+-])(\d{1,2}):(\d{2})$`)
+ offsetFlat = regexp.MustCompile(`^([+-])(\d{2})(\d{2})$`)
+ offsetHoursOnly = regexp.MustCompile(`^([+-])(\d{1,2})$`)
+)
+
+func Resolve(tz string) (string, error) {
+ normalized := strings.TrimSpace(tz)
+ if normalized == "" {
+ return "", fmt.Errorf("timezone: empty string")
+ }
+
+ lower := strings.ToLower(normalized)
+
+ if mapped, ok := abbrMap[lower]; ok {
+ return mapped, nil
+ }
+
+ stripped := gmtPrefix.ReplaceAllString(normalized, "")
+ if stripped != normalized {
+ if resolved, ok := tryParseOffset(stripped); ok {
+ return resolved, nil
+ }
+ }
+
+ if resolved, ok := tryParseOffset(normalized); ok {
+ return resolved, nil
+ }
+
+ _, err := time.LoadLocation(normalized)
+ if err == nil {
+ return normalized, nil
+ }
+
+ return "", fmt.Errorf("timezone: unable to resolve %q", tz)
+}
+
+func tryParseOffset(s string) (string, bool) {
+ m := offsetWithColon.FindStringSubmatch(s)
+ if m != nil {
+ return lookupOffset(m[1], m[2], m[3])
+ }
+
+ m = offsetFlat.FindStringSubmatch(s)
+ if m != nil {
+ return lookupOffset(m[1], m[2], m[3])
+ }
+
+ m = offsetHoursOnly.FindStringSubmatch(s)
+ if m != nil {
+ return lookupOffset(m[1], m[2], "")
+ }
+
+ return "", false
+}
+
+func lookupOffset(sign, hoursStr, minutesStr string) (string, bool) {
+ hours, err := strconv.Atoi(hoursStr)
+ if err != nil {
+ return "", false
+ }
+
+ minutes := 0
+ if minutesStr != "" {
+ minutes, err = strconv.Atoi(minutesStr)
+ if err != nil {
+ return "", false
+ }
+ }
+
+ totalSeconds := (hours*60 + minutes) * 60
+ if sign == "-" {
+ totalSeconds = -totalSeconds
+ }
+
+ mapped, ok := offsetToIANA[totalSeconds]
+ if !ok {
+ return "", false
+ }
+ return mapped, true
+}
diff --git a/internal/pkg/timezone/resolver_test.go b/internal/pkg/timezone/resolver_test.go
new file mode 100644
index 000000000..3243cd658
--- /dev/null
+++ b/internal/pkg/timezone/resolver_test.go
@@ -0,0 +1,225 @@
+package timezone
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveIANAPassthrough(t *testing.T) {
+ tests := []string{
+ "Europe/Amsterdam",
+ "America/New_York",
+ "Asia/Tokyo",
+ "Australia/Sydney",
+ "UTC",
+ "Europe/London",
+ "Pacific/Auckland",
+ }
+
+ for _, tz := range tests {
+ t.Run(tz, func(t *testing.T) {
+ resolved, err := Resolve(tz)
+ require.NoError(t, err)
+ require.Equal(t, tz, resolved)
+ })
+ }
+}
+
+func TestResolveAliases(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"EST", "America/New_York"},
+ {"EDT", "America/New_York"},
+ {"CST", "America/Chicago"},
+ {"CDT", "America/Chicago"},
+ {"MST", "America/Denver"},
+ {"MDT", "America/Denver"},
+ {"PST", "America/Los_Angeles"},
+ {"PDT", "America/Los_Angeles"},
+ {"CET", "Europe/Paris"},
+ {"CEST", "Europe/Paris"},
+ {"BST", "Europe/London"},
+ {"IST", "Asia/Kolkata"},
+ {"JST", "Asia/Tokyo"},
+ {"AEST", "Australia/Sydney"},
+ {"HST", "Pacific/Honolulu"},
+ {"AKST", "America/Anchorage"},
+ {"WET", "Europe/Lisbon"},
+ {"CAT", "Africa/Harare"},
+ {"EAT", "Africa/Nairobi"},
+ {"SAST", "Africa/Johannesburg"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveGMTOffsets(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"GMT+2", "Europe/Amsterdam"},
+ {"GMT-5", "America/New_York"},
+ {"GMT+8", "Asia/Shanghai"},
+ {"GMT-8", "America/Los_Angeles"},
+ {"GMT+0", "Europe/London"},
+ {"GMT+1", "Europe/Paris"},
+ {"GMT+3", "Europe/Moscow"},
+ {"GMT+12", "Pacific/Auckland"},
+ {"GMT-11", "Pacific/Pago_Pago"},
+ {"GMT-10", "Pacific/Honolulu"},
+ {"GMT-9", "America/Anchorage"},
+ {"GMT-7", "America/Denver"},
+ {"GMT-6", "America/Chicago"},
+ {"GMT-4", "America/Halifax"},
+ {"GMT-3", "America/Sao_Paulo"},
+ {"GMT-2", "Etc/GMT+2"},
+ {"GMT-1", "Atlantic/Azores"},
+ {"GMT+4", "Asia/Dubai"},
+ {"GMT+5", "Asia/Karachi"},
+ {"GMT+6", "Asia/Dhaka"},
+ {"GMT+7", "Asia/Bangkok"},
+ {"GMT+9", "Asia/Tokyo"},
+ {"GMT+10", "Australia/Sydney"},
+ {"GMT+11", "Pacific/Noumea"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveUTCOffsets(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"UTC+2", "Europe/Amsterdam"},
+ {"UTC-5", "America/New_York"},
+ {"UTC+8", "Asia/Shanghai"},
+ {"UTC+0", "Europe/London"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveFormattedOffsets(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"+2", "Europe/Amsterdam"},
+ {"-5", "America/New_York"},
+ {"+0200", "Europe/Amsterdam"},
+ {"-0500", "America/New_York"},
+ {"+02:00", "Europe/Amsterdam"},
+ {"-05:00", "America/New_York"},
+ {"GMT+02:00", "Europe/Amsterdam"},
+ {"UTC+02:00", "Europe/Amsterdam"},
+ {"GMT+0200", "Europe/Amsterdam"},
+ {"UTC-0500", "America/New_York"},
+ {"UT+2", "Europe/Amsterdam"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveHalfHourOffsets(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"GMT+5:30", "Asia/Kolkata"},
+ {"GMT+3:30", "Asia/Tehran"},
+ {"GMT+4:30", "Asia/Kabul"},
+ {"GMT+6:30", "Asia/Yangon"},
+ {"GMT+9:30", "Australia/Darwin"},
+ {"GMT+5:45", "Asia/Kathmandu"},
+ {"GMT-3:30", "America/St_Johns"},
+ {"GMT-4:30", "America/Caracas"},
+ {"GMT-9:30", "Pacific/Marquesas"},
+ {"+5:30", "Asia/Kolkata"},
+ {"+0530", "Asia/Kolkata"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveCaseInsensitive(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"gmt+2", "Europe/Amsterdam"},
+ {"gmt-5", "America/New_York"},
+ {"Gmt+2", "Europe/Amsterdam"},
+ {"est", "America/New_York"},
+ {"Est", "America/New_York"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ resolved, err := Resolve(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, resolved)
+ })
+ }
+}
+
+func TestResolveInvalid(t *testing.T) {
+ tests := []string{
+ "",
+ "NotATimezone",
+ "Foobar/Unknown",
+ "GMT+X",
+ "++2",
+ }
+
+ for _, tt := range tests {
+ t.Run(tt, func(t *testing.T) {
+ _, err := Resolve(tt)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestResolveWithWhitespace(t *testing.T) {
+ resolved, err := Resolve(" GMT+2 ")
+ require.NoError(t, err)
+ require.Equal(t, "Europe/Amsterdam", resolved)
+
+ resolved, err = Resolve(" Europe/Amsterdam ")
+ require.NoError(t, err)
+ require.Equal(t, "Europe/Amsterdam", resolved)
+}
diff --git a/internal/wasm/test/action.wasm b/internal/wasm/test/action.wasm
index f04e3f6f8..66e2a6440 100644
Binary files a/internal/wasm/test/action.wasm and b/internal/wasm/test/action.wasm differ
diff --git a/internal/wasm/test/provider.wasm b/internal/wasm/test/provider.wasm
index 5647cdb8f..0b072455d 100644
Binary files a/internal/wasm/test/provider.wasm and b/internal/wasm/test/provider.wasm differ