fix user deletion bug and implement timezone support
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
This commit is contained in:
parent
b781246abd
commit
1b4b7f1773
4 changed files with 229 additions and 17 deletions
|
|
@ -1,8 +1,54 @@
|
|||
defmodule ToweropsWeb.TimeHelpers do
|
||||
@moduledoc """
|
||||
Helper functions for formatting time and date values.
|
||||
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.
|
||||
|
||||
|
|
@ -53,6 +99,12 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
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.
|
||||
|
||||
|
|
@ -70,12 +122,10 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
@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 DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
Calendar.strftime(shifted_dt, "%b %d, %Y at %I:%M %p %Z")
|
||||
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
|
||||
|
|
@ -83,6 +133,12 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
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.
|
||||
|
||||
|
|
@ -100,11 +156,9 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
@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 DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
case shift_timezone(datetime, timezone) do
|
||||
{:ok, shifted_dt, _tz_abbr} ->
|
||||
Calendar.strftime(shifted_dt, "%b %d, %Y")
|
||||
|
||||
{:error, _reason} ->
|
||||
|
|
@ -113,6 +167,12 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
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.
|
||||
|
||||
|
|
@ -130,12 +190,9 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
@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 DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
tz_abbr = shifted_dt.zone_abbr || timezone
|
||||
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} ->
|
||||
|
|
@ -143,4 +200,17 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ defmodule ToweropsWeb.Admin.UserLive.Index do
|
|||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
users = Admin.list_all_users()
|
||||
ip = get_connect_info_ip(socket)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "All Users")
|
||||
|> assign(:users, users)}
|
||||
|> assign(:users, users)
|
||||
|> assign(:client_ip, ip)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -24,7 +26,7 @@ defmodule ToweropsWeb.Admin.UserLive.Index do
|
|||
@impl true
|
||||
def handle_event("delete_user", %{"id" => user_id}, socket) do
|
||||
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
|
||||
ip = get_connect_info_ip(socket)
|
||||
ip = socket.assigns.client_ip
|
||||
|
||||
case Admin.delete_user(user_id, superuser.id, ip) do
|
||||
{:ok, _} ->
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -70,6 +70,7 @@
|
|||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
|
|
|
|||
139
test/towerops_web/helpers/time_helpers_test.exs
Normal file
139
test/towerops_web/helpers/time_helpers_test.exs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
defmodule ToweropsWeb.TimeHelpersTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias ToweropsWeb.TimeHelpers
|
||||
|
||||
describe "format_datetime/2" do
|
||||
test "formats datetime in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, nil) == "Jan 15, 2026 at 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "formats datetime in UTC when timezone is explicitly UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "UTC") == "Jan 15, 2026 at 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "converts datetime to America/New_York timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 09:34 EST (UTC-5)
|
||||
assert TimeHelpers.format_datetime(datetime, "America/New_York") =~ "Jan 15, 2026 at 09:34 AM"
|
||||
assert TimeHelpers.format_datetime(datetime, "America/New_York") =~ "EST"
|
||||
end
|
||||
|
||||
test "converts datetime to America/Los_Angeles timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 06:34 PST (UTC-8)
|
||||
assert TimeHelpers.format_datetime(datetime, "America/Los_Angeles") =~ "Jan 15, 2026 at 06:34 AM"
|
||||
assert TimeHelpers.format_datetime(datetime, "America/Los_Angeles") =~ "PST"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_datetime(datetime, "Invalid/Timezone") == "Jan 15, 2026 at 02:34 PM UTC"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_datetime(nil, "America/New_York") == "Never"
|
||||
assert TimeHelpers.format_datetime(nil, nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_date/2" do
|
||||
test "formats date in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_date(datetime, nil) == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "formats date in UTC when timezone is explicitly UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "UTC") == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "converts to different date in America/Los_Angeles for early UTC times" do
|
||||
# 02:00 UTC on Jan 15 = 18:00 PST on Jan 14 (UTC-8)
|
||||
datetime = ~U[2026-01-15 02:00:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "America/Los_Angeles") == "Jan 14, 2026"
|
||||
end
|
||||
|
||||
test "keeps same date for late UTC times in America/Los_Angeles" do
|
||||
# 20:00 UTC on Jan 15 = 12:00 PST on Jan 15 (UTC-8)
|
||||
datetime = ~U[2026-01-15 20:00:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "America/Los_Angeles") == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_date(datetime, "Invalid/Timezone") == "Jan 15, 2026"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_date(nil, "America/New_York") == "Never"
|
||||
assert TimeHelpers.format_date(nil, nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_iso8601/2" do
|
||||
test "formats datetime in UTC when timezone is nil" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, nil) == "2026-01-15 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "formats datetime in UTC when timezone is explicitly UTC" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, "UTC") == "2026-01-15 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "converts datetime to America/New_York timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 09:34 EST (UTC-5)
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/New_York")
|
||||
assert result =~ "2026-01-15 09:34:00"
|
||||
assert result =~ "EST"
|
||||
end
|
||||
|
||||
test "converts datetime to America/Los_Angeles timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
# 14:34 UTC = 06:34 PST (UTC-8)
|
||||
result = TimeHelpers.format_iso8601(datetime, "America/Los_Angeles")
|
||||
assert result =~ "2026-01-15 06:34:00"
|
||||
assert result =~ "PST"
|
||||
end
|
||||
|
||||
test "falls back to UTC for invalid timezone" do
|
||||
datetime = ~U[2026-01-15 14:34:00Z]
|
||||
assert TimeHelpers.format_iso8601(datetime, "Invalid/Timezone") == "2026-01-15 14:34:00 UTC"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_iso8601(nil, "America/New_York") == "Never"
|
||||
assert TimeHelpers.format_iso8601(nil, nil) == "Never"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_time_ago/1" do
|
||||
test "formats seconds ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -30, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "s ago"
|
||||
end
|
||||
|
||||
test "formats minutes ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -120, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "m ago"
|
||||
end
|
||||
|
||||
test "formats hours ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -7200, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "h ago"
|
||||
end
|
||||
|
||||
test "formats days ago" do
|
||||
datetime = DateTime.add(DateTime.utc_now(), -172_800, :second)
|
||||
assert TimeHelpers.format_time_ago(datetime) =~ "d ago"
|
||||
end
|
||||
|
||||
test "returns 'Never' for nil datetime" do
|
||||
assert TimeHelpers.format_time_ago(nil) == "Never"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue