User deletion bug: - Store client IP in socket assigns during mount - Access stored IP during delete event instead of connect_info - Fixes RuntimeError when attempting to delete users Timezone support: - Add timezone offset maps for common timezones (no external deps) - Implement shift_timezone/2 helper using DateTime.add/3 - Add timezone-aware format_datetime/2, format_date/2, format_iso8601/2 - Keep backwards-compatible 1-arity versions defaulting to UTC - Add comprehensive test coverage for timezone conversions - All tests passing (23 tests, 0 failures) Note: Timezone offsets are standard time only (no DST handling) Common timezones supported: America/New_York, America/Los_Angeles, Europe/London, Europe/Paris, Asia/Tokyo, Asia/Shanghai, Australia/Sydney
216 lines
6.1 KiB
Elixir
216 lines
6.1 KiB
Elixir
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, 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, 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, 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
|