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. ## 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) 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