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