defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do @moduledoc """ Formatting helper functions for DeviceLive.Show. Pure functions that format values for display without side effects. """ @doc """ Formats a date to human-readable string. Examples: - DateTime ~U[2024-02-15 10:30:00Z] -> "February 15, 2024" - nil -> "" """ @spec format_date(DateTime.t() | nil) :: String.t() def format_date(nil), do: "" def format_date(date) do Calendar.strftime(date, "%B %-d, %Y") end @doc """ Formats a speed in bps to human-readable units (Kbps, Mbps, Gbps). Examples: - 1_500_000_000 -> "1.5 Gbps" - 100_000_000 -> "100.0 Mbps" - 512 -> "512 bps" - nil -> "-" """ @spec format_speed(integer() | nil) :: String.t() def format_speed(speed_bps) when is_integer(speed_bps) do cond do speed_bps >= 1_000_000_000 -> "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" speed_bps >= 1_000_000 -> "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" speed_bps >= 1_000 -> "#{Float.round(speed_bps / 1_000, 1)} Kbps" true -> "#{speed_bps} bps" end end def format_speed(_), do: "-" @doc """ Formats bytes to human-readable units (KB, MB, GB, TB). Examples: - 1_073_741_824 -> "1.0 GB" - 1_048_576 -> "1.0 MB" - 0 -> "0 B" - nil -> "-" """ @spec format_bytes(integer() | nil) :: String.t() def format_bytes(nil), do: "-" def format_bytes(0), do: "0 B" def format_bytes(bytes) when is_integer(bytes) do cond do bytes >= 1_099_511_627_776 -> "#{Float.round(bytes / 1_099_511_627_776, 1)} TB" bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB" bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB" bytes >= 1024 -> "#{Float.round(bytes / 1024, 1)} KB" true -> "#{bytes} B" end end def format_bytes(_), do: "-" @doc """ Formats a sensor value (already divided by divisor). Returns value as string with 1 decimal place. """ @spec format_sensor_value(number() | nil, any()) :: String.t() def format_sensor_value(value, _divisor) when is_number(value) do # Value is already divided by divisor when stored in sensor_reading # Format as string to avoid scientific notation for large numbers :erlang.float_to_binary(value * 1.0, decimals: 1) end def format_sensor_value(_, _), do: "-" @doc """ Formats a location string, parsing GPS coordinates to 6 decimal places. Examples: - "40.7128, -74.0060" -> "40.712800, -74.006000" - "New York" -> "New York" - nil -> "N/A" """ @spec format_location(String.t() | nil) :: String.t() def format_location(location) when is_binary(location) do if gps_coordinates?(location) do format_gps_coordinates(location) else location end end def format_location(nil), do: "N/A" def format_location(_), do: "N/A" defp gps_coordinates?(location) do String.contains?(location, ",") && String.match?(location, ~r/[-\d.]+,\s*[-\d.]+/) end defp format_gps_coordinates(location) do location |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.map_join(", ", &format_coordinate/1) end defp format_coordinate(coord) do case Float.parse(coord) do {num, _} -> :erlang.float_to_binary(num, decimals: 6) :error -> coord end end @doc """ Formats a DateTime as "time ago" relative to now. Examples: - 30 seconds ago -> "30s ago" - 5 minutes ago -> "5m ago" - 2 hours ago -> "2h ago" - 7 days ago -> "7d ago" - nil -> "Never" """ @spec time_ago(DateTime.t() | nil) :: String.t() def time_ago(nil), do: "Never" def time_ago(datetime) do diff = DateTime.diff(DateTime.utc_now(), datetime, :second) cond do diff < 60 -> "#{diff}s ago" diff < 3600 -> "#{div(diff, 60)}m ago" diff < 86_400 -> "#{div(diff, 3600)}h ago" true -> "#{div(diff, 86_400)}d ago" end end @doc """ Formats SNMP uptime (in timeticks) to human-readable duration. Timeticks are in hundredths of a second (centiseconds). Examples: - 8_640_000 (1 day) -> "1 day" - 360_000 (1 hour) -> "1 hour" - nil -> "N/A" """ @spec format_uptime(integer() | nil) :: String.t() def format_uptime(nil), do: "N/A" def format_uptime(timeticks) when is_integer(timeticks) do # Timeticks are in hundredths of a second seconds = div(timeticks, 100) format_duration(seconds) end def format_uptime(_), do: "N/A" @doc """ Formats a duration in seconds to human-readable string. Examples: - 86400 -> "1 day" - 3661 -> "1 hour 1 minute" - 45 -> "less than a minute" """ @spec format_duration(integer()) :: String.t() def format_duration(total_seconds) do {weeks, remainder} = seconds_to_weeks(total_seconds) {days, remainder} = seconds_to_days(remainder) {hours, remainder} = seconds_to_hours(remainder) {minutes, _seconds} = seconds_to_minutes(remainder) parts = Enum.reject( [ format_time_part(weeks, "week"), format_time_part(days, "day"), format_time_part(hours, "hour"), format_time_part(minutes, "minute") ], &is_nil/1 ) case parts do [] -> "less than a minute" parts -> Enum.join(parts, " ") end end @spec seconds_to_weeks(integer()) :: {integer(), integer()} defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)} @spec seconds_to_days(integer()) :: {integer(), integer()} defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)} @spec seconds_to_hours(integer()) :: {integer(), integer()} defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)} @spec seconds_to_minutes(integer()) :: {integer(), integer()} defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)} @spec format_time_part(integer(), String.t()) :: String.t() | nil defp format_time_part(0, _unit), do: nil defp format_time_part(1, unit), do: "1 #{unit}" defp format_time_part(count, unit), do: "#{count} #{unit}s" @doc """ Formats device age (time since created) as duration + "ago". Examples: - 1 day old -> "1 day ago" - nil -> "N/A" """ @spec format_device_age(DateTime.t() | nil) :: String.t() def format_device_age(nil), do: "N/A" def format_device_age(datetime) do diff = DateTime.diff(DateTime.utc_now(), datetime, :second) format_duration(diff) <> " ago" end @doc """ Formats event type from snake_case to Title Case. Examples: - "interface_down" -> "Interface down" - "sensor_threshold_exceeded" -> "Sensor threshold exceeded" """ @spec format_event_type(String.t()) :: String.t() def format_event_type(event_type) do event_type |> String.replace("_", " ") |> String.capitalize() end @doc """ Returns capacity source label for display. """ @spec capacity_source_label(String.t()) :: String.t() def capacity_source_label("manual"), do: "Manual" def capacity_source_label("sensor"), do: "Sensor" def capacity_source_label("if_speed"), do: "Link" def capacity_source_label("peak"), do: "Peak" def capacity_source_label(_), do: "" @doc """ Returns Tailwind CSS classes for capacity source badges. """ @spec capacity_source_class(String.t()) :: String.t() def capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" def capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" def capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" def capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300" def capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" @doc """ Returns background color class for utilization percentage. - >= 90%: red - >= 70%: yellow - < 70%: green """ @spec utilization_color(number()) :: String.t() def utilization_color(pct) when pct >= 90, do: "bg-red-500" def utilization_color(pct) when pct >= 70, do: "bg-yellow-500" def utilization_color(_pct), do: "bg-green-500" @doc """ Returns text color class for utilization percentage. - >= 90%: red - >= 70%: yellow - < 70%: green """ @spec utilization_text_color(number()) :: String.t() def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400" end