- 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
343 lines
11 KiB
Elixir
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
|