defmodule ToweropsWeb.TimeHelpers do @moduledoc """ Helper functions for formatting time and date values with timezone support. """ # 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 """ 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: "Never" def format_time_ago(datetime) do now = DateTime.utc_now() diff = DateTime.diff(now, datetime, :second) cond do diff < 60 -> "#{diff}s ago" diff < 3600 -> minutes = div(diff, 60) "#{minutes}m ago" diff < 86_400 -> hours = div(diff, 3600) "#{hours}h ago" diff < 2_592_000 -> days = div(diff, 86_400) "#{days}d ago" diff < 31_536_000 -> months = div(diff, 2_592_000) "#{months}mo ago" true -> years = div(diff, 31_536_000) "#{years}y ago" end end @doc """ Formats a DateTime into a full date and time string in UTC. """ @spec format_datetime(DateTime.t() | nil) :: String.t() def format_datetime(datetime), do: format_datetime(datetime, "UTC") @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") "Jan 15, 2026 at 09:34 AM EST" iex> datetime = ~U[2026-01-15 14:34:00Z] iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC") "Jan 15, 2026 at 02:34 PM UTC" """ @spec format_datetime(DateTime.t() | nil, String.t() | nil) :: String.t() def format_datetime(nil, _timezone), do: "Never" def format_datetime(datetime, nil), do: format_datetime(datetime, "UTC") def format_datetime(datetime, timezone) when is_binary(timezone) do case shift_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> Calendar.strftime(shifted_dt, "%b %d, %Y at %I:%M %p #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p UTC") end 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: "Never" def format_date(datetime, nil), do: format_date(datetime, "UTC") def format_date(datetime, timezone) when is_binary(timezone) do case shift_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. """ @spec format_iso8601(DateTime.t() | nil) :: String.t() def format_iso8601(datetime), do: format_iso8601(datetime, "UTC") @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") "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") "2026-01-15 09:34:00 EST" """ @spec format_iso8601(DateTime.t() | nil, String.t() | nil) :: String.t() def format_iso8601(nil, _timezone), do: "Never" def format_iso8601(datetime, nil), do: format_iso8601(datetime, "UTC") def format_iso8601(datetime, timezone) when is_binary(timezone) do case shift_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> Calendar.strftime(shifted_dt, "%Y-%m-%d %H:%M:%S #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC") end end # Private helper to shift a DateTime by a timezone offset defp shift_timezone(datetime, 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 end