# `PhoenixKit.Utils.TimeZone`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.16.0/lib/phoenix_kit/utils/time_zone.ex#L1)

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/2` — `date_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`.

# `database`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `identifier?`

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

Whether `value` is one of the selectable IANA identifiers.

# `identifiers`

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

Every selectable IANA identifier, sorted.

# `label`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

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

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

# `same_group?`

```elixir
@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`

```elixir
@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?`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
