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 "Mon DD HH:MM" string in the given timezone. """ @spec format_short_datetime(DateTime.t() | nil, String.t()) :: String.t() def format_short_datetime(nil, _timezone), do: "—" def format_short_datetime(datetime, timezone) do case to_user_timezone(datetime, timezone || "UTC") do {:ok, shifted_dt, _tz_abbr} -> Calendar.strftime(shifted_dt, "%b %d %H:%M") {:error, _} -> Calendar.strftime(datetime, "%b %d %H:%M") end end @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