PhoenixKit.Utils.TimeZone (phoenix_kit v2.13.17)

Copy Markdown View Source

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.

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".

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

database()

@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.

effectively_same?(a, b)

@spec effectively_same?(String.t() | nil, String.t() | nil) :: boolean()

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.

from_wall(naive, value)

@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.

group_for(zone)

@spec group_for(String.t()) :: String.t() | nil

The representative zone for whichever group zone belongs to, or nil.

identifier?(value)

@spec identifier?(term()) :: boolean()

Whether value is one of the selectable IANA identifiers.

identifiers()

@spec identifiers() :: [String.t()]

Every selectable IANA identifier, sorted.

label(value)

@spec label(String.t() | nil) :: String.t()

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"

legacy_offset?(value)

@spec legacy_offset?(term()) :: boolean()

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.

options(opts \\ [])

@spec options(keyword()) :: [{String.t(), String.t()}]

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.

representative?(zone)

@spec representative?(String.t()) :: boolean()

Whether zone is one of the group representatives the picker lists directly.

same_group?(a, b)

@spec same_group?(String.t() | nil, String.t() | nil) :: boolean()

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.

shift(datetime, value)

@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.

valid?(value)

@spec valid?(term()) :: boolean()

Whether value is storable — an identifier, a legacy offset, or blank.