Skip to content

feat(users): resolve timezone strings to IANA format on import#235

Open
IamKirbki wants to merge 5 commits into
mainfrom
chore/import-csv-improvements
Open

feat(users): resolve timezone strings to IANA format on import#235
IamKirbki wants to merge 5 commits into
mainfrom
chore/import-csv-improvements

Conversation

@IamKirbki
Copy link
Copy Markdown
Contributor

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/timezone with a single exported Resolve(tz string) (string, error) function. Resolution runs through these strategies in order:

  1. Abbreviation map EST resolves to America/New_York, CET to Europe/Paris, etc.
  2. GMT/UTC/UT prefix stripping plus offset parsing GMT+02:00, UTC+0200, and UT+2 all normalize to the same result
  3. Raw offset parsing +02:00, +0200, +2 without any prefix
  4. IANA passthrough already valid strings pass through unchanged via time.LoadLocation

Offset to IANA mapping is seeded from go-timezone's TzInfos() at startup, with an explicit override table that enforces preferred zones for ambiguous offsets (e.g. +02:00 resolves to Europe/Amsterdam rather than a random African zone).

The resolver is applied in two places:

  • internal/importer/users.go for the CSV field mapper on the timezone column
  • internal/http/controllers/v1/management/users.go for the IdentifyUser and UpdateUser endpoints

The 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.go covering 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+2 cannot 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.

IamKirbki added 5 commits May 8, 2026 15:11
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread go.mod
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants