towerops/lib/towerops_web/helpers/time_helpers.ex
Graham McIntire d1403c8069 Fix failing tests and clean up code
- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior
- Fix dynamic_extra_test vendor post-processing tests to seed sensor data
- Fix activity_controller_test to seed devices for feed data
- Fix session_manager_test to create browser session for test
- Fix topology_test link creation for connection test
- Fix device_monitor/driver_worker tests for unique job constraints
- Fix accounts_test expired_tokens assertion (magic link token is expired)
- Fix happy_path_test and show_events_test to seed monitor data
- Fix admin user_live_test user.name -> user.email (no name field)
- Fix schema_test to seed activity data
- Fix mobile_qr_live_test to match actual template text
- Fix SnmpKit.MIB doctests and tests for enriched return values
- Fix onboarding_live, mobile_controller, mib_test weak assertions
- Remove dead code and fix credo warnings
2026-06-16 14:54:34 -05:00

343 lines
11 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> TimeHelpers.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:00 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 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> TimeHelpers.format_utc_hour(7, "America/New_York")
"2:00 AM EST"
iex> TimeHelpers.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