From 10508c2dc6c429d5051af6043be4c1803b2d16a4 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 1 Sep 2025 10:29:35 +0200 Subject: [PATCH] fix: Implement dynamic age calculation for aircraft tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves issue #3 where aircraft age field was always showing 0 seconds. Backend Changes: - Fix MarshalJSON in merger.go to calculate age dynamically using time.Since(a.LastUpdate) - Add comprehensive tests for age calculation in merger_test.go Frontend Changes: - Add calculateAge() utility method to both aircraft-manager.js and ui-manager.js - Update popup content and aircraft table to use client-side age calculation - Implement real-time age updates every second in app.js - Add updateOpenPopupAges() function to refresh popup displays Key Benefits: - Aircraft age now shows actual seconds since last transmission - Real-time updates in UI without requiring new WebSocket data - Proper data staleness indicators for aviation tracking - No additional server load - client-side calculation using last_update timestamp 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/static/js/app.js | 30 +++++++ assets/static/js/modules/aircraft-manager.js | 12 ++- assets/static/js/modules/ui-manager.js | 12 ++- internal/merger/merger.go | 2 +- internal/merger/merger_test.go | 92 ++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 internal/merger/merger_test.go diff --git a/assets/static/js/app.js b/assets/static/js/app.js index 4ba39d3..dc271f4 100644 --- a/assets/static/js/app.js +++ b/assets/static/js/app.js @@ -469,10 +469,40 @@ class SkyView { this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera); } + updateOpenPopupAges() { + // Find any open aircraft popups and update their age displays + if (!this.aircraftManager) return; + + this.aircraftManager.aircraftMarkers.forEach((marker, icao) => { + if (marker.isPopupOpen()) { + const aircraft = this.aircraftManager.aircraftData.get(icao); + if (aircraft) { + // Refresh the popup content with current age + marker.setPopupContent(this.aircraftManager.createPopupContent(aircraft)); + + // Re-enhance callsign display for the updated popup + const popupElement = marker.getPopup().getElement(); + if (popupElement) { + this.aircraftManager.enhanceCallsignDisplay(popupElement); + } + } + } + }); + } + startPeriodicTasks() { // Update clocks every second setInterval(() => this.uiManager.updateClocks(), 1000); + // Update aircraft ages and refresh displays every second + setInterval(() => { + // Update aircraft table to show current ages + this.uiManager.updateAircraftTable(); + + // Update any open aircraft popups with current ages + this.updateOpenPopupAges(); + }, 1000); + // Update charts every 10 seconds setInterval(() => this.updateCharts(), 10000); diff --git a/assets/static/js/modules/aircraft-manager.js b/assets/static/js/modules/aircraft-manager.js index d2bbfdd..81de56d 100644 --- a/assets/static/js/modules/aircraft-manager.js +++ b/assets/static/js/modules/aircraft-manager.js @@ -491,7 +491,7 @@ export class AircraftManager { Messages: ${aircraft.TotalMessages || 0}
- Age: ${aircraft.Age ? aircraft.Age.toFixed(1) : '0'}s + Age: ${this.calculateAge(aircraft).toFixed(1)}s
@@ -513,6 +513,16 @@ export class AircraftManager { return minDistance === Infinity ? null : minDistance; } + calculateAge(aircraft) { + if (!aircraft.last_update) return 0; + + const lastUpdate = new Date(aircraft.last_update); + const now = new Date(); + const ageMs = now.getTime() - lastUpdate.getTime(); + + return Math.max(0, ageMs / 1000); // Return age in seconds, minimum 0 + } + // Enhance callsign display in popup after it's created async enhanceCallsignDisplay(popupElement) { if (!this.callsignManager) return; diff --git a/assets/static/js/modules/ui-manager.js b/assets/static/js/modules/ui-manager.js index b891096..d72c8e7 100644 --- a/assets/static/js/modules/ui-manager.js +++ b/assets/static/js/modules/ui-manager.js @@ -129,7 +129,7 @@ export class UIManager { ${aircraft.Track || '-'}° ${sources} ${bestSignal ? bestSignal.toFixed(1) : '-'} - ${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s + ${this.calculateAge(aircraft).toFixed(0)}s `; row.addEventListener('click', () => { @@ -161,6 +161,16 @@ export class UIManager { return 'commercial'; } + calculateAge(aircraft) { + if (!aircraft.last_update) return 0; + + const lastUpdate = new Date(aircraft.last_update); + const now = new Date(); + const ageMs = now.getTime() - lastUpdate.getTime(); + + return Math.max(0, ageMs / 1000); // Return age in seconds, minimum 0 + } + getBestSignalFromSources(sources) { if (!sources) return null; let bestSignal = -999; diff --git a/internal/merger/merger.go b/internal/merger/merger.go index dd049f1..0d907ce 100644 --- a/internal/merger/merger.go +++ b/internal/merger/merger.go @@ -202,7 +202,7 @@ func (a *AircraftState) MarshalJSON() ([]byte, error) { SpeedHistory: a.SpeedHistory, Distance: a.Distance, Bearing: a.Bearing, - Age: a.Age, + Age: time.Since(a.LastUpdate).Seconds(), MLATSources: a.MLATSources, PositionSource: a.PositionSource, UpdateRate: a.UpdateRate, diff --git a/internal/merger/merger_test.go b/internal/merger/merger_test.go new file mode 100644 index 0000000..bb1c2e0 --- /dev/null +++ b/internal/merger/merger_test.go @@ -0,0 +1,92 @@ +package merger + +import ( + "encoding/json" + "testing" + "time" + + "skyview/internal/modes" +) + +func TestAircraftState_MarshalJSON_AgeCalculation(t *testing.T) { + // Create a test aircraft state with a known LastUpdate time + pastTime := time.Now().Add(-30 * time.Second) // 30 seconds ago + + state := &AircraftState{ + Aircraft: &modes.Aircraft{ + ICAO24: 0x123456, + Callsign: "TEST123", + }, + LastUpdate: pastTime, + } + + // Marshal to JSON + jsonData, err := json.Marshal(state) + if err != nil { + t.Fatalf("Failed to marshal AircraftState: %v", err) + } + + // Parse the JSON to check the age field + var result map[string]interface{} + if err := json.Unmarshal(jsonData, &result); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Check that age is not 0 and is approximately 30 seconds + age, ok := result["age"].(float64) + if !ok { + t.Fatal("Age field not found or not a number") + } + + if age < 25 || age > 35 { + t.Errorf("Expected age to be around 30 seconds, got %f", age) + } + + if age == 0 { + t.Error("Age should not be 0 - this indicates the bug is still present") + } + + t.Logf("Aircraft age calculated correctly: %f seconds", age) +} + +func TestAircraftState_MarshalJSON_ZeroAge(t *testing.T) { + // Test with very recent time to ensure age is very small but not exactly 0 + recentTime := time.Now() + + state := &AircraftState{ + Aircraft: &modes.Aircraft{ + ICAO24: 0x123456, + Callsign: "TEST123", + }, + LastUpdate: recentTime, + } + + // Marshal to JSON + jsonData, err := json.Marshal(state) + if err != nil { + t.Fatalf("Failed to marshal AircraftState: %v", err) + } + + // Parse the JSON to check the age field + var result map[string]interface{} + if err := json.Unmarshal(jsonData, &result); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + // Check that age is very small but should not be exactly 0 (unless extremely fast) + age, ok := result["age"].(float64) + if !ok { + t.Fatal("Age field not found or not a number") + } + + if age < 0 { + t.Errorf("Age should not be negative, got %f", age) + } + + // Age should be very small (less than 1 second) but might not be exactly 0 + if age > 1.0 { + t.Errorf("Age should be very small for recent update, got %f", age) + } + + t.Logf("Recent aircraft age calculated correctly: %f seconds", age) +}