feat(users): resolve timezone strings to IANA format on import#235
Open
IamKirbki wants to merge 5 commits into
Open
feat(users): resolve timezone strings to IANA format on import#235IamKirbki wants to merge 5 commits into
IamKirbki wants to merge 5 commits into
Conversation
Add timezone.Resolve() to normalize arbitrary timezone strings (IANA, GMT/UTC offsets, abbreviations) to canonical IANA names. Apply at CSV import and API upsert/update endpoints.
…ation and update functions
There was a problem hiding this comment.
Pull request overview
Adds a backend timezone resolver to normalize varied timezone inputs into canonical (Go-loadable) IANA timezone identifiers, and updates import/UI/docs fixtures to reflect the new behavior.
Changes:
- Introduces
internal/pkg/timezone.Resolve()with abbreviation + GMT/UTC/offset parsing and extensive unit tests. - Applies timezone normalization in the CSV user importer and updates importer/controller CSV fixtures/templates accordingly.
- Updates the Console “Create user” timezone field from free-text to a searchable combobox sourced from
Intl.supportedValuesOf("timeZone"), and documents auto-conversion behavior.
Reviewed changes
Copilot reviewed 11 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/pkg/timezone/resolver.go | New resolver implementation (abbreviations, GMT/UTC prefix stripping, offset parsing, IANA passthrough). |
| internal/pkg/timezone/resolver_test.go | Unit tests covering resolver strategies and edge cases. |
| internal/importer/users.go | Uses resolver for the CSV timezone field, with fallback to raw value on failure. |
| internal/importer/users_test.go | Adds importer tests for GMT offset conversion and IANA passthrough. |
| internal/http/controllers/v1/management/test/users/valid.csv | Updates timezone values in controller test CSV to reflect new accepted formats. |
| internal/http/controllers/v1/management/test/users/out-of-order.csv | Updates timezone values in out-of-order CSV fixture. |
| go.mod | Adds github.com/tkuchiki/go-timezone requirement (currently marked indirect). |
| go.sum | Adds checksums for github.com/tkuchiki/go-timezone. |
| docs/content/docs/users.mdx | Documents timezone auto-conversion behavior for CSV imports. |
| docs/content/docs/lists.mdx | Documents timezone auto-conversion behavior for list CSV imports. |
| console/src/views/users/Users.tsx | Replaces timezone free-text input with searchable combobox. |
| console/public/templates/users.csv | Expands CSV template with sample timezone values in multiple formats. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 |
Comment on lines
+463
to
+472
| <Label>{t("timezone")}</Label> | ||
| <Popover open={isNewUserTimezoneOpen} onOpenChange={setIsNewUserTimezoneOpen}> | ||
| <PopoverTrigger asChild> | ||
| <Button | ||
| variant="outline" | ||
| role="combobox" | ||
| className="justify-between" | ||
| > | ||
| {newUserTimezone || t("select_timezone", "Select timezone")} | ||
| </Button> |
Comment on lines
+481
to
+503
| <CommandGroup> | ||
| {Intl.supportedValuesOf("timeZone").map((tz) => ( | ||
| <CommandItem | ||
| key={tz} | ||
| value={tz} | ||
| onSelect={() => { | ||
| setNewUserTimezone(tz) | ||
| setIsNewUserTimezoneOpen(false) | ||
| }} | ||
| > | ||
| <Check | ||
| className={cn( | ||
| "mr-2 h-4 w-4", | ||
| newUserTimezone === tz | ||
| ? "opacity-100" | ||
| : "opacity-0", | ||
| )} | ||
| /> | ||
| {tz} | ||
| </CommandItem> | ||
| ))} | ||
| </CommandGroup> | ||
| </CommandList> |
Comment on lines
+122
to
+151
| 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) | ||
| } |
Comment on lines
+272
to
+274
| 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. |
Comment on lines
+143
to
+145
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Admins importing via CSV can provide timezones in basically any format (
GMT+2,EST,UTC+05:30,America/New_York, etc). This PR normalizes all of them to canonical IANA timezone strings before they hit the database.Why
When importing from other systems it can be that they have another timezone format than we do, to make sure they don't have to convert them themselves I made a automatic converting system
How
Adds
internal/pkg/timezonewith a single exportedResolve(tz string) (string, error)function. Resolution runs through these strategies in order:ESTresolves toAmerica/New_York,CETtoEurope/Paris, etc.GMT+02:00,UTC+0200, andUT+2all normalize to the same result+02:00,+0200,+2without any prefixtime.LoadLocationOffset to IANA mapping is seeded from
go-timezone'sTzInfos()at startup, with an explicit override table that enforces preferred zones for ambiguous offsets (e.g.+02:00resolves toEurope/Amsterdamrather than a random African zone).The resolver is applied in two places:
internal/importer/users.gofor the CSV field mapper on thetimezonecolumninternal/http/controllers/v1/management/users.gofor theIdentifyUserandUpdateUserendpointsThe frontend timezone field is also upgraded from a free-text input to a searchable combobox powered by
Intl.supportedValuesOf("timeZone"), defaulting to the user's local timezone.Docs updated with a callout in both the users and lists pages explaining the auto-conversion behaviour.
Testing
Full test suite in
internal/pkg/timezone/resolver_test.gocovering IANA passthrough, abbreviations, GMT/UTC offsets, formatted offsets, half-hour offsets, case insensitivity, whitespace, and invalid inputs. Importer tests updated with realistic timezone values.Notes
Offset resolution is intentionally lossy.
GMT+2cannot unambiguously map to a single IANA zone, so the override table encodes the preferred zone per offset for this platform's user base.The importer falls back to the raw string on resolution failure. The API layer silently skips resolution on failure. These behaviours are asymmetric by design but worth keeping an eye on.