Improve info page live updates and fix APRS parser

- 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 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2025-07-28 15:14:33 -05:00
parent dc459b491d
commit 85c3b3d9e7
No known key found for this signature in database
7 changed files with 187 additions and 59 deletions

View file

@ -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 = () => {

View file

@ -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(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>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: `<div style="
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
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(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>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: `<div style="
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
},
destroyed() {
if (this.map) {
this.map.remove();

View file

@ -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;
}
}
};

View file

@ -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)

View file

@ -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

View file

@ -102,7 +102,12 @@
<dt class="text-xs font-medium opacity-70">{gettext("Last Position")}</dt>
<dd class="mt-1 text-sm">
<%= if @packet.received_at do %>
<div class="font-semibold">
<div
class="font-semibold"
phx-hook="TimeAgoHook"
id="last-position-time"
data-timestamp={DateTime.to_iso8601(@packet.received_at)}
>
{AprsmeWeb.TimeHelpers.time_ago_in_words(@packet.received_at)}
</div>
<div class="text-xs opacity-70 font-mono">
@ -381,8 +386,13 @@
<% end %>
</td>
<td class="opacity-70">
<%= if ssid_info.last_heard do %>
<span class="text-sm">
<%= if ssid_info.last_heard && ssid_info.last_heard.timestamp do %>
<span
class="text-sm"
phx-hook="TimeAgoHook"
id={"ssid-time-#{ssid_info.ssid}"}
data-timestamp={ssid_info.last_heard.timestamp}
>
{ssid_info.last_heard.time_ago}
</span>
<% else %>
@ -466,7 +476,12 @@
</td>
<td class="opacity-70">
<%= if neighbor.last_heard do %>
<div class="font-semibold">
<div
class="font-semibold"
phx-hook="TimeAgoHook"
id={"neighbor-time-#{neighbor.callsign}"}
data-timestamp={neighbor.last_heard.timestamp}
>
{neighbor.last_heard.time_ago}
</div>
<div class="text-xs opacity-70 font-mono">

2
vendor/aprs vendored

@ -1 +1 @@
Subproject commit 7e5e707605cc428d1b45cbf00f0f38278f50f2cd
Subproject commit d7d7a86ad31ce5bafa4422fe918745b24eafd639