PhoenixKit.Utils.TimeZone (phoenix_kit v2.17.0)

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.

Which function for what

Every conversion is per instant — a named zone follows daylight saving on the date being converted, never on the day a preference was saved:

  • for_viewer/1 — the value a page should use for a scope, a user, or nobody (profile → site setting → "0").
  • shift/2 — a UTC instant as a wall clock in the zone (display).
  • from_wall/2 — a typed wall clock (NaiveDateTime) as the UTC instant (a datetime-local input, a booking slot).
  • date_start/2 — the UTC instant a local date begins (day windows).
  • local_date/2 — the local date of an instant ("today").
  • day_start/2date_start/2 for the local date of now, or of at.
  • offset_seconds/2 — the offset at one instant, for the rare place that genuinely needs a scalar (a label). Never add it to another instant.

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 date begins in value — the lower bound of a viewer-local day, and (with the next date) the exclusive upper bound.

The UTC instant at which the current day began in value.

Whether a and b render the same wall-clock time right now.

The timezone value a page should use for viewer — their own user_timezone when set, else the site's time_zone setting, else "0".

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

The calendar date of instant in value — what "today" means to a viewer in that zone when instant is now.

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

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.

date_start(date, value)

@spec date_start(Date.t(), String.t() | nil) :: DateTime.t()

The UTC instant at which date begins in value — the lower bound of a viewer-local day, and (with the next date) the exclusive upper bound.

Resolved for THAT date: Europe/Tallinn starts January 15 at 22:00Z the evening before and July 15 at 21:00Z. Subtracting one offset taken today from both bounds of a window — what several modules did — was an hour off for any window on the other side of a daylight-saving switch. A midnight that never happens (spring-forward at 00:00, as in Santiago) is the instant the clocks jump to; one that happens twice (fall-back at 00:00, as in Havana) is its first occurrence — either way the first instant of that date. Falls back to UTC midnight when the value cannot be resolved.

Examples

iex> PhoenixKit.Utils.TimeZone.date_start(~D[2026-07-15], "2")
~U[2026-07-14 22:00:00Z]

iex> PhoenixKit.Utils.TimeZone.date_start(~D[2026-07-15], "nonsense")
~U[2026-07-15 00:00:00Z]

day_start(value, at \\ nil)

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

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.

for_viewer(user)

@spec for_viewer(PhoenixKit.Users.Auth.Scope.t() | map() | nil) :: String.t()

The timezone value a page should use for viewer — their own user_timezone when set, else the site's time_zone setting, else "0".

Takes a %Scope{}, a user (a %User{} or any map, with or without the column — test scopes and degraded embeds carry partial maps), or nil. Always a string: an IANA id or a legacy offset, never a number, ready for shift/2, from_wall/2, date_start/2 and local_date/2.

This is the rule PhoenixKit.Utils.Date.get_user_timezone/1 applies to a full %User{}, made total. Before it existed, five modules each carried their own copy of the fallback chain — and two of them disagreed with it.

Examples

iex> PhoenixKit.Utils.TimeZone.for_viewer(%{user_timezone: "Europe/Warsaw"})
"Europe/Warsaw"

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.

local_date(instant, value)

@spec local_date(DateTime.t(), String.t() | nil) :: Date.t()

The calendar date of instant in value — what "today" means to a viewer in that zone when instant is now.

Date.utc_today/0 is the wrong answer to that question for most of the world for part of every day: at 01:00 in Tallinn it is still yesterday in UTC, so a grid highlighted the wrong day and a "New event" prefilled the wrong date. Unresolvable values read as UTC.

Examples

iex> PhoenixKit.Utils.TimeZone.local_date(~U[2026-07-14 22:30:00Z], "2")
~D[2026-07-15]

iex> PhoenixKit.Utils.TimeZone.local_date(~U[2026-07-14 22:30:00Z], "-5")
~D[2026-07-14]

offset_seconds(value, at \\ nil)

@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

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.