towerops/lib/towerops_web/helpers/time_helpers.ex
Graham McIntire 872bef3512
feat: add 12/24-hour time format setting with consistent timezone display
wire time_format into scope, add settings UI selector, change defaults
to 24h, and replace all user-facing Calendar.strftime calls with
centralized TimeHelpers using the user's timezone and time format.
2026-02-13 15:16:29 -06:00

330 lines
10 KiB
Elixir

defmodule ToweropsWeb.TimeHelpers do
@moduledoc """
Helper functions for formatting time and date values with timezone support.
All functions use a centralized `to_user_timezone/2` function to convert UTC
datetimes to the user's preferred timezone.
"""
use Gettext, backend: ToweropsWeb.Gettext
# Common timezone offsets in seconds
# Note: These are standard time offsets. DST handling would require a full timezone database.
@timezone_offsets %{
"UTC" => 0,
"America/New_York" => -18_000,
# EST (UTC-5)
"America/Chicago" => -21_600,
# CST (UTC-6)
"America/Denver" => -25_200,
# MST (UTC-7)
"America/Los_Angeles" => -28_800,
# PST (UTC-8)
"America/Phoenix" => -25_200,
# MST (no DST)
"Europe/London" => 0,
# GMT (UTC+0)
"Europe/Paris" => 3600,
# CET (UTC+1)
"Europe/Berlin" => 3600,
# CET (UTC+1)
"Asia/Tokyo" => 32_400,
# JST (UTC+9)
"Asia/Shanghai" => 28_800,
# CST (UTC+8)
"Asia/Dubai" => 14_400,
# GST (UTC+4)
"Australia/Sydney" => 36_000
# AEST (UTC+10)
}
@timezone_abbrs %{
"UTC" => "UTC",
"America/New_York" => "EST",
"America/Chicago" => "CST",
"America/Denver" => "MST",
"America/Los_Angeles" => "PST",
"America/Phoenix" => "MST",
"Europe/London" => "GMT",
"Europe/Paris" => "CET",
"Europe/Berlin" => "CET",
"Asia/Tokyo" => "JST",
"Asia/Shanghai" => "CST",
"Asia/Dubai" => "GST",
"Australia/Sydney" => "AEST"
}
@doc """
Central function to convert a UTC DateTime to the user's timezone.
All other formatting functions should use this function to ensure consistent
timezone handling across the application.
Returns `{:ok, shifted_datetime, timezone_abbr}` or `{:error, reason}`.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> to_user_timezone(datetime, "America/New_York")
{:ok, ~U[2026-01-15 09:34:00Z], "EST"}
"""
@spec to_user_timezone(DateTime.t(), String.t()) ::
{:ok, DateTime.t(), String.t()} | {:error, atom()}
def to_user_timezone(datetime, timezone) when is_binary(timezone) do
case Map.get(@timezone_offsets, timezone) do
nil ->
{:error, :unknown_timezone}
offset_seconds ->
shifted_dt = DateTime.add(datetime, offset_seconds, :second)
tz_abbr = Map.get(@timezone_abbrs, timezone, timezone)
{:ok, shifted_dt, tz_abbr}
end
end
@doc """
Formats a DateTime into a human-readable "time ago" string.
Supports short time periods (seconds, minutes, hours, days) and
long time periods (months, years) for things like build timestamps.
Handles clock skew gracefully by treating future timestamps as "just now".
## Examples
iex> datetime = DateTime.add(DateTime.utc_now(), -65, :second)
iex> ToweropsWeb.TimeHelpers.format_time_ago(datetime)
"1m ago"
iex> datetime = DateTime.add(DateTime.utc_now(), -7200, :second)
iex> ToweropsWeb.TimeHelpers.format_time_ago(datetime)
"2h ago"
"""
@spec format_time_ago(DateTime.t() | nil) :: String.t()
def format_time_ago(nil), do: gettext("Never")
def format_time_ago(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
# Clamp diff to 0 if negative (handles clock skew where datetime is in the future)
diff = max(diff, 0)
cond do
diff < 60 ->
ngettext("%{count}s ago", "%{count}s ago", diff, count: diff)
diff < 3600 ->
minutes = div(diff, 60)
ngettext("%{count}m ago", "%{count}m ago", minutes, count: minutes)
diff < 86_400 ->
hours = div(diff, 3600)
ngettext("%{count}h ago", "%{count}h ago", hours, count: hours)
diff < 2_592_000 ->
days = div(diff, 86_400)
ngettext("%{count}d ago", "%{count}d ago", days, count: days)
diff < 31_536_000 ->
months = div(diff, 2_592_000)
ngettext("%{count}mo ago", "%{count}mo ago", months, count: months)
true ->
years = div(diff, 31_536_000)
ngettext("%{count}y ago", "%{count}y ago", years, count: years)
end
end
@doc """
Formats a DateTime into a full date and time string in UTC with default 12h format.
"""
@spec format_datetime(DateTime.t() | nil) :: String.t()
def format_datetime(datetime), do: format_datetime(datetime, "UTC", "24h")
@doc """
Formats a DateTime into a full date and time string, converted to the given timezone.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York", "12h")
"Jan 15, 2026 at 09:34 AM EST"
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC", "24h")
"Jan 15, 2026 at 14:34 UTC"
"""
@spec format_datetime(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t()
def format_datetime(nil, _timezone, _time_format), do: gettext("Never")
def format_datetime(datetime, nil, time_format), do: format_datetime(datetime, "UTC", time_format)
def format_datetime(datetime, timezone, time_format) when is_binary(timezone) do
case to_user_timezone(datetime, timezone) do
{:ok, shifted_dt, tz_abbr} ->
time_str = format_time_part(shifted_dt, time_format)
Calendar.strftime(shifted_dt, "%b %d, %Y at #{time_str} #{tz_abbr}")
{:error, _reason} ->
# Fallback to UTC if timezone is invalid
time_str = format_time_part(datetime, time_format)
Calendar.strftime(datetime, "%b %d, %Y at #{time_str} UTC")
end
end
# Backwards compatibility - accepts 2 args, defaults to 24h format
def format_datetime(datetime, timezone) do
format_datetime(datetime, timezone, "24h")
end
@doc """
Formats a DateTime into a short date string in UTC.
"""
@spec format_date(DateTime.t() | nil) :: String.t()
def format_date(datetime), do: format_date(datetime, "UTC")
@doc """
Formats a DateTime into a short date string, converted to the given timezone.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_date(datetime, "UTC")
"Jan 15, 2026"
iex> datetime = ~U[2026-01-15 02:00:00Z]
iex> ToweropsWeb.TimeHelpers.format_date(datetime, "America/Los_Angeles")
"Jan 14, 2026"
"""
@spec format_date(DateTime.t() | nil, String.t() | nil) :: String.t()
def format_date(nil, _timezone), do: gettext("Never")
def format_date(datetime, nil), do: format_date(datetime, "UTC")
def format_date(datetime, timezone) when is_binary(timezone) do
case to_user_timezone(datetime, timezone) do
{:ok, shifted_dt, _tz_abbr} ->
Calendar.strftime(shifted_dt, "%b %d, %Y")
{:error, _reason} ->
# Fallback to UTC if timezone is invalid
Calendar.strftime(datetime, "%b %d, %Y")
end
end
@doc """
Formats a DateTime into ISO 8601 format in UTC with default 24h format.
"""
@spec format_iso8601(DateTime.t() | nil) :: String.t()
def format_iso8601(datetime), do: format_iso8601(datetime, "UTC", "24h")
@doc """
Formats a DateTime into ISO 8601 format, converted to the given timezone.
## Examples
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC", "24h")
"2026-01-15 14:34:00 UTC"
iex> datetime = ~U[2026-01-15 14:34:00Z]
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York", "12h")
"2026-01-15 09:34:00 AM EST"
"""
@spec format_iso8601(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t()
def format_iso8601(nil, _timezone, _time_format), do: gettext("Never")
def format_iso8601(datetime, nil, time_format), do: format_iso8601(datetime, "UTC", time_format)
def format_iso8601(datetime, timezone, time_format) when is_binary(timezone) do
case to_user_timezone(datetime, timezone) do
{:ok, shifted_dt, tz_abbr} ->
time_str = format_time_part(shifted_dt, time_format)
Calendar.strftime(shifted_dt, "%Y-%m-%d #{time_str} #{tz_abbr}")
{:error, _reason} ->
# Fallback to UTC if timezone is invalid
time_str = format_time_part(datetime, time_format)
Calendar.strftime(datetime, "%Y-%m-%d #{time_str} UTC")
end
end
# Backwards compatibility - accepts 2 args, defaults to 24h format for ISO8601
def format_iso8601(datetime, timezone) do
format_iso8601(datetime, timezone, "24h")
end
@doc """
Formats a UTC hour (0-23) into the user's timezone with abbreviation.
Uses 12-hour format with AM/PM.
## Examples
iex> format_utc_hour(7, "America/New_York")
"2:00 AM EST"
iex> format_utc_hour(7, "UTC")
"7:00 AM UTC"
"""
def format_utc_hour(utc_hour, timezone \\ "UTC", time_format \\ "12h") when utc_hour >= 0 and utc_hour <= 23 do
# Create a DateTime for today at the specified UTC hour
today = Date.utc_today()
{:ok, datetime} = DateTime.new(today, ~T[00:00:00], "Etc/UTC")
datetime = DateTime.add(datetime, utc_hour * 3600, :second)
case to_user_timezone(datetime, timezone) do
{:ok, shifted_dt, tz_abbr} ->
format_hour(shifted_dt.hour, tz_abbr, time_format)
{:error, _reason} ->
format_hour(utc_hour, "UTC", time_format)
end
end
defp format_hour(hour, tz_abbr, "24h") do
"#{hour}:00 #{tz_abbr}"
end
defp format_hour(hour, tz_abbr, _time_format) do
{hour_12, am_pm} = to_12hour_format(hour)
"#{hour_12}:00 #{am_pm} #{tz_abbr}"
end
@doc """
Formats a DateTime using timezone and time_format from a scope.
Convenience function that extracts timezone and time_format from a scope map
(or any map with `:timezone` and `:time_format` keys).
## Examples
iex> scope = %{timezone: "America/New_York", time_format: "12h"}
iex> ToweropsWeb.TimeHelpers.format_datetime_for_scope(~U[2026-01-15 14:34:00Z], scope)
"Jan 15, 2026 at 09:34 AM EST"
"""
def format_datetime_for_scope(datetime, scope) do
format_datetime(datetime, scope.timezone, scope.time_format)
end
# Private helper to format the time portion based on user preference
defp format_time_part(datetime, "24h") do
Calendar.strftime(datetime, "%H:%M:%S")
end
defp format_time_part(datetime, _default_12h) do
Calendar.strftime(datetime, "%I:%M %p")
end
# Private helper to convert 24-hour format to 12-hour format with AM/PM
defp to_12hour_format(hour) when hour == 0, do: {12, "AM"}
defp to_12hour_format(hour) when hour < 12, do: {hour, "AM"}
defp to_12hour_format(hour) when hour == 12, do: {12, "PM"}
defp to_12hour_format(hour), do: {hour - 12, "PM"}
end