diff --git a/assets/js/app.js b/assets/js/app.js index cb38335..e1cf69f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,6 +21,7 @@ import "phoenix_html"; import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; import topbar from "../vendor/topbar"; +import "../vendor/oms.min.js"; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); @@ -74,6 +75,139 @@ let Hooks = {}; Hooks.APRSMap = MapAPRSMap; Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook; +Hooks.PlotlyTempChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const temps = data.map(d => d.temperature); + const dews = data.map(d => d.dew_point); + Plotly.newPlot(this.el, [ + { x: times, y: temps, name: "Temperature", type: "scatter", line: { color: "red" } }, + { x: times, y: dews, name: "Dew Point", type: "scatter", line: { color: "blue" } } + ], { + title: "Temperature & Dew Point (°F)", + xaxis: { title: "Time" }, + yaxis: { title: "°F" } + }, { + responsive: true, + staticPlot: true, + displayModeBar: false + }); + } +}; + +Hooks.PlotlyHumidityChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const hums = data.map(d => d.humidity); + Plotly.newPlot(this.el, [ + { x: times, y: hums, name: "Humidity", type: "scatter", line: { color: "blue" } } + ], { + title: "Humidity (%)", + xaxis: { title: "Time" }, + yaxis: { title: "%" } + }, { + responsive: true, + staticPlot: true, + displayModeBar: false + }); + } +}; + +Hooks.PlotlyPressureChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const pressures = data.map(d => d.pressure); + Plotly.newPlot(this.el, [ + { x: times, y: pressures, name: "Pressure", type: "scatter", line: { color: "green" } } + ], { + title: "Pressure (hPa)", + xaxis: { title: "Time" }, + yaxis: { title: "hPa" } + }, {responsive: true, staticPlot: true, displayModeBar: false}); + } +}; + +Hooks.PlotlyWindDirectionChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const dirs = data.map(d => d.wind_direction); + Plotly.newPlot(this.el, [ + { x: times, y: dirs, name: "Wind Direction", type: "scatter", line: { color: "orange" } } + ], { + title: "Wind Direction (°)", + xaxis: { title: "Time" }, + yaxis: { title: "°" } + }, {responsive: true, staticPlot: true, displayModeBar: false}); + } +}; + +Hooks.PlotlyWindSpeedChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const speeds = data.map(d => d.wind_speed); + Plotly.newPlot(this.el, [ + { x: times, y: speeds, name: "Wind Speed", type: "scatter", line: { color: "purple" } } + ], { + title: "Wind Speed (mph)", + xaxis: { title: "Time" }, + yaxis: { title: "mph" } + }, {responsive: true, staticPlot: true, displayModeBar: false}); + } +}; + +Hooks.PlotlyRainChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const rain1h = data.map(d => d.rain_1h); + const rain24h = data.map(d => d.rain_24h); + const rainMid = data.map(d => d.rain_since_midnight); + Plotly.newPlot(this.el, [ + { x: times, y: rain1h, name: "Rain (1h)", type: "scatter", line: { color: "blue" } }, + { x: times, y: rain24h, name: "Rain (24h)", type: "scatter", line: { color: "navy" } }, + { x: times, y: rainMid, name: "Rain (since midnight)", type: "scatter", line: { color: "teal" } } + ], { + title: "Rain (inches)", + xaxis: { title: "Time" }, + yaxis: { title: "in" } + }, {responsive: true, staticPlot: true, displayModeBar: false}); + } +}; + +Hooks.PlotlyLuminosityChart = { + mounted() { this.renderChart(); }, + updated() { this.renderChart(); }, + renderChart() { + const data = JSON.parse(this.el.dataset.weatherHistory); + const times = data.map(d => d.timestamp); + const lum = data.map(d => d.luminosity); + Plotly.newPlot(this.el, [ + { x: times, y: lum, name: "Luminosity", type: "scatter", line: { color: "gold" } } + ], { + title: "Luminosity", + xaxis: { title: "Time" }, + yaxis: { title: "" } + }, {responsive: true, staticPlot: true, displayModeBar: false}); + } +}; + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, @@ -93,3 +227,6 @@ liveSocket.connect(); // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket; + +window.Hooks = Hooks; +export default Hooks; diff --git a/assets/js/map.ts b/assets/js/map.ts index a74f57f..3668c1e 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1,5 +1,6 @@ // Declare Leaflet as a global variable declare const L: any; +declare const OverlappingMarkerSpiderfier: any; // Import trail management functionality import { TrailManager } from "./features/trail_manager"; @@ -23,6 +24,7 @@ type LiveViewHookContext = { maxInitializationAttempts?: number; lastZoom?: number; currentPopupMarkerId?: string | null; + oms?: any; [key: string]: any; }; @@ -351,6 +353,10 @@ let MapAPRSMap = { // LiveView event handlers self.setupLiveViewHandlers(); + + if (typeof OverlappingMarkerSpiderfier !== "undefined") { + self.oms = new OverlappingMarkerSpiderfier(self.map); + } }, handleFatalError(message: string) { @@ -861,6 +867,10 @@ let MapAPRSMap = { if (data.openPopup && marker.openPopup) { marker.openPopup(); } + + if (self.oms && marker) { + self.oms.addMarker(marker); + } }, removeMarker(id: string | any) { @@ -882,6 +892,10 @@ let MapAPRSMap = { const trailId = markerState?.callsign_group || markerState?.callsign || markerId; self.trailManager.removeTrail(trailId); } + + if (self.oms && marker) { + self.oms.removeMarker(marker); + } }, updateMarker(data: MarkerData) { diff --git a/assets/vendor/oms.min.js b/assets/vendor/oms.min.js new file mode 100644 index 0000000..3b42684 --- /dev/null +++ b/assets/vendor/oms.min.js @@ -0,0 +1,19 @@ +(function(){/* + OverlappingMarkerSpiderfier +https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet +Copyright (c) 2011 - 2012 George MacKerron +Released under the MIT licence: http://opensource.org/licenses/mit-license +Note: The Leaflet maps API must be included *before* this code +*/ +(function(){var q={}.hasOwnProperty,r=[].slice;null!=this.L&&(this.OverlappingMarkerSpiderfier=function(){function n(c,b){var a,e,g,f,d=this;this.map=c;null==b&&(b={});for(a in b)q.call(b,a)&&(e=b[a],this[a]=e);this.initMarkerArrays();this.listeners={};f=["click","zoomend"];e=0;for(g=f.length;eb)return this;a=this.markerListeners.splice(b,1)[0];c.removeEventListener("click",a);delete c._oms;this.markers.splice(b,1);return this};d.clearMarkers=function(){var c,b,a,e,g;this.unspiderfy();g=this.markers;c=a=0;for(e=g.length;aa||this.listeners[c].splice(a,1);return this};d.clearListeners=function(c){this.listeners[c]=[];return this};d.trigger=function(){var c,b,a,e,g,f;b=arguments[0];c=2<=arguments.length?r.call(arguments,1):[];b=null!=(a=this.listeners[b])?a:[];f=[];e=0;for(g=b.length;ec;a=0<=c?++f:--f)a=this.circleStartAngle+a*e,d.push(new L.Point(b.x+g*Math.cos(a),b.y+g*Math.sin(a)));return d};d.generatePtsSpiral=function(c,b){var a,e,g,f,d;g=this.spiralLengthStart;a=0;d=[];for(e=f=0;0<=c?fc;e=0<=c?++f:--f)a+=this.spiralFootSeparation/g+5E-4*e,e=new L.Point(b.x+g*Math.cos(a),b.y+g*Math.sin(a)),g+=k*this.spiralLengthFactor/a,d.push(e);return d};d.spiderListener=function(c){var b,a,e,g,f,d,h,k,l;(b=null!= +c._omsData)&&this.keepSpiderfied||this.unspiderfy();if(b)return this.trigger("click",c);g=[];f=[];d=this.nearbyDistance*this.nearbyDistance;e=this.map.latLngToLayerPoint(c.getLatLng());l=this.markers;h=0;for(k=l.length;h=this.circleSpiralSwitchover?this.generatePtsSpiral(m,a).reverse():this.generatePtsCircle(m,a);a=function(){var a,b,k,m=this;k=[];a=0;for(b=d.length;a View packets + <%= if AprsmeWeb.MapLive.PacketUtils.has_weather_packets?(@callsign) do %> + <.link + navigate={~p"/weather/#{@callsign}"} + class="ml-2 text-sm text-blue-600 hover:underline" + > + Weather charts + + <% end %> <%= if @packet do %>
diff --git a/lib/aprsme_web/live/map_live/packet_utils.ex b/lib/aprsme_web/live/map_live/packet_utils.ex index 10e90bf..fa608c3 100644 --- a/lib/aprsme_web/live/map_live/packet_utils.ex +++ b/lib/aprsme_web/live/map_live/packet_utils.ex @@ -101,6 +101,21 @@ defmodule AprsmeWeb.MapLive.PacketUtils do data_type == "weather" or (symbol_table_id == "/" and symbol_code == "_") end + @doc """ + Checks if a station has sent weather packets by looking at recent packets. + """ + @spec has_weather_packets?(String.t()) :: boolean() + # Get recent packets for this callsign and check if any are weather packets + def has_weather_packets?(callsign) when is_binary(callsign) do + %{callsign: callsign, limit: 10} + |> Aprsme.Packets.get_recent_packets() + |> Enum.any?(&weather_packet?/1) + rescue + _ -> false + end + + def has_weather_packets?(_), do: false + @doc """ Recursively converts tuples in data structures to strings for JSON serialization. """ @@ -200,9 +215,16 @@ defmodule AprsmeWeb.MapLive.PacketUtils do "" end + weather_link_html = + if has_weather_packets?(packet_info.callsign) do + ~s( weather charts) + else + "" + end + """
- +
#{packet_info.callsign} info#{weather_link_html}
#{comment_html} #{timestamp_html}
@@ -230,7 +252,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """
- +
Weather Report
#{timestamp_html}
diff --git a/lib/aprsme_web/live/packets_live/callsign_view.html.heex b/lib/aprsme_web/live/packets_live/callsign_view.html.heex index 031926a..d613877 100644 --- a/lib/aprsme_web/live/packets_live/callsign_view.html.heex +++ b/lib/aprsme_web/live/packets_live/callsign_view.html.heex @@ -26,11 +26,11 @@ <%= case packet.received_at do %> <% %DateTime{} = dt -> %> - {Calendar.strftime(dt, "%H:%M:%S")} + {Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")} <% dt when is_binary(dt) -> %> <%= case DateTime.from_iso8601(dt) do %> <% {:ok, parsed_dt, _} -> %> - {Calendar.strftime(parsed_dt, "%H:%M:%S")} + {Calendar.strftime(parsed_dt, "%Y-%m-%d %H:%M:%S")} <% _ -> %> {dt} <% end %> diff --git a/lib/aprsme_web/live/weather_live/callsign_view.ex b/lib/aprsme_web/live/weather_live/callsign_view.ex index 525a394..efa82a2 100644 --- a/lib/aprsme_web/live/weather_live/callsign_view.ex +++ b/lib/aprsme_web/live/weather_live/callsign_view.ex @@ -2,6 +2,8 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do @moduledoc false use AprsmeWeb, :live_view + import Jason + alias Aprsme.Packets alias AprsmeWeb.MapLive.PacketUtils @@ -9,12 +11,40 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do def mount(%{"callsign" => callsign}, _session, socket) do normalized_callsign = String.upcase(String.trim(callsign)) weather_packet = get_latest_weather_packet(normalized_callsign) + {start_time, end_time} = default_time_range() + weather_history = get_weather_history(normalized_callsign, start_time, end_time) + + weather_history_json = + weather_history + |> Enum.map(fn pkt -> + dew_point = + if is_number(pkt.temperature) and is_number(pkt.humidity) do + calc_dew_point(pkt.temperature, pkt.humidity) + end + + %{ + timestamp: pkt.received_at, + temperature: pkt.temperature, + dew_point: dew_point, + humidity: pkt.humidity, + pressure: pkt.pressure, + wind_direction: pkt.wind_direction, + wind_speed: pkt.wind_speed, + rain_1h: pkt.rain_1h, + rain_24h: pkt.rain_24h, + rain_since_midnight: pkt.rain_since_midnight, + luminosity: pkt.luminosity + } + end) + |> Jason.encode!() socket = socket |> assign(:callsign, normalized_callsign) |> assign(:weather_packet, weather_packet) |> assign(:page_title, "Weather for #{normalized_callsign}") + |> assign(:weather_history, weather_history) + |> assign(:weather_history_json, weather_history_json) {:ok, socket} end @@ -25,4 +55,66 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do |> Packets.get_recent_packets() |> Enum.find(&PacketUtils.weather_packet?/1) end + + defp get_weather_history(callsign, start_time, end_time) do + opts = %{callsign: callsign, start_time: start_time, end_time: end_time, limit: 1000} + + opts + |> Packets.get_packets_for_replay() + |> Enum.filter(&PacketUtils.weather_packet?/1) + |> Enum.sort_by(& &1.received_at) + end + + defp default_time_range do + now = DateTime.utc_now() + {DateTime.add(now, -48 * 3600, :second), now} + end + + defp build_temp_chart_svg(history) do + data = + history + |> Enum.map(fn pkt -> + [pkt.received_at, pkt.temperature, pkt.dew_point] + end) + |> Enum.filter(fn [_, t, d] -> not is_nil(t) and not is_nil(d) end) + + if data == [] do + "No data" + else + dataset = Dataset.new(data, ["Time", "Temperature", "Dew Point"]) + + dataset + |> Plot.new(LinePlot, 600, 300, smoothed: false) + |> Plot.titles("Temperature & Dew Point (°F)", nil) + |> Plot.axis_labels("Time", "°F") + |> Plot.to_svg() + end + end + + defp build_humidity_chart_svg(history) do + data = + history + |> Enum.map(fn pkt -> + [pkt.received_at, pkt.humidity] + end) + |> Enum.filter(fn [_, h] -> not is_nil(h) end) + + if data == [] do + "No data" + else + dataset = Dataset.new(data, ["Time", "Humidity"]) + + dataset + |> Plot.new(LinePlot, 600, 300, smoothed: false) + |> Plot.titles("Humidity (%)", nil) + |> Plot.axis_labels("Time", "%") + |> Plot.to_svg() + end + end + + defp calc_dew_point(temp, humidity) when is_number(temp) and is_number(humidity) do + temp - (100 - humidity) / 5 + end + + defp calc_dew_point(_, _), do: nil end diff --git a/lib/aprsme_web/live/weather_live/callsign_view.html.heex b/lib/aprsme_web/live/weather_live/callsign_view.html.heex index 7643fd2..28df6b7 100644 --- a/lib/aprsme_web/live/weather_live/callsign_view.html.heex +++ b/lib/aprsme_web/live/weather_live/callsign_view.html.heex @@ -1,6 +1,14 @@ <%= if @weather_packet do %>
-

Weather for {@callsign}

+

+ Weather for {@callsign} + <.link + navigate={~p"/#{@callsign}"} + class="ml-4 text-blue-600 hover:underline text-base font-normal" + > + view on map + +

{PacketUtils.get_timestamp(@weather_packet)}
@@ -45,6 +53,74 @@ Raw comment: {@weather_packet.comment || @weather_packet["comment"]}
+
+

Weather History Graphs

+ <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :temperature, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :humidity, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :pressure, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_direction, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_speed, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_1h, nil)))) or Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_24h, nil)))) or Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_since_midnight, nil)))) do %> +
+
+ <% end %> + <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :luminosity, nil)))) do %> +
+
+ <% end %> + +
<% else %>

Weather for {@callsign}

diff --git a/mix.lock b/mix.lock index 77b41ac..4064b25 100644 --- a/mix.lock +++ b/mix.lock @@ -8,6 +8,7 @@ "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "contex": {:hex, :contex, "0.5.0", "5d8a6defbeb41f54adfcb0f85c4756d4f2b84aa5b0d809d45a5d2e90d91d0392", [:mix], [{:nimble_strftime, "~> 0.1.0", [hex: :nimble_strftime, repo: "hexpm", optional: false]}], "hexpm", "b7497a1790324d84247859df44ba4bcf2489d9bba1812a5375b2f2046b9e6fd7"}, "convertat": {:hex, :convertat, "1.1.0", "00c125f00a2e06be74d27df5a6b93f9b2d7c27aaa6ecb41d96f009ee7d7bd978", [:mix], [], "hexpm", "603229c43df6769f2166c78c5c3f31316390bf6e19fa8e15f02026170ab51a79"}, "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, @@ -54,6 +55,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.8.0-rc.3", "6ae19e57b9c109556f1b8abdb992d96d443b0ae28e03b604f3dc6c75d9f7d35f", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "419422afc33e965c0dbf181cbedc77b4cfd024dac0db7d9d2287656043d48e24"}, diff --git a/test/aprsme_web/live/map_live/packet_utils_test.exs b/test/aprsme_web/live/map_live/packet_utils_test.exs index e06ceba..651f682 100644 --- a/test/aprsme_web/live/map_live/packet_utils_test.exs +++ b/test/aprsme_web/live/map_live/packet_utils_test.exs @@ -51,6 +51,8 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do assert popup =~ "TEST-1" assert popup =~ "/TEST-1" assert popup =~ "/info/TEST-1" + assert popup =~ "/weather/TEST-1" + assert popup =~ "weather charts" end test "generates weather popup with N/A for missing weather data" do @@ -291,6 +293,17 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do refute PacketUtils.weather_packet?(regular_packet) end + + test "has_weather_packets? returns false for non-existent callsign" do + # This should return false since the database call will fail in test context + refute PacketUtils.has_weather_packets?("NONEXISTENT") + end + + test "has_weather_packets? handles invalid input gracefully" do + refute PacketUtils.has_weather_packets?(nil) + refute PacketUtils.has_weather_packets?("") + refute PacketUtils.has_weather_packets?(123) + end end describe "weather field extraction" do