diff --git a/assets/js/app.js b/assets/js/app.js index f122f9c..4599b19 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -54,6 +54,8 @@ import MapAPRSMap from "./map"; import ErrorBoundary from "./hooks/error_boundary"; // Import info map hook import { InfoMap } from "./hooks/info_map"; +// Import time ago hook +import TimeAgoHook from "./hooks/time_ago_hook"; // Responsive Slideover Hook let ResponsiveSlideoverHook = { @@ -218,6 +220,7 @@ Object.keys(WeatherChartHooks).forEach(hookName => { Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook; Hooks.BodyClassHook = BodyClassHook; Hooks.ErrorBoundary = ErrorBoundary; +Hooks.TimeAgoHook = TimeAgoHook; // Helper function to get theme-aware colors const getThemeColors = () => { diff --git a/assets/js/hooks/info_map.js b/assets/js/hooks/info_map.js index ed5d907..9fdfba9 100644 --- a/assets/js/hooks/info_map.js +++ b/assets/js/hooks/info_map.js @@ -1,6 +1,51 @@ // Simple map hook for displaying a single station on the info page export const InfoMap = { mounted() { + this.initializeMap(); + }, + + updated() { + // When the element updates, check if we need to update the marker + const lat = parseFloat(this.el.dataset.lat); + const lon = parseFloat(this.el.dataset.lon); + const symbolHtml = this.el.dataset.symbolHtml; + + // Validate coordinates + if (isNaN(lat) || isNaN(lon)) { + return; + } + + // If map doesn't exist yet, initialize it + if (!this.map) { + this.initializeMap(); + return; + } + + // Update marker position if it changed + if (this.marker) { + const currentPos = this.marker.getLatLng(); + if (currentPos.lat !== lat || currentPos.lng !== lon) { + // Animate the marker to the new position + this.marker.setLatLng([lat, lon]); + + // Update the popup content + const callsign = this.el.dataset.callsign; + this.marker.setPopupContent(`${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`); + + // Optionally pan the map to the new position with animation + this.map.panTo([lat, lon], { animate: true, duration: 1 }); + } + + // Update marker icon if symbol changed + if (symbolHtml !== this.lastSymbolHtml) { + const markerIcon = this.createMarkerIcon(symbolHtml); + this.marker.setIcon(markerIcon); + this.lastSymbolHtml = symbolHtml; + } + } + }, + + initializeMap() { // Check if Leaflet is available if (typeof L === "undefined") { console.error("Leaflet not loaded for InfoMap"); @@ -37,34 +82,11 @@ export const InfoMap = { }).addTo(this.map); // Create marker icon - let markerIcon; - if (symbolHtml) { - // Use the APRS symbol if provided - markerIcon = L.divIcon({ - html: symbolHtml, - className: 'aprs-info-marker', - iconSize: [32, 32], - iconAnchor: [16, 16] - }); - } else { - // Default marker - markerIcon = L.divIcon({ - html: `
`, - className: '', - iconSize: [24, 24], - iconAnchor: [12, 12] - }); - } + const markerIcon = this.createMarkerIcon(symbolHtml); + this.lastSymbolHtml = symbolHtml; // Add marker for the station - const marker = L.marker([lat, lon], { icon: markerIcon }) + this.marker = L.marker([lat, lon], { icon: markerIcon }) .addTo(this.map) .bindPopup(`${callsign}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}`); @@ -80,6 +102,33 @@ export const InfoMap = { } }, + createMarkerIcon(symbolHtml) { + if (symbolHtml) { + // Use the APRS symbol if provided + return L.divIcon({ + html: symbolHtml, + className: 'aprs-info-marker', + iconSize: [32, 32], + iconAnchor: [16, 16] + }); + } else { + // Default marker + return L.divIcon({ + html: `
`, + className: '', + iconSize: [24, 24], + iconAnchor: [12, 12] + }); + } + }, + destroyed() { if (this.map) { this.map.remove(); diff --git a/assets/js/hooks/time_ago_hook.js b/assets/js/hooks/time_ago_hook.js new file mode 100644 index 0000000..b8d30a5 --- /dev/null +++ b/assets/js/hooks/time_ago_hook.js @@ -0,0 +1,69 @@ +// Hook to automatically update time ago displays +export default { + mounted() { + this.startTimer(); + }, + + destroyed() { + this.stopTimer(); + }, + + updated() { + // Restart timer when the element updates + this.stopTimer(); + this.startTimer(); + }, + + startTimer() { + const updateTimeAgo = () => { + const timestampStr = this.el.dataset.timestamp; + if (!timestampStr) return; + + const timestamp = new Date(timestampStr); + const now = new Date(); + const diffMs = now - timestamp; + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) { + this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`; + } else if (diffSeconds < 3600) { + const minutes = Math.floor(diffSeconds / 60); + this.el.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + } else if (diffSeconds < 86400) { + const hours = Math.floor(diffSeconds / 3600); + this.el.textContent = `${hours} hour${hours !== 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(diffSeconds / 86400); + this.el.textContent = `${days} day${days !== 1 ? 's' : ''} ago`; + } + }; + + // Update immediately + updateTimeAgo(); + + // Update every second for recent timestamps, less frequently for older ones + const timestampStr = this.el.dataset.timestamp; + if (timestampStr) { + const timestamp = new Date(timestampStr); + const age = Date.now() - timestamp; + + let interval; + if (age < 60000) { // Less than 1 minute old + interval = 1000; // Update every second + } else if (age < 3600000) { // Less than 1 hour old + interval = 60000; // Update every minute + } else { + interval = 300000; // Update every 5 minutes + } + + this.timer = setInterval(updateTimeAgo, interval); + } + }, + + stopTimer() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } +}; \ No newline at end of file diff --git a/lib/aprsme/postgres_notifier.ex b/lib/aprsme/postgres_notifier.ex index d3a6692..17219f2 100644 --- a/lib/aprsme/postgres_notifier.ex +++ b/lib/aprsme/postgres_notifier.ex @@ -34,6 +34,14 @@ defmodule Aprsme.PostgresNotifier do # Broadcast to the general packet topic Phoenix.PubSub.broadcast(Aprsme.PubSub, @packet_topic, {:postgres_packet, packet}) + # Broadcast to callsign-specific topic + callsign = Map.get(packet, "sender") || Map.get(packet, :sender) + + if is_binary(callsign) and callsign != "" do + normalized_callsign = String.upcase(String.trim(callsign)) + Phoenix.PubSub.broadcast(Aprsme.PubSub, "packets:#{normalized_callsign}", {:postgres_packet, packet}) + end + # Also broadcast to callsign-specific weather topic if it's a weather packet broadcast_weather_packet_if_relevant(packet) diff --git a/lib/aprsme_web/live/info_live/show.ex b/lib/aprsme_web/live/info_live/show.ex index ea29ca8..239e1d3 100644 --- a/lib/aprsme_web/live/info_live/show.ex +++ b/lib/aprsme_web/live/info_live/show.ex @@ -20,9 +20,9 @@ defmodule AprsmeWeb.InfoLive.Show do def mount(%{"callsign" => callsign}, _session, socket) do normalized_callsign = Callsign.normalize(callsign) - # Subscribe to Postgres notifications for live updates + # Subscribe to callsign-specific topic for live updates if connected?(socket) do - Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") + Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets:#{normalized_callsign}") end packet = get_latest_packet(normalized_callsign) @@ -52,31 +52,13 @@ defmodule AprsmeWeb.InfoLive.Show do @impl true def handle_info({:postgres_packet, packet}, socket) do - require Logger - - # Debug log to see if we're receiving packets - packet_sender = Map.get(packet, "sender") || Map.get(packet, :sender, "unknown") - Logger.debug("InfoLive received packet from sender: #{packet_sender} for callsign: #{socket.assigns.callsign}") - - SharedPacketHandler.handle_packet_update(packet, socket, - filter_fn: SharedPacketHandler.callsign_filter(socket.assigns.callsign), - process_fn: &process_packet_update/2, - enrich_packet: false - ) + # Since we're subscribed to callsign-specific topic, no need to filter + process_packet_update(packet, socket) end - def handle_info(message, socket) do - require Logger - - Logger.debug("InfoLive received unknown message: #{inspect(message)}") - {:noreply, socket} - end + def handle_info(_message, socket), do: {:noreply, socket} defp process_packet_update(_incoming_packet, socket) do - require Logger - - Logger.debug("InfoLive processing packet update for callsign: #{socket.assigns.callsign}") - # Refresh data when new packet arrives packet = get_latest_packet(socket.assigns.callsign) packet = if packet, do: SharedPacketHandler.enrich_with_device_info(packet) @@ -103,8 +85,7 @@ defmodule AprsmeWeb.InfoLive.Show do defp get_latest_packet(callsign) do # Get the most recent packet for this callsign, regardless of type # This ensures we show the most recent activity, not just position packets - # Use cached version for better performance - Aprsme.CachedQueries.get_latest_packet_for_callsign_cached(callsign) + Packets.get_latest_packet_for_callsign(callsign) end defp get_neighbors(nil, _callsign, _locale), do: [] @@ -180,18 +161,21 @@ defmodule AprsmeWeb.InfoLive.Show do if timestamp_dt do %{ time_ago: AprsmeWeb.TimeHelpers.time_ago_in_words(timestamp_dt), - formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC") + formatted: Calendar.strftime(timestamp_dt, "%Y-%m-%d %H:%M:%S UTC"), + timestamp: DateTime.to_iso8601(timestamp_dt) } else %{ time_ago: gettext("Unknown"), - formatted: "" + formatted: "", + timestamp: nil } end else %{ time_ago: gettext("Unknown"), - formatted: "" + formatted: "", + timestamp: nil } end end diff --git a/lib/aprsme_web/live/info_live/show.html.heex b/lib/aprsme_web/live/info_live/show.html.heex index 4fdc6ae..5ce8700 100644 --- a/lib/aprsme_web/live/info_live/show.html.heex +++ b/lib/aprsme_web/live/info_live/show.html.heex @@ -102,7 +102,12 @@
{gettext("Last Position")}
<%= if @packet.received_at do %> -
+
{AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)}
@@ -381,8 +386,13 @@ <% end %> - <%= if ssid_info.last_heard do %> - + <%= if ssid_info.last_heard && ssid_info.last_heard.timestamp do %> + {ssid_info.last_heard.time_ago} <% else %> @@ -466,7 +476,12 @@ <%= if neighbor.last_heard do %> -
+
{neighbor.last_heard.time_ago}
diff --git a/vendor/aprs b/vendor/aprs index 7e5e707..d7d7a86 160000 --- a/vendor/aprs +++ b/vendor/aprs @@ -1 +1 @@ -Subproject commit 7e5e707605cc428d1b45cbf00f0f38278f50f2cd +Subproject commit d7d7a86ad31ce5bafa4422fe918745b24eafd639