diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 0896690..fc0fdb8 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -461,6 +461,26 @@ const WeatherSat = (function() { return `${m}:${s.toString().padStart(2, '0')}`; } + /** + * Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z). + */ + function parsePassDate(value) { + if (!value || typeof value !== 'string') return null; + + let parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + + // Backward-compatible cleanup for accidentally double-suffixed UTC timestamps. + parsed = new Date(value.replace(/\+00:00Z$/, 'Z')); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + + return null; + } + /** * Load pass predictions (with trajectory + ground track) */ @@ -553,13 +573,15 @@ const WeatherSat = (function() { const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; const timeStr = pass.startTime || '--'; const now = new Date(); - const passStart = new Date(pass.startTimeISO); - const diffMs = passStart - now; - const diffMins = Math.floor(diffMs / 60000); + const passStart = parsePassDate(pass.startTimeISO); + const diffMs = passStart ? passStart - now : NaN; + const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; const isSelected = idx === selectedPassIndex; - let countdown = ''; - if (diffMs < 0) { + let countdown = '--'; + if (!Number.isFinite(diffMs)) { + countdown = '--'; + } else if (diffMs < 0) { countdown = 'NOW'; } else if (diffMins < 60) { countdown = `in ${diffMins}m`; @@ -828,8 +850,11 @@ const WeatherSat = (function() { let isActive = false; for (const pass of passes) { - const start = new Date(pass.startTimeISO); - const end = new Date(pass.endTimeISO); + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) { + continue; + } if (end > now) { nextPass = pass; isActive = start <= now; @@ -858,7 +883,19 @@ const WeatherSat = (function() { return; } - const target = new Date(nextPass.startTimeISO); + const target = parsePassDate(nextPass.startTimeISO); + if (!target) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'Invalid pass time'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } let diffMs = target - now; if (isActive) { @@ -918,8 +955,9 @@ const WeatherSat = (function() { const dayMs = 24 * 60 * 60 * 1000; passList.forEach((pass, idx) => { - const start = new Date(pass.startTimeISO); - const end = new Date(pass.endTimeISO); + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) return; const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); diff --git a/tests/test_weather_sat_predict.py b/tests/test_weather_sat_predict.py index 97c2e29..50220f2 100644 --- a/tests/test_weather_sat_predict.py +++ b/tests/test_weather_sat_predict.py @@ -7,10 +7,10 @@ and ground track generation. from __future__ import annotations from datetime import datetime, timezone, timedelta -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest -from utils.weather_sat_predict import predict_passes +from utils.weather_sat_predict import _format_utc_iso, predict_passes class TestPredictPasses: @@ -673,3 +673,20 @@ class TestPassDataStructure: with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}): with pytest.raises((ImportError, AttributeError)): predict_passes(lat=51.5, lon=-0.1) + + +class TestTimestampFormatting: + """Tests for UTC timestamp serialization helpers.""" + + def test_format_utc_iso_from_aware_datetime(self): + """Aware UTC datetimes should not get a duplicate UTC suffix.""" + dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + value = _format_utc_iso(dt) + assert value == '2024-01-01T12:00:00Z' + assert '+00:00Z' not in value + + def test_format_utc_iso_from_naive_datetime(self): + """Naive datetimes should be treated as UTC and serialized consistently.""" + dt = datetime(2024, 1, 1, 12, 0, 0) + value = _format_utc_iso(dt) + assert value == '2024-01-01T12:00:00Z' diff --git a/utils/weather_sat_predict.py b/utils/weather_sat_predict.py index 88dfd32..de8f538 100644 --- a/utils/weather_sat_predict.py +++ b/utils/weather_sat_predict.py @@ -14,6 +14,15 @@ from utils.weather_sat import WEATHER_SATELLITES logger = get_logger('intercept.weather_sat_predict') +def _format_utc_iso(dt: datetime.datetime) -> str: + """Return an ISO8601 UTC timestamp with a single timezone designator.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + else: + dt = dt.astimezone(datetime.timezone.utc) + return dt.isoformat().replace('+00:00', 'Z') + + def predict_passes( lat: float, lon: float, @@ -38,8 +47,9 @@ def predict_passes( Raises: ImportError: If skyfield is not installed. """ - from skyfield.api import load, wgs84, EarthSatellite from skyfield.almanac import find_discrete + from skyfield.api import EarthSatellite, load, wgs84 + from data.satellites import TLE_SATELLITES # Use live TLE cache from satellite module if available (refreshed from CelesTrak) @@ -100,8 +110,10 @@ def predict_passes( i += 1 continue + rise_dt = rise_time.utc_datetime() + set_dt = set_time.utc_datetime() duration_seconds = ( - set_time.utc_datetime() - rise_time.utc_datetime() + set_dt - rise_dt ).total_seconds() duration_minutes = round(duration_seconds / 60, 1) @@ -141,14 +153,14 @@ def predict_passes( _, set_az, _ = set_topo.altaz() pass_data: dict[str, Any] = { - 'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M%S')}", + 'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}", 'satellite': sat_key, 'name': sat_info['name'], 'frequency': sat_info['frequency'], 'mode': sat_info['mode'], - 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), - 'startTimeISO': rise_time.utc_datetime().isoformat() + 'Z', - 'endTimeISO': set_time.utc_datetime().isoformat() + 'Z', + 'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'), + 'startTimeISO': _format_utc_iso(rise_dt), + 'endTimeISO': _format_utc_iso(set_dt), 'maxEl': round(max_el, 1), 'maxElAz': round(max_el_az, 1), 'riseAz': round(rise_az.degrees, 1),