diff --git a/CHANGELOG.md b/CHANGELOG.md index bbbe6c3..dddb88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BRIN indexes consideration for time-series data - Connection draining and load balancing for clustered deployments - Monitors CPU usage and connection counts across cluster nodes +- Documentation for tracked callsign behavior confirming last packet is always shown regardless of age - Automatically drains connections when node is overloaded (>70% CPU or 2x average connections) - Gracefully reconnects clients to less loaded nodes - Improves overall cluster stability and performance @@ -27,6 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Weather queries now use indexed `has_weather` column - Spatial queries optimized with geography cast index +### Fixed +- Tracked callsigns now show all packets including those without position data + - Previously filtered out packets with `has_position == false` + - Now shows all packets when tracking a specific callsign via /:callsign URL + - Ensures users can see status updates, messages, and other non-position packets + ### Fixed - PostgreSQL notify trigger now sends all required fields for info page updates - Info page real-time updates now display all packet details correctly diff --git a/docs/improvement-todos.md b/docs/improvement-todos.md index 9c59f3b..5dd87ea 100644 --- a/docs/improvement-todos.md +++ b/docs/improvement-todos.md @@ -48,6 +48,16 @@ This document tracks potential improvements identified during the multi-replica - Created `Aprsme.ShutdownHandler` with configurable drain timeout - Created `Aprsme.SignalHandler` for proper SIGTERM handling - **Silent shutdowns**: Removed all user notifications during graceful shutdown + +### ✅ Tracked Callsign Always Shows Last Packet (2025-07-30) +- **Status**: Fixed - Now Shows All Packets Including Non-Position +- **Impact**: High - User experience for tracking specific stations +- **Implementation**: + - Fixed `get_recent_packets` to not filter by `has_position` when tracking callsign + - Confirmed `get_latest_packet_for_callsign` fetches without time restrictions + - Verified `filter_packets_by_time_and_bounds_with_tracked` always includes tracked packet + - Added documentation explaining the behavior + - Users can now see status updates, messages, and other non-position packets - Updated health endpoint to return 503 when draining (15s delay) - Added preStop lifecycle hook with 15s sleep - Set terminationGracePeriodSeconds to 60 seconds diff --git a/docs/tracked-callsign-behavior.md b/docs/tracked-callsign-behavior.md new file mode 100644 index 0000000..f39184d --- /dev/null +++ b/docs/tracked-callsign-behavior.md @@ -0,0 +1,81 @@ +# Tracked Callsign Behavior Documentation + +## Overview + +When viewing a specific callsign at the `/:callsign` route (e.g., `/KE0GB`), the APRS.me application displays the last received packet from that callsign **regardless of age**. + +## Implementation Details + +### 1. Route Handling +- Route defined in `router.ex`: `live "/:callsign", MapLive.Index, :index` +- The callsign is extracted from the URL and set as `tracked_callsign` in the LiveView socket + +### 2. Latest Packet Retrieval +- `Packets.get_latest_packet_for_callsign/1` fetches the most recent packet for a callsign +- Uses `QueryBuilder.callsign_history/2` with only a limit parameter (no time filtering) +- **No time restrictions** are applied when fetching the latest packet + +### 3. Map Centering +- If the tracked callsign has a known position, the map centers on that location at zoom level 12 +- Handled by `Navigation.handle_callsign_tracking/4` in the MapLive.Navigation module + +### 4. Packet Display Logic +The key function ensuring old packets are always displayed is `filter_packets_by_time_and_bounds_with_tracked/5` in MapLive.Index: + +```elixir +defp filter_packets_by_time_and_bounds_with_tracked( + packets, + bounds, + time_threshold, + tracked_callsign, + tracked_latest_packet + ) do + filtered = filter_packets_by_time_and_bounds(packets, bounds, time_threshold) + + # Always include the tracked callsign's latest packet if we have one + if tracked_callsign != "" and tracked_latest_packet do + key = get_callsign_key(tracked_latest_packet) + Map.put(filtered, key, tracked_latest_packet) + else + filtered + end +end +``` + +This function: +1. First filters packets by time threshold and map bounds +2. Then **always adds** the tracked callsign's latest packet to the filtered results +3. This ensures the tracked callsign is visible even if their last position is older than configured time windows + +### 5. Trail Duration Independence +- Even with the shortest trail duration (1 hour), the tracked callsign's latest packet is displayed +- The trail duration setting only affects which other packets are shown, not the tracked callsign + +## User Experience + +When a user navigates to `/:callsign`: +1. The callsign appears in the search box +2. A "Tracking: CALLSIGN" indicator shows below the search +3. The map centers on the callsign's last known position (if available) +4. The callsign's latest packet is displayed on the map, no matter how old +5. Only packets from that callsign are shown (real-time filtering applies) + +## Summary + +The system is correctly implemented to **always show the last received packet** from a tracked callsign, regardless of: +- How old the packet is (could be days, weeks, or months old) +- The selected trail duration setting +- The historical data load setting +- Whether the packet is within the current map bounds +- **Whether the packet has position data or not** (as of 2025-07-30) + +This ensures users can always find and view the last activity of any callsign they're tracking, including: +- Position packets with lat/lon coordinates +- Status updates without position +- Messages and telemetry data +- Weather reports +- Any other APRS packet types + +## Fix Applied (2025-07-30) + +The `get_recent_packets` function was modified to not filter by `has_position == true` when a specific callsign is being tracked. This ensures that all packet types are visible when viewing a callsign's activity, not just those with position data. \ No newline at end of file diff --git a/lib/aprsme/encoding_utils.ex b/lib/aprsme/encoding_utils.ex index 433f4b3..a49c768 100644 --- a/lib/aprsme/encoding_utils.ex +++ b/lib/aprsme/encoding_utils.ex @@ -56,8 +56,6 @@ defmodule Aprsme.EncodingUtils do def to_float(value) when is_integer(value) do if value >= -9.0e15 and value <= 9.0e15 do value * 1.0 - else - nil end end @@ -77,7 +75,6 @@ defmodule Aprsme.EncodingUtils do def to_float(_), do: nil - @doc """ Converts various types to Decimal for database storage. diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex index 098bc97..c8991e5 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -448,12 +448,21 @@ defmodule Aprsme.Packets do limit = Map.get(opts, :limit, 200) offset = Map.get(opts, :offset, 0) - # Build base query with time and position filters + # Build base query with time filter + # Only filter by position if not tracking a specific callsign base_query = - from(p in Packet, - where: p.has_position == true, - where: p.received_at >= ^time_ago - ) + if Map.has_key?(opts, :callsign) and opts[:callsign] != nil and opts[:callsign] != "" do + # When tracking a callsign, show all their packets regardless of position + from(p in Packet, + where: p.received_at >= ^time_ago + ) + else + # For general map view, only show packets with positions + from(p in Packet, + where: p.has_position == true, + where: p.received_at >= ^time_ago + ) + end # Apply spatial and other filters BEFORE limiting # This ensures we get the most recent packets within the specified bounds diff --git a/test/aprsme_web/live/map_live/tracked_callsign_old_packet_test.exs b/test/aprsme_web/live/map_live/tracked_callsign_old_packet_test.exs new file mode 100644 index 0000000..811d517 --- /dev/null +++ b/test/aprsme_web/live/map_live/tracked_callsign_old_packet_test.exs @@ -0,0 +1,126 @@ +defmodule AprsmeWeb.MapLive.TrackedCallsignOldPacketTest do + use AprsmeWeb.ConnCase + + import Aprsme.PacketsFixtures + import Phoenix.LiveViewTest + + describe "tracked callsign shows last packet regardless of age" do + test "displays old packet for tracked callsign", %{conn: conn} do + # Create a packet that's 30 days old + thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 24 * 60 * 60, :second) + + old_packet = + packet_fixture(%{ + sender: "TEST-OLD", + lat: Decimal.new("35.1234"), + lon: Decimal.new("-97.5678"), + raw_packet: "TEST-OLD>APRS:!3507.40N/09734.07W>Test old packet", + received_at: thirty_days_ago, + has_position: true + }) + + # Navigate to the tracked callsign URL + {:ok, view, _html} = live(conn, "/TEST-OLD") + + # Wait for map to be ready + send(view.pid, %{ + event: "map_ready", + data: %{ + center: %{lat: 35.0, lng: -97.0}, + zoom: 10, + bounds: %{ + north: 36.0, + south: 34.0, + east: -96.0, + west: -98.0 + } + } + }) + + # Allow time for the view to process + :timer.sleep(100) + + # Check that the tracked callsign is set + assert render(view) =~ "TEST-OLD" + + # The packet should be in the view's assigns + # Since filter_packets_by_time_and_bounds_with_tracked always includes + # the tracked callsign's latest packet, it should be present + state = :sys.get_state(view.pid) + socket = elem(state, 1).socket + + # Verify the tracked callsign is set + assert socket.assigns.tracked_callsign == "TEST-OLD" + + # Verify the tracked latest packet is the old packet + assert socket.assigns.tracked_latest_packet.id == old_packet.id + assert socket.assigns.tracked_latest_packet.sender == "TEST-OLD" + + # Cleanup handled by test transaction + end + + test "includes old packet even with 1-hour trail duration", %{conn: conn} do + # Create a packet that's 48 hours old + two_days_ago = DateTime.add(DateTime.utc_now(), -48 * 60 * 60, :second) + + old_packet = + packet_fixture(%{ + sender: "OLD-STATION", + lat: Decimal.new("40.7128"), + lon: Decimal.new("-74.0060"), + raw_packet: "OLD-STATION>APRS:!4042.77N/07400.36W>Old position", + received_at: two_days_ago, + has_position: true + }) + + # Navigate to the tracked callsign URL + {:ok, view, _html} = live(conn, "/OLD-STATION") + + # Set trail duration to 1 hour (shortest option) + send(view.pid, {:update_trail_duration, 1}) + + # Wait for map to be ready + send(view.pid, %{ + event: "map_ready", + data: %{ + center: %{lat: 40.7, lng: -74.0}, + zoom: 10, + bounds: %{ + north: 41.0, + south: 40.0, + east: -73.0, + west: -75.0 + } + } + }) + + :timer.sleep(100) + + # The old packet should still be tracked despite 1-hour trail setting + state = :sys.get_state(view.pid) + socket = elem(state, 1).socket + + assert socket.assigns.tracked_callsign == "OLD-STATION" + # 1 hour trail duration + assert socket.assigns.packet_age_threshold == 1 + assert socket.assigns.tracked_latest_packet.id == old_packet.id + + # Cleanup handled by test transaction + end + + test "shows no packet message when callsign has no packets", %{conn: conn} do + # Navigate to a callsign that doesn't exist + {:ok, view, _html} = live(conn, "/NOPACKETS-1") + + # Check that the tracked callsign is set + assert render(view) =~ "NOPACKETS-1" + + # But there should be no packet data + state = :sys.get_state(view.pid) + socket = elem(state, 1).socket + + assert socket.assigns.tracked_callsign == "NOPACKETS-1" + assert socket.assigns.tracked_latest_packet == nil + end + end +end