Timezone identity for users and for the site: the picker list, the label for
a stored value, and the one place a DateTime is moved into someone's zone.
Why IANA ids and not an offset
This used to store an integer offset — "2" for a row labelled
"UTC+2 (Kyiv, Athens, Helsinki, Cairo, Johannesburg)". A number cannot carry
a location, and that broke in three compounding ways:
- The labels were winter times. Kyiv, Athens, Helsinki and Cairo are all UTC+3 from spring to autumn, so for half the year the row named cities that were not on the offset it claimed.
- The rows mixed zones that only agree in winter. Johannesburg is UTC+2 every day of the year; the other four are not. One row could not be right for all of them at once.
- A stored offset cannot follow DST. Pick Helsinki in January and
"2"is written down; come summer Helsinki is UTC+3 and every timestamp shown is an hour behind until the profile is edited by hand.
An IANA id names the place. Europe/Warsaw is UTC+1 in January and UTC+2
in August without anything being re-saved, and it stays correct while the
person travels.
Legacy values
Rows written before this change still hold offsets, and they keep working:
shift/2 reads "2" as a fixed +2 exactly as before, so nobody's
timestamps move underneath them. Such a value is not upgraded
automatically — "2" is genuinely ambiguous between Europe/Warsaw in
summer and Africa/Johannesburg in any season, and guessing would put a
location on the account that its owner never chose. legacy_offset?/1 marks
them so the UI can ask.
"5.5" and "9.5" also start shifting for the first time here. The old
code parsed offsets with Integer.parse/1 and required an empty remainder,
so "5.5" left ".5" over, failed the match, and returned the timestamp
unshifted — every account on UTC+5:30 (Mumbai, Delhi, Kolkata, Colombo)
or UTC+9:30 (Adelaide, Darwin) was silently reading UTC.
The list
identifiers/0 is the IANA region list, aliases included. Aliases are kept
on purpose: tzdata links Europe/Oslo, Europe/Stockholm and
Europe/Copenhagen to Europe/Berlin, and dropping them to "canonical"
entries would leave a Norwegian unable to find Oslo — the same complaint
that started this ("Warsaw is missing"). Every id is asserted resolvable
against the compiled tz database in time_zone_test.exs.
Summary
Functions
The timezone database backing every lookup here.
The UTC instant at which the current day began in value.
Whether a and b render the same wall-clock time right now.
Reads a wall-clock NaiveDateTime as local time in value, returning UTC.
The representative zone for whichever group zone belongs to, or nil.
Whether value is one of the selectable IANA identifiers.
Every selectable IANA identifier, sorted.
Human label for a stored value — an IANA id, a legacy offset, or nothing.
Whether value is a pre-IANA numeric offset, e.g. "2", "-5", "5.5".
Offset from UTC in seconds for either kind of stored value.
Picker options as {label, identifier}, ordered by current UTC offset.
Whether zone is one of the group representatives the picker lists directly.
Whether two zones behave identically all year — same offset, same DST rule.
Moves datetime into the zone named by value.
Whether value is storable — an identifier, a legacy offset, or blank.
Functions
@spec database() :: module()
The timezone database backing every lookup here.
Passed explicitly to DateTime.shift_zone/3 rather than set as
config :elixir, :time_zone_database: this is a library, and a library
reaching into the host's Elixir config to swap a global would decide for
every other dependency in the app too.
@spec day_start(String.t() | nil, DateTime.t() | nil) :: DateTime.t()
The UTC instant at which the current day began in value.
For "how many X happened today", where today is the operator's day and not
UTC's. Getting this wrong is invisible most of the day and then wrong every
evening: a site on Europe/Tallinn (UTC+3) counting from UTC midnight loses
everything between 21:00 and midnight local, every night.
at overrides "now", for tests and for asking about another moment.
Falls back to UTC midnight when the value cannot be resolved.
Examples
iex> PhoenixKit.Utils.TimeZone.day_start("0", ~U[2026-09-05 14:00:00Z])
~U[2026-09-05 00:00:00Z]
iex> PhoenixKit.Utils.TimeZone.day_start("2", ~U[2026-09-05 00:30:00Z])
~U[2026-09-04 22:00:00Z]
Whether a and b render the same wall-clock time right now.
same_group?/2 only ever returns true for two identifiers — a legacy
offset is not a group member, so comparing a browser-detected zone against
a site's time_zone setting (which is the legacy offset "0" on every
install that has never touched that setting) always failed same_group?,
even from a browser genuinely on UTC+0. This compares current effective
offset instead, which is defined for a legacy offset too.
@spec from_wall(NaiveDateTime.t(), String.t() | nil) :: {:ok, DateTime.t()} | :error
Reads a wall-clock NaiveDateTime as local time in value, returning UTC.
The inverse of shift/2, for a datetime-local input: the person typed
09:00 meaning 09:00 where they are, and it has to be stored as an instant.
Daylight saving makes this genuinely ambiguous twice a year. An hour that happens twice resolves to the first occurrence, and an hour that never happens resolves to the instant the clocks jump to — both deterministic, and both closer to what someone typing a time expects than an error would be.
Returns {:ok, datetime} or :error.
The representative zone for whichever group zone belongs to, or nil.
Whether value is one of the selectable IANA identifiers.
@spec identifiers() :: [String.t()]
Every selectable IANA identifier, sorted.
Human label for a stored value — an IANA id, a legacy offset, or nothing.
Examples
iex> PhoenixKit.Utils.TimeZone.label("Europe/Warsaw") =~ "Europe/Warsaw"
true
iex> PhoenixKit.Utils.TimeZone.label(nil)
"Use System Default"
Whether value is a pre-IANA numeric offset, e.g. "2", "-5", "5.5".
Kept working by shift/2, but the UI should offer to replace it: the number
says nothing about where the account holder is, so it cannot follow DST.
@spec offset_seconds(String.t() | nil, DateTime.t() | nil) :: integer()
Offset from UTC in seconds for either kind of stored value.
A legacy offset ("2", "-5", "5.5") is that offset. An IANA id has no
single answer — Europe/Warsaw is +1 in January and +2 in August — so it is
resolved at an instant, at or now.
That snapshot is the honest limit of this function, and callers doing date
arithmetic across a daylight-saving boundary want shift/2 or from_wall/2
instead, which are correct per-instant. It exists because several call sites
genuinely need a scalar (a window offset, a comparison), and the alternative
they had was Float.parse/1 returning 0 for every named zone — silently
computing in UTC on any site that used the picker, which since the move to
IANA ids is every site that touched the setting.
Unresolvable values give 0, the same safe default as before.
Examples
iex> PhoenixKit.Utils.TimeZone.offset_seconds("2")
7200
iex> PhoenixKit.Utils.TimeZone.offset_seconds("5.5")
19800
iex> PhoenixKit.Utils.TimeZone.offset_seconds("nonsense")
0
Picker options as {label, identifier}, ordered by current UTC offset.
One row per behaviour group — 59, not 447 — so the list stays browsable while every row remains a real zone that follows its own daylight-saving rule. Selecting a row stores that group's representative; everyone in the group behaves identically all year, so the choice is right for all of them.
Offsets are computed now, not baked into the string: the central-European row
reads (UTC+01:00) in January and (UTC+02:00) in July. Freezing that number
is what made the old list wrong for half the year.
Pass the currently-stored value as :selected. When it is a zone that is not
itself a representative — the usual case once detection has stored somewhere
precise like Europe/Tallinn — it is prepended as its own row, so the list
stays short without ever misreporting what is saved.
Whether zone is one of the group representatives the picker lists directly.
Whether two zones behave identically all year — same offset, same DST rule.
Used by the mismatch check: someone detected in Europe/Tallinn whose account
says Europe/Helsinki is not misconfigured, because the two never disagree.
@spec shift(DateTime.t(), String.t() | nil) :: DateTime.t()
Moves datetime into the zone named by value.
An identifier goes through DateTime.shift_zone/3, so DST is applied for the
instant being shown rather than for the moment the preference was saved. A
legacy offset is added as a fixed number of seconds. Anything unusable —
including a zone the database cannot resolve — returns datetime untouched,
because a page of timestamps is worth more than a crash over a preference.
Whether value is storable — an identifier, a legacy offset, or blank.