towerops/lib/towerops_web/helpers/time_helpers.ex

306 lines
9.8 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", "12h")
@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 12h format
def format_datetime(datetime, timezone) do
format_datetime(datetime, timezone, "12h")
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") 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} ->
{hour_12, am_pm} = to_12hour_format(shifted_dt.hour)
"#{hour_12}:00 #{am_pm} #{tz_abbr}"
{:error, _reason} ->
{hour_12, am_pm} = to_12hour_format(utc_hour)
"#{hour_12}:00 #{am_pm} UTC"
end
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