diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 98caa93..9a5ccd4 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -2,12 +2,10 @@ defmodule AprsWeb.MapLive.CallsignView do @moduledoc false use AprsWeb, :live_view - alias Aprs.EncodingUtils alias Aprs.Packets alias AprsWeb.Endpoint alias AprsWeb.MapLive.MapHelpers alias AprsWeb.MapLive.PacketUtils - alias AprsWeb.TimeHelpers @default_center %{lat: 39.0, lng: -98.0} @default_zoom 4 @@ -266,61 +264,30 @@ defmodule AprsWeb.MapLive.CallsignView do def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket) def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket) - def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} = msg, socket) do - handle_aprs_packet_broadcast(msg, payload, socket) + def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do + # Only process packets for the specific callsign being viewed + if PacketUtils.generate_callsign(packet) == socket.assigns.callsign do + handle_info_postgres_packet(packet, socket) + else + {:noreply, socket} + end end def handle_info(_msg, socket), do: {:noreply, socket} - defp handle_aprs_packet_broadcast(_msg, payload, socket) do - sanitized_packet = EncodingUtils.sanitize_packet(payload) - sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) - {lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet) + defp handle_info_postgres_packet(packet, socket) do + key = System.unique_integer([:positive]) + updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet) + socket = assign(socket, visible_packets: updated_visible_packets) - callsign_key = - "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" + packet_data = PacketUtils.build_packet_data(packet) - if MapHelpers.has_position_data?(sanitized_packet) and - packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and - MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and - packet_within_time_threshold?( - sanitized_packet, - socket.assigns.packet_age_threshold - ) do - packet_data = build_packet_data(sanitized_packet) + socket = + if packet_data, + do: push_event(socket, "add_markers", %{markers: [packet_data]}), + else: socket - # Remove any previous marker for this callsign - socket = - if Map.has_key?(socket.assigns.visible_packets, callsign_key) do - push_event(socket, "remove_marker", %{id: callsign_key}) - else - socket - end - - visible_packets = %{callsign_key => sanitized_packet} - - last_known_position = - if lat && lng, do: %{lat: lat, lng: lng}, else: socket.assigns.last_known_position - - socket = - socket - |> push_event("new_packet", packet_data) - |> assign( - visible_packets: visible_packets, - last_known_position: last_known_position - ) - - {:noreply, socket} - else - # Remove marker if it exists and is now out of bounds or expired - if Map.has_key?(socket.assigns.visible_packets, callsign_key) do - socket = push_event(socket, "remove_marker", %{id: callsign_key}) - visible_packets = %{} - {:noreply, assign(socket, visible_packets: visible_packets)} - else - {:noreply, socket} - end - end + {:noreply, socket} end defp handle_replay_next_packet(socket) do @@ -338,20 +305,8 @@ defmodule AprsWeb.MapLive.CallsignView do socket.assigns.replay_index < length(socket.assigns.replay_packets) end - defp handle_replay_packet(nil, socket) do - socket = - assign(socket, - replay_active: false, - replay_paused: false, - replay_timer_ref: nil, - replay_index: 0 - ) - - {:noreply, socket} - end - defp handle_replay_packet(packet, socket) do - case build_packet_data(packet) do + case PacketUtils.build_packet_data(packet) do nil -> handle_invalid_replay_packet(socket) packet_data -> handle_valid_replay_packet(packet, packet_data, socket) end @@ -445,7 +400,7 @@ defmodule AprsWeb.MapLive.CallsignView do .callsign-header { position: absolute; top: 10px; - left: 10px; + left: 60px; z-index: 1000; background: rgba(255, 255, 255, 0.95); border: 2px solid rgba(0,0,0,0.2); @@ -614,6 +569,11 @@ defmodule AprsWeb.MapLive.CallsignView do max-width: 300px; } + /* Hides the empty state dialog when not needed */ + .empty-state.hidden { + display: none; + } + .empty-state h3 { margin: 0 0 8px 0; color: #333; @@ -638,14 +598,15 @@ defmodule AprsWeb.MapLive.CallsignView do - <%= if map_size(@visible_packets) == 0 and not @replay_active do %> -
-

Loading Historical Data

-

- Loading packet history for {@callsign}... -

-
- <% end %> +
0 or @replay_active, do: "hidden") + ]}> +

Loading Historical Data

+

+ Loading packet history for {@callsign}... +

+
Enum.with_index() |> Enum.map(fn {packet, index} -> - case build_packet_data(packet) do + case PacketUtils.build_packet_data(packet) do nil -> nil @@ -794,7 +755,7 @@ defmodule AprsWeb.MapLive.CallsignView do socket = if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do # Push the latest marker as a regular marker (not historical) - packet_data = build_packet_data(latest_packet) + packet_data = PacketUtils.build_packet_data(latest_packet) if packet_data do push_event(socket, "add_markers", %{markers: [packet_data]}) @@ -876,123 +837,6 @@ defmodule AprsWeb.MapLive.CallsignView do abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001 end - defp build_packet_data(packet) do - {lat, lng, _} = MapHelpers.get_coordinates(packet) - callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) - - if lat != nil and lng != nil and callsign != "" and callsign != nil do - build_packet_map(packet, lat, lng, packet.data_extended) - end - end - - defp format_popup_timestamp(ts) do - dt = - cond do - is_binary(ts) -> - case DateTime.from_iso8601(ts) do - {:ok, dt, _} -> dt - _ -> DateTime.utc_now() - end - - is_integer(ts) -> - DateTime.from_unix!(ts, :millisecond) - - match?(%DateTime{}, ts) -> - ts - - match?(%NaiveDateTime{}, ts) -> - DateTime.from_naive!(ts, "Etc/UTC") - - true -> - DateTime.utc_now() - end - - ago = TimeHelpers.time_ago_in_words(dt) - abs_time = Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") - {ago, abs_time} - end - - defp build_packet_map(packet, lat, lng, data_extended) do - data_extended = data_extended || %{} - callsign = PacketUtils.generate_callsign(packet) - {symbol_table_id, symbol_code} = PacketUtils.get_symbol_info(packet) - timestamp = PacketUtils.get_timestamp(packet) - comment = PacketUtils.get_packet_field(packet, :comment, "") - - {ago, abs_time} = format_popup_timestamp(timestamp) - - popup = """ -
- -
Symbol: #{symbol_table_id}#{symbol_code}
- #{if comment == "", do: "", else: "
#{comment}
"} -
#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lng), 4)}
-
#{ago}
#{abs_time}
-
- """ - - %{ - "id" => callsign, - "callsign" => callsign, - "base_callsign" => PacketUtils.get_packet_field(packet, :base_callsign, ""), - "ssid" => PacketUtils.get_packet_field(packet, :ssid, 0), - "lat" => PacketUtils.to_float(lat), - "lng" => PacketUtils.to_float(lng), - "data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")), - "path" => PacketUtils.get_packet_field(packet, :path, ""), - "comment" => comment, - "data_extended" => PacketUtils.convert_tuples_to_strings(data_extended || %{}), - "symbol_table_id" => symbol_table_id, - "symbol_code" => symbol_code, - "symbol_description" => "Symbol: #{symbol_table_id}#{symbol_code}", - "timestamp" => timestamp, - "popup" => popup - } - end - - defp packet_matches_callsign?(packet, target_callsign) do - normalized_packet = normalize_packet_callsign(packet) - normalized_target = normalize_target_callsign(target_callsign) - exact_or_base_match?(normalized_packet, normalized_target, packet) - end - - defp normalize_packet_callsign(packet) do - base_callsign = packet[:base_callsign] || packet.base_callsign || "" - ssid = packet[:ssid] || packet.ssid - - case_result = - case ssid do - nil -> base_callsign - "" -> base_callsign - "0" -> base_callsign - _ -> "#{base_callsign}-#{ssid}" - end - - case_result - |> String.upcase() - |> String.trim() - end - - defp normalize_target_callsign(target_callsign) do - target_callsign - |> String.upcase() - |> String.trim() - end - - defp exact_or_base_match?(normalized_packet, normalized_target, packet) do - cond do - normalized_packet == normalized_target -> - true - - not String.contains?(normalized_target, "-") -> - base_callsign = packet[:base_callsign] || packet.base_callsign || "" - String.upcase(String.trim(base_callsign)) == normalized_target - - true -> - false - end - end - defp load_callsign_packets(socket, callsign) do latest_packet = %{callsign: callsign} @@ -1042,7 +886,7 @@ defmodule AprsWeb.MapLive.CallsignView do defp maybe_push_latest_marker(socket, nil), do: socket defp maybe_push_latest_marker(socket, packet) do - packet_data = build_packet_data(packet) + packet_data = PacketUtils.build_packet_data(packet) if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket end end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index a770b62..c2f9389 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -4,6 +4,8 @@ defmodule AprsWeb.MapLive.Index do """ use AprsWeb, :live_view + import AprsWeb.TimeHelpers, only: [time_ago_in_words: 1] + alias AprsWeb.Endpoint alias AprsWeb.MapLive.MapHelpers alias AprsWeb.MapLive.PacketUtils @@ -195,7 +197,7 @@ defmodule AprsWeb.MapLive.Index do visible_packets_list = filtered_packets - |> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end) + |> Enum.map(fn {_callsign, packet} -> PacketUtils.build_packet_data(packet) end) |> Enum.filter(& &1) socket = assign(socket, visible_packets: filtered_packets) @@ -273,7 +275,7 @@ defmodule AprsWeb.MapLive.Index do {:noreply, socket} else # Navigate to the callsign-specific route - {:noreply, push_navigate(socket, to: "/#{trimmed_callsign}")} + {:noreply, push_navigate(socket, to: "/map/callsign/#{trimmed_callsign}")} end end @@ -307,7 +309,7 @@ defmodule AprsWeb.MapLive.Index do @spec handle_bounds_update(map(), Socket.t()) :: {:noreply, Socket.t()} defp handle_bounds_update(bounds, socket) do - # Update the map bounds from the client + # Update the map bounds from the client, converting to atom keys map_bounds = %{ north: bounds["north"], south: bounds["south"], @@ -324,7 +326,6 @@ defmodule AprsWeb.MapLive.Index do # Only schedule a bounds update if the bounds have actually changed (with rounding) if compare_bounds(map_bounds, socket.assigns.map_bounds) do {:noreply, socket} - # Cancel any pending bounds update timer else if socket.assigns[:bounds_update_timer] do Process.cancel_timer(socket.assigns.bounds_update_timer) @@ -495,7 +496,7 @@ defmodule AprsWeb.MapLive.Index do else: System.unique_integer([:positive]) new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) - marker_data = build_packet_data(packet) + marker_data = PacketUtils.build_packet_data(packet) socket = if marker_data do @@ -903,38 +904,6 @@ defmodule AprsWeb.MapLive.Index do

- -
- <.link - navigate={~p"/packets"} - class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors" - > - - - - View All Packets - - <.link - navigate={~p"/badpackets"} - class="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors" - > - - - - View Bad Packets - -
-
@@ -1079,7 +1048,7 @@ defmodule AprsWeb.MapLive.Index do packet_data_list = historical_packets - |> Enum.group_by(&generate_callsign/1) + |> Enum.group_by(&PacketUtils.generate_callsign/1) |> Enum.flat_map(fn {callsign, packets} -> sorted_packets = Enum.sort_by( @@ -1120,7 +1089,7 @@ defmodule AprsWeb.MapLive.Index do end defp build_historical_packet_data(packet, index, callsign) do - case build_packet_data(packet) do + case PacketUtils.build_packet_data(packet) do nil -> nil @@ -1208,7 +1177,7 @@ defmodule AprsWeb.MapLive.Index do packets = packets_module.get_packets_for_replay(packets_params) # Sort packets by received_at timestamp to ensure chronological replay - Enum.sort_by(packets, fn packet -> packet.received_at end) + Enum.sort_by(packets, & &1.received_at) end @spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() @@ -1258,146 +1227,6 @@ defmodule AprsWeb.MapLive.Index do end end - @spec build_packet_data(map() | struct()) :: map() | nil - defp build_packet_data(packet) do - {lat, lon, data_extended} = MapHelpers.get_coordinates(packet) - callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) - - # Only include packets with valid position data and a non-empty callsign - if lat && lon && callsign != "" && callsign != nil do - build_packet_map(packet, lat, lon, data_extended) - end - end - - @spec build_packet_map(map() | struct(), number(), number(), map() | nil) :: map() - defp build_packet_map(packet, lat, lon, data_extended) do - data_extended = data_extended || %{} - packet_info = extract_packet_info(packet, data_extended) - popup = build_popup_content(packet, packet_info, lat, lon) - - build_packet_result(packet, packet_info, lat, lon, popup) - end - - defp extract_packet_info(packet, data_extended) do - %{ - callsign: PacketUtils.generate_callsign(packet), - symbol_table_id: PacketUtils.get_packet_field(packet, :symbol_table_id, "/"), - symbol_code: PacketUtils.get_packet_field(packet, :symbol_code, ">"), - timestamp: PacketUtils.get_timestamp(packet), - comment: PacketUtils.get_packet_field(packet, :comment, ""), - safe_data_extended: PacketUtils.convert_tuples_to_strings(data_extended), - is_weather_packet: PacketUtils.weather_packet?(packet) - } - end - - defp build_popup_content(packet, packet_info, lat, lon) do - if packet_info.is_weather_packet do - build_weather_popup_html(packet, packet_info.callsign) - else - build_standard_popup_html(packet_info, lat, lon) - end - end - - defp build_standard_popup_html(packet_info, lat, lon) do - comment_html = - if packet_info.comment == "", - do: "", - else: ~s(
#{packet_info.comment}
) - - """ -
- - #{comment_html} -
#{Float.round(PacketUtils.to_float(lat), 4)}, #{Float.round(PacketUtils.to_float(lon), 4)}
-
#{format_popup_timestamp(packet_info.timestamp)}
-
- """ - end - - defp build_packet_result(packet, packet_info, lat, lon, popup) do - %{ - "id" => packet_info.callsign, - "callsign" => packet_info.callsign, - "base_callsign" => PacketUtils.get_packet_field(packet, :base_callsign, ""), - "ssid" => PacketUtils.get_packet_field(packet, :ssid, 0), - "lat" => PacketUtils.to_float(lat), - "lng" => PacketUtils.to_float(lon), - "data_type" => to_string(PacketUtils.get_packet_field(packet, :data_type, "unknown")), - "path" => PacketUtils.get_packet_field(packet, :path, ""), - "comment" => packet_info.comment, - "data_extended" => packet_info.safe_data_extended || %{}, - "symbol_table_id" => packet_info.symbol_table_id, - "symbol_code" => packet_info.symbol_code, - "symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}", - "timestamp" => packet_info.timestamp, - "popup" => popup - } - end - - defp build_weather_popup_html(packet, callsign) do - received_at = get_received_at(packet) - timestamp_str = format_popup_timestamp(received_at) - - """ - #{callsign} - Weather Report
- #{timestamp_str} -
- Temperature: #{PacketUtils.get_weather_field(packet, :temperature)}°F
- Humidity: #{PacketUtils.get_weather_field(packet, :humidity)}%
- Wind: #{PacketUtils.get_weather_field(packet, :wind_direction)}° at #{PacketUtils.get_weather_field(packet, :wind_speed)} mph, gusts to #{PacketUtils.get_weather_field(packet, :wind_gust)} mph
- Pressure: #{PacketUtils.get_weather_field(packet, :pressure)} hPa
- Rain (1h): #{PacketUtils.get_weather_field(packet, :rain_1h)} in.
- Rain (24h): #{PacketUtils.get_weather_field(packet, :rain_24h)} in.
- Rain (since midnight): #{PacketUtils.get_weather_field(packet, :rain_since_midnight)} in.
- """ - end - - defp get_received_at(packet) do - cond do - Map.has_key?(packet, :received_at) -> packet.received_at - Map.has_key?(packet, "received_at") -> packet["received_at"] - true -> nil - end - end - - defp format_popup_timestamp(ts) do - cond do - is_binary(ts) -> - case DateTime.from_iso8601(ts) do - {:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC") - _ -> ts - end - - is_integer(ts) -> - ts - |> DateTime.from_unix!(:millisecond) - |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC") - - match?(%DateTime{}, ts) -> - Calendar.strftime(ts, "%Y-%m-%d %H:%M:%S UTC") - - match?(%NaiveDateTime{}, ts) -> - ts - |> DateTime.from_naive!("Etc/UTC") - |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC") - - true -> - to_string(ts) - end - end - - @spec generate_callsign(map() | struct()) :: String.t() - defp generate_callsign(packet) do - base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) - ssid = Map.get(packet, :ssid, Map.get(packet, "ssid", 0)) - - if ssid != 0 and ssid != "" and ssid != nil do - "#{base_callsign}-#{ssid}" - else - base_callsign - end - end - # Get location from IP using ip-api.com @spec get_ip_location(String.t() | nil) :: {float(), float()} | nil defp get_ip_location(nil), do: nil @@ -1469,7 +1298,4 @@ defmodule AprsWeb.MapLive.Index do round4.(Map.get(b1, key)) == round4.(Map.get(b2, key)) end) end - - # Use shared helper - defp time_ago_in_words(datetime), do: AprsWeb.TimeHelpers.time_ago_in_words(datetime) end diff --git a/lib/aprs_web/live/map_live/packet_utils.ex b/lib/aprs_web/live/map_live/packet_utils.ex index bee56e7..5cff344 100644 --- a/lib/aprs_web/live/map_live/packet_utils.ex +++ b/lib/aprs_web/live/map_live/packet_utils.ex @@ -3,6 +3,9 @@ defmodule AprsWeb.MapLive.PacketUtils do Shared utilities for extracting and processing packet data in map views. """ + alias AprsWeb.MapLive.MapHelpers + alias AprsWeb.TimeHelpers + @doc """ Safely extracts a value from a packet or data_extended map with fallback support. Checks both atom and string keys, and provides a default value. @@ -134,4 +137,129 @@ defmodule AprsWeb.MapLive.PacketUtils do Map.get(data_extended, key) || Map.get(data_extended, to_string(key)) || "N/A" end + + def build_packet_data(packet) do + {lat, lon, data_extended} = MapHelpers.get_coordinates(packet) + callsign = generate_callsign(packet) + + if lat && lon && callsign != "" && callsign != nil do + build_packet_map(packet, lat, lon, data_extended) + end + end + + defp build_packet_map(packet, lat, lon, data_extended) do + data_extended = data_extended || %{} + packet_info = extract_packet_info(packet, data_extended) + popup = build_popup_content(packet, packet_info, lat, lon) + + build_packet_result(packet, packet_info, lat, lon, popup) + end + + defp extract_packet_info(packet, data_extended) do + %{ + callsign: generate_callsign(packet), + symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"), + symbol_code: get_packet_field(packet, :symbol_code, ">"), + timestamp: get_timestamp(packet), + comment: get_packet_field(packet, :comment, ""), + safe_data_extended: convert_tuples_to_strings(data_extended), + is_weather_packet: weather_packet?(packet) + } + end + + defp build_popup_content(packet, packet_info, lat, lon) do + if packet_info.is_weather_packet do + build_weather_popup_html(packet, packet_info.callsign) + else + build_standard_popup_html(packet_info, lat, lon) + end + end + + defp build_standard_popup_html(packet_info, lat, lon) do + comment_html = + if packet_info.comment == "", + do: "", + else: ~s(
#{packet_info.comment}
) + + timestamp_dt = TimeHelpers.to_datetime(packet_info.timestamp) + + timestamp_html = + if timestamp_dt do + """ +
+
#{TimeHelpers.time_ago_in_words(timestamp_dt)}
+
#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}
+
+ """ + else + "" + end + + """ +
+ + #{comment_html} +
#{Float.round(to_float(lat), 4)}, #{Float.round(to_float(lon), 4)}
+ #{timestamp_html} +
+ """ + end + + defp build_weather_popup_html(packet, callsign) do + received_at = get_received_at(packet) + timestamp_dt = TimeHelpers.to_datetime(received_at) + + timestamp_html = + if timestamp_dt do + """ +
+
#{TimeHelpers.time_ago_in_words(timestamp_dt)}
+
#{Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC")}
+
+ """ + else + "" + end + + """ + #{callsign} - Weather Report + #{timestamp_html} +
+ Temperature: #{get_weather_field(packet, :temperature)}°F
+ Humidity: #{get_weather_field(packet, :humidity)}%
+ Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph
+ Pressure: #{get_weather_field(packet, :pressure)} hPa
+ Rain (1h): #{get_weather_field(packet, :rain_1h)} in.
+ Rain (24h): #{get_weather_field(packet, :rain_24h)} in.
+ Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.
+ """ + end + + defp build_packet_result(packet, packet_info, lat, lon, popup) do + %{ + "id" => packet_info.callsign, + "callsign" => packet_info.callsign, + "base_callsign" => get_packet_field(packet, :base_callsign, ""), + "ssid" => get_packet_field(packet, :ssid, 0), + "lat" => to_float(lat), + "lng" => to_float(lon), + "data_type" => to_string(get_packet_field(packet, :data_type, "unknown")), + "path" => get_packet_field(packet, :path, ""), + "comment" => packet_info.comment, + "data_extended" => packet_info.safe_data_extended || %{}, + "symbol_table_id" => packet_info.symbol_table_id, + "symbol_code" => packet_info.symbol_code, + "symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}", + "timestamp" => packet_info.timestamp, + "popup" => popup + } + end + + defp get_received_at(packet) do + cond do + Map.has_key?(packet, :received_at) -> packet.received_at + Map.has_key?(packet, "received_at") -> packet["received_at"] + true -> nil + end + end end diff --git a/lib/aprs_web/time_helpers.ex b/lib/aprs_web/time_helpers.ex index a84df51..d4d7dd8 100644 --- a/lib/aprs_web/time_helpers.ex +++ b/lib/aprs_web/time_helpers.ex @@ -26,4 +26,31 @@ defmodule AprsWeb.TimeHelpers do defp format_time_diff(seconds) when seconds < 63_072_000, do: "1 year" defp format_time_diff(seconds), do: "#{div(seconds, 31_536_000)} years" + + @doc """ + Converts various timestamp formats into a standard DateTime object. + Handles ISO 8601 strings, Unix timestamps in milliseconds, and existing + DateTime or NaiveDateTime structs. + """ + def to_datetime(ts) do + cond do + is_binary(ts) -> + case DateTime.from_iso8601(ts) do + {:ok, dt, _} -> dt + _ -> nil + end + + is_integer(ts) -> + DateTime.from_unix!(ts, :millisecond) + + match?(%DateTime{}, ts) -> + ts + + match?(%NaiveDateTime{}, ts) -> + DateTime.from_naive!(ts, "Etc/UTC") + + true -> + nil + end + end end