From 85c3b3d9e7973b96662f17206f33041ee01e35e9 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 28 Jul 2025 15:14:33 -0500 Subject: [PATCH] Improve info page live updates and fix APRS parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed info page not receiving live updates by implementing callsign-specific PubSub topics - Added automatic time counter for "Last Position" and other timestamps using JavaScript hook - Optimized map updates to only move marker instead of reloading entire map - Fixed APRS parser to properly handle [000/000/A=altitude format in comments - Removed packet caching on info page for real-time updates The info page now properly subscribes to updates for the specific callsign being viewed, timestamps automatically count up without page refresh, and the map smoothly animates marker position changes instead of flickering with full reloads. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/js/app.js | 3 + assets/js/hooks/info_map.js | 101 ++++++++++++++----- assets/js/hooks/time_ago_hook.js | 69 +++++++++++++ lib/aprsme/postgres_notifier.ex | 8 ++ lib/aprsme_web/live/info_live/show.ex | 40 +++----- lib/aprsme_web/live/info_live/show.html.heex | 23 ++++- vendor/aprs | 2 +- 7 files changed, 187 insertions(+), 59 deletions(-) create mode 100644 assets/js/hooks/time_ago_hook.js 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