diff --git a/Program.cs b/Program.cs index 8021e3c..f6d395d 100644 --- a/Program.cs +++ b/Program.cs @@ -30,6 +30,9 @@ if (enableHttpsRedirection) // Enable routing (required for CORS to work with controllers) app.UseRouting(); +// Static files (for wwwroot assets like CSS, JS, images) +app.UseStaticFiles(); + // CORS middleware - MUST be after UseRouting but before MapControllers app.UseCors(); diff --git a/Services/BomRadarService.cs b/Services/BomRadarService.cs index 4e922d9..6fa2a31 100644 --- a/Services/BomRadarService.cs +++ b/Services/BomRadarService.cs @@ -406,7 +406,16 @@ public class BomRadarService : IBomRadarService, IDisposable } } - semaphore.Release(); + // Safely release semaphore - it may be disposed during application shutdown + try + { + semaphore.Release(); + } + catch (ObjectDisposedException) + { + // Semaphore was disposed during shutdown, ignore + _logger.LogDebug("Semaphore was disposed, skipping release"); + } } } diff --git a/Services/TimeParsingService.cs b/Services/TimeParsingService.cs index cff3573..2bb4dd0 100644 --- a/Services/TimeParsingService.cs +++ b/Services/TimeParsingService.cs @@ -190,20 +190,32 @@ public class TimeParsingService : ITimeParsingService } } - // Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT + // Determine the timezone - map AEST/AEDT based on what the BOM website displays + // Note: The BOM website shows the timezone abbreviation for the location being viewed, + // NOT the browser's configured timezone. The browser timezone only affects JavaScript Date operations. TimeZoneInfo tz = _timeZoneInfo; if (!string.IsNullOrEmpty(timezoneStr)) { - // AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney - // Both Brisbane and Sydney use AEST/AEDT - if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) || - timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase)) + // Map timezone abbreviations to actual timezones: + // - "AEST" = UTC+10 (Australian Eastern Standard Time) + // Used by Brisbane year-round, and by Sydney/Melbourne during winter + // Since both are UTC+10 when showing AEST, we use Brisbane timezone (UTC+10 year-round) + // - "AEDT" = UTC+11 (Australian Eastern Daylight Time) + // Used by Sydney/Melbourne during summer (Oct-Apr), never Brisbane + // We use Sydney timezone which handles the UTC+11 offset correctly + if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase)) { - tz = _timeZoneInfo; // Use configured timezone + // AEST maps to Brisbane timezone (UTC+10) - works for both Brisbane and Sydney in winter + tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane"); + } + else if (timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase)) + { + // AEDT maps to Sydney timezone (UTC+11) - only appears in summer for Sydney/Melbourne + tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney"); } else { - // Try to find the timezone, fallback to configured + // Try to find the timezone by name, fallback to configured browser timezone try { tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr); diff --git a/Views/RadarTest/Index.cshtml b/Views/RadarTest/Index.cshtml index ddda89b..17687d2 100644 --- a/Views/RadarTest/Index.cshtml +++ b/Views/RadarTest/Index.cshtml @@ -11,707 +11,7 @@ @ViewData["Title"] - +
@@ -889,1446 +189,9 @@
+ - diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh index 4324c86..ff162fe 100644 --- a/scripts/docker-start.sh +++ b/scripts/docker-start.sh @@ -1,29 +1,92 @@ #!/bin/bash set -e +# Function to cleanup stale Xvfb processes and lock files +cleanup_xvfb() { + # Kill any stale Xvfb processes for display :99 + pkill -f "Xvfb :99" 2>/dev/null || true + sleep 0.5 + + # Remove stale lock files + rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true +} + +# Function to check if Xvfb is actually functional +check_xvfb_working() { + # Check if process exists and is running (not zombie) + local pid=$(pgrep -f "Xvfb :99" | head -n1) + if [ -z "$pid" ]; then + return 1 + fi + + # Check if process is actually running (not a zombie) + if ! kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + return 0 +} + # Function to start Xvfb start_xvfb() { - if ! pgrep -f "Xvfb :99" > /dev/null; then + # Clean up any stale processes/locks first + cleanup_xvfb + + if ! check_xvfb_working; then echo "Starting Xvfb on display :99..." Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 & - sleep 1 - if ! pgrep -f "Xvfb :99" > /dev/null; then + sleep 1.5 + + if check_xvfb_working; then + echo "Xvfb started successfully" + return 0 + else echo "ERROR: Xvfb failed to start" >&2 return 1 fi - echo "Xvfb started successfully" fi + return 0 } # Function to monitor and restart Xvfb if it dies (runs as background process) monitor_xvfb() { + local consecutive_failures=0 + local max_backoff=60 # Maximum backoff in seconds + local backoff_seconds=5 + # Monitor while PID 1 (dotnet after exec) is still running while kill -0 1 2>/dev/null; do - sleep 5 - if ! pgrep -f "Xvfb :99" > /dev/null; then - echo "WARNING: Xvfb process not found, restarting..." >&2 - start_xvfb || true + sleep $backoff_seconds + + if ! check_xvfb_working; then + consecutive_failures=$((consecutive_failures + 1)) + + if [ $consecutive_failures -eq 1 ]; then + echo "WARNING: Xvfb not working, attempting restart..." >&2 + elif [ $consecutive_failures -le 3 ]; then + echo "WARNING: Xvfb restart attempt $consecutive_failures..." >&2 + elif [ $((consecutive_failures % 10)) -eq 0 ]; then + echo "ERROR: Xvfb has failed to start $consecutive_failures times. Display may be unavailable." >&2 + fi + + if start_xvfb; then + consecutive_failures=0 + backoff_seconds=5 + else + # Exponential backoff, capped at max_backoff + backoff_seconds=$((backoff_seconds * 2)) + if [ $backoff_seconds -gt $max_backoff ]; then + backoff_seconds=$max_backoff + fi + fi + else + # Xvfb is working, reset failure counter + if [ $consecutive_failures -gt 0 ]; then + echo "Xvfb is now working" >&2 + consecutive_failures=0 + backoff_seconds=5 + fi fi done } @@ -31,7 +94,7 @@ monitor_xvfb() { # Cleanup function cleanup() { echo "Shutting down Xvfb..." - pkill -f "Xvfb :99" || true + cleanup_xvfb } # Set up signal handlers for cleanup diff --git a/wwwroot/css/radar-test/components.css b/wwwroot/css/radar-test/components.css new file mode 100644 index 0000000..7386477 --- /dev/null +++ b/wwwroot/css/radar-test/components.css @@ -0,0 +1,288 @@ +.info-section { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + background: #f8f9fa; + border-radius: 12px; + padding: 15px; +} + +@media (min-width: 480px) { + .info-section { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 768px) { + .info-section { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + padding: 20px; + } +} + +.radar-section { + background: #f8f9fa; + border-radius: 12px; + padding: 15px; +} + +@media (min-width: 768px) { + .radar-section { + padding: 20px; + } +} + +.radar-image-container { + position: relative; + width: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; + margin-bottom: 15px; + aspect-ratio: 16/9; + min-height: 250px; /* Smaller on mobile */ +} + +@media (min-width: 768px) { + .radar-image-container { + margin-bottom: 20px; + min-height: 400px; + } +} + +.radar-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; + image-rendering: crisp-edges; /* Prevent blurry scaling */ +} + +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 1.2em; +} + +.frame-controls { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 15px; +} + +@media (min-width: 768px) { + .frame-controls { + gap: 10px; + margin-bottom: 20px; + } +} + +.frame-btn { + padding: 10px 20px; + border: 2px solid #667eea; + background: white; + color: #667eea; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + font-size: 0.9em; +} + +.frame-btn:hover { + background: #667eea; + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.frame-btn.active { + background: #667eea; + color: white; +} + +.frame-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.play-controls { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 15px; + flex-wrap: wrap; +} + +@media (min-width: 768px) { + .play-controls { + gap: 10px; + margin-bottom: 20px; + } +} + +.play-btn { + padding: 12px 20px; + border: none; + background: #667eea; + color: white; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.9em; + transition: all 0.2s; + min-height: 44px; /* Touch target size */ + flex: 1; + min-width: 100px; + white-space: nowrap; +} + +@media (min-width: 480px) { + .play-btn { + flex: 0 1 auto; + min-width: auto; + } +} + +@media (min-width: 768px) { + .play-btn { + padding: 12px 30px; + font-size: 1em; + } +} + +.play-btn:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.play-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.info-card { + background: white; + border-radius: 8px; + padding: 12px; + border-left: 4px solid #667eea; +} + +@media (min-width: 768px) { + .info-card { + padding: 15px; + } +} + +.info-card h3 { + font-size: 0.8em; + text-transform: uppercase; + color: #666; + margin-bottom: 6px; + letter-spacing: 0.5px; +} + +@media (min-width: 768px) { + .info-card h3 { + font-size: 0.9em; + margin-bottom: 8px; + } +} + +.info-card .value { + font-size: 1em; + font-weight: 600; + color: #333; + word-wrap: break-word; +} + +@media (min-width: 768px) { + .info-card .value { + font-size: 1.2em; + } +} + +.status-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 0.85em; + font-weight: 600; + margin-top: 5px; +} + +.status-valid { + background: #d4edda; + color: #155724; +} + +.status-invalid { + background: #f8d7da; + color: #721c24; +} + +.status-updating { + background: #fff3cd; + color: #856404; +} + +.error { + background: #f8d7da; + color: #721c24; + padding: 15px; + border-radius: 8px; + margin: 20px; + border-left: 4px solid #dc3545; + animation: fadeIn 0.3s; +} + +.error.retrying { + background: #fff3cd; + color: #856404; + border-left-color: #ffc107; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.timestamp { + font-size: 0.85em; + color: #666; + margin-top: 5px; +} + +.refresh-indicator { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + background: #28a745; + margin-right: 8px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.frame-info { + text-align: center; + color: #666; + font-size: 0.9em; + margin-top: 10px; +} + diff --git a/wwwroot/css/radar-test/frame-slider.css b/wwwroot/css/radar-test/frame-slider.css new file mode 100644 index 0000000..83df768 --- /dev/null +++ b/wwwroot/css/radar-test/frame-slider.css @@ -0,0 +1,275 @@ +/* Frame Slider Controls */ +.frame-slider-container { + width: 100%; + max-width: 100%; + padding: 15px; + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + border-radius: 12px; + border: 2px solid #e0e0e0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + box-sizing: border-box; + margin-bottom: 15px; +} + +@media (min-width: 768px) { + .frame-slider-container { + padding: 24px; + border-radius: 16px; + margin-bottom: 20px; + } +} + +.frame-slider-wrapper { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 12px; + width: 100%; + flex-wrap: wrap; +} + +@media (min-width: 480px) { + .frame-slider-wrapper { + gap: 6px; + flex-wrap: nowrap; + } +} + +@media (min-width: 768px) { + .frame-slider-wrapper { + gap: 12px; + margin-bottom: 20px; + } +} + +.frame-nav-btn { + padding: 10px 16px; + border: 2px solid #667eea; + background: white; + color: #667eea; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + font-size: 0.9em; + transition: all 0.2s; + min-width: 50px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.frame-nav-btn:hover:not(:disabled) { + background: #667eea; + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.frame-nav-btn:active:not(:disabled) { + transform: translateY(0); +} + +.frame-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f0f0f0; + border-color: #d0d0d0; + color: #999; +} + +.frame-slider { + flex: 1; + min-width: 120px; + height: 10px; + border-radius: 5px; + background: #e0e0e0; + outline: none; + -webkit-appearance: none; + max-width: 100%; + cursor: pointer; + transition: background 0.2s; + width: 100%; + order: 3; /* Put slider in middle on mobile when wrapped */ +} + +@media (min-width: 480px) { + .frame-slider { + min-width: 100px; + order: 0; /* Reset order for larger screens */ + } +} + +@media (min-width: 768px) { + .frame-slider { + min-width: 0; + } +} + +.frame-slider:hover { + background: #d0d0d0; +} + +/* Webkit (Chrome, Safari, Edge) */ +.frame-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + transition: all 0.2s; + border: 3px solid white; +} + +.frame-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); +} + +.frame-slider::-webkit-slider-thumb:active { + transform: scale(1.05); +} + +/* Track fill for webkit */ +.frame-slider::-webkit-slider-runnable-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); +} + +/* Firefox */ +.frame-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + transition: all 0.2s; +} + +.frame-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); +} + +.frame-slider::-moz-range-thumb:active { + transform: scale(1.05); +} + +.frame-slider::-moz-range-track { + height: 10px; + border-radius: 5px; + background: #e0e0e0; +} + +.frame-slider::-moz-range-progress { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); +} + +.frame-info-display { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +@media (min-width: 480px) { + .frame-info-display { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + padding-top: 16px; + } +} + +@media (min-width: 768px) { + .frame-info-display { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} + +.frame-info-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85em; +} + +@media (min-width: 768px) { + .frame-info-item { + gap: 8px; + font-size: 0.95em; + } +} + +.frame-info-label { + color: #667eea; + font-weight: 600; + font-size: 0.8em; + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 60px; +} + +@media (min-width: 768px) { + .frame-info-label { + font-size: 0.9em; + min-width: 70px; + } +} + +.frame-info-value { + color: #333; + font-weight: 700; + font-size: 0.95em; +} + +@media (min-width: 768px) { + .frame-info-value { + font-size: 1.1em; + } +} + +.frame-jump-input { + width: 70px; + padding: 10px 12px; + border: 2px solid #667eea; + border-radius: 8px; + font-size: 0.9em; + text-align: center; + font-weight: 700; + color: #333; + background: white; + transition: all 0.2s; + min-height: 44px; /* Touch target size */ + box-sizing: border-box; +} + +@media (min-width: 768px) { + .frame-jump-input { + padding: 8px 12px; + font-size: 1em; + min-height: auto; + } +} + +.frame-jump-input:focus { + outline: none; + border-color: #764ba2; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15); + background: #f8f9ff; +} + +.frame-jump-input::-webkit-inner-spin-button, +.frame-jump-input::-webkit-outer-spin-button { + opacity: 1; + height: 20px; +} + diff --git a/wwwroot/css/radar-test/layout.css b/wwwroot/css/radar-test/layout.css new file mode 100644 index 0000000..4f2a059 --- /dev/null +++ b/wwwroot/css/radar-test/layout.css @@ -0,0 +1,122 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 10px; + color: #333; +} + +@media (min-width: 768px) { + body { + padding: 20px; + } +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: white; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + overflow: hidden; +} + +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; +} + +@media (min-width: 768px) { + .header { + padding: 30px; + gap: 20px; + } +} + +.header-content { + flex: 1; + text-align: center; + min-width: 0; +} + +.header h1 { + font-size: 1.5em; + margin-bottom: 8px; + font-weight: 700; + word-wrap: break-word; +} + +@media (min-width: 768px) { + .header h1 { + font-size: 2.5em; + margin-bottom: 10px; + } +} + +.header .subtitle { + font-size: 0.9em; + opacity: 0.9; +} + +@media (min-width: 768px) { + .header .subtitle { + font-size: 1.1em; + } +} + +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.settings-header-btn { + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.9em; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; + min-height: 44px; /* Touch target size */ +} + +@media (min-width: 768px) { + .settings-header-btn { + padding: 12px 24px; + font-size: 1em; + gap: 8px; + } +} + +.settings-header-btn:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-2px); +} + +.content { + display: flex; + flex-direction: column; + gap: 15px; + padding: 15px; +} + +@media (min-width: 768px) { + .content { + gap: 20px; + padding: 30px; + } +} + diff --git a/wwwroot/css/radar-test/reset.css b/wwwroot/css/radar-test/reset.css new file mode 100644 index 0000000..1b915e8 --- /dev/null +++ b/wwwroot/css/radar-test/reset.css @@ -0,0 +1,6 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + diff --git a/wwwroot/css/radar-test/styles.css b/wwwroot/css/radar-test/styles.css new file mode 100644 index 0000000..6b95884 --- /dev/null +++ b/wwwroot/css/radar-test/styles.css @@ -0,0 +1,5 @@ +@import url('./reset.css'); +@import url('./layout.css'); +@import url('./components.css'); +@import url('./frame-slider.css'); + diff --git a/wwwroot/js/radar-test/api-client.js b/wwwroot/js/radar-test/api-client.js new file mode 100644 index 0000000..e4d4851 --- /dev/null +++ b/wwwroot/js/radar-test/api-client.js @@ -0,0 +1,382 @@ +// API client for fetching radar data +import { API_BASE } from './config.js'; +import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js'; +import { state } from './state.js'; + +/** + * Fetch cache range information + */ +export async function fetchCacheRange() { + try { + const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, { + signal: AbortSignal.timeout(10000) + }); + + if (!response.ok) { + if (isNetworkErrorResponse(response)) { + throw new Error('Network error'); + } + console.warn('Failed to fetch cache range'); + return { data: null, error: null }; + } + + const data = await response.json(); + state.cacheRangeInfo = data; + + // Update UI + const rangeTextEl = document.getElementById('cache-range-text'); + if (rangeTextEl) { + if (data.totalCacheFolders > 0) { + const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0; + rangeTextEl.innerHTML = ` +
Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}
+
Newest: ${formatDate(data.newestCache.cacheTimestamp)}
+
Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)
+ `; + } else { + rangeTextEl.textContent = 'No cache data available'; + } + } + + return { data: data, error: null }; + } catch (error) { + console.error('Error fetching cache range:', error); + const rangeTextEl = document.getElementById('cache-range-text'); + if (rangeTextEl) { + rangeTextEl.textContent = 'Unable to load cache range'; + } + + if (isNetworkError(error)) { + return { data: null, error: 'network' }; + } + return { data: null, error: null }; + } +} + +/** + * Fetch historical radar data for extended timespan + */ +export async function fetchHistoricalRadar(startTime, endTime) { + try { + let url = `${API_BASE}/timeseries`; + const params = new URLSearchParams(); + if (startTime) params.append('startTime', startTime.toISOString()); + if (endTime) params.append('endTime', endTime.toISOString()); + if (params.toString()) url += '?' + params.toString(); + + const response = await fetch(url, { + signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` })); + + // Handle 400 Bad Request + if (response.status === 400) { + const errorMessage = errorData.message || 'Invalid request'; + let details = ''; + if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) { + const requested = errorData.details.requestedRange; + if (requested.requestedHours && errorData.details.maxHours) { + details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`; + } + } + throw new Error(errorMessage + details); + } + + // Handle 404 Not Found + if (response.status === 404) { + if (errorData.errorCode === 'CACHE_NOT_FOUND') { + triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint); + return { + frames: null, + error: 'location_missing', + cacheStatus: errorData.details || errorData + }; + } else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) { + const availableRange = errorData.details.availableRange; + let rangeMessage = ''; + + if (errorData.suggestions?.suggestedRange) { + const suggested = errorData.suggestions.suggestedRange; + rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`; + } else if (availableRange.oldest && availableRange.newest) { + rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`; + } else if (availableRange.totalCacheFolders > 0) { + rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`; + } + + if (errorData.suggestions?.suggestion) { + rangeMessage += ` ${errorData.suggestions.suggestion}`; + } + + throw new Error(errorData.message + rangeMessage); + } else { + throw new Error(errorData.message || 'No historical data found'); + } + } + + throw new Error(errorData.message || 'Failed to fetch historical radar'); + } + + const data = await response.json(); + state.historicalData = data; + + // Flatten all frames from all cache folders + const allFrames = []; + data.cacheFolders.forEach(cacheFolder => { + cacheFolder.frames.forEach(frame => { + frame.cacheTimestamp = cacheFolder.cacheTimestamp; + frame.observationTime = cacheFolder.observationTime; + frame.cacheFolderName = cacheFolder.cacheFolderName; + allFrames.push(frame); + }); + }); + + // Filter out frames without absoluteObservationTime + const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime); + if (invalidFrames.length > 0) { + console.error('Backend returned frames missing absoluteObservationTime:', { + count: invalidFrames.length, + frames: invalidFrames.map((f, idx) => ({ + index: idx, + frameIndex: f.frameIndex, + cacheFolder: f.cacheFolderName, + imageUrl: f.imageUrl + })) + }); + } + const validFrames = allFrames.filter(frame => frame.absoluteObservationTime); + + // Validate chronological order + let previousTime = null; + const outOfOrderFrames = []; + validFrames.forEach((frame, idx) => { + const currentTime = new Date(frame.absoluteObservationTime).getTime(); + if (previousTime !== null && currentTime < previousTime) { + outOfOrderFrames.push({ + index: idx, + frameIndex: frame.frameIndex, + cacheFolder: frame.cacheFolderName, + absoluteTime: frame.absoluteObservationTime, + previousTime: new Date(previousTime).toISOString(), + timeDiffMinutes: (currentTime - previousTime) / 1000 / 60 + }); + } + previousTime = currentTime; + }); + + if (outOfOrderFrames.length > 0) { + console.error('Backend returned frames out of chronological order:', { + count: outOfOrderFrames.length, + issues: outOfOrderFrames + }); + } + + // Re-index frames sequentially for display + validFrames.forEach((frame, idx) => { + frame.sequentialIndex = idx; + }); + + return { frames: validFrames, error: null }; + } catch (error) { + console.error('Error fetching historical radar:', error); + if (isNetworkError(error)) { + return { frames: null, error: 'network' }; + } + return { frames: [], error: null }; + } +} + +/** + * Fetch metadata separately + */ +export async function fetchMetadata() { + try { + const response = await fetch(`${API_BASE}/metadata`, { + signal: AbortSignal.timeout(5000) + }); + + if (response.ok) { + return await response.json(); + } + } catch (error) { + console.debug('Could not fetch metadata:', error); + } + return null; +} + +/** + * Fetch latest radar data (for latest mode) + */ +export async function fetchLatestRadar() { + const response = await fetch(API_BASE, { + signal: AbortSignal.timeout(10000) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` })); + + if (response.status === 404) { + const errorData = await response.json().catch(() => ({})); + state.lastRefreshTime = new Date(); + + const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint; + triggerBackgroundRefresh(refreshEndpoint); + + const cacheStatusData = createCacheStatusData(errorData); + const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30; + const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`; + + return { + error: 'cache_generating', + cacheStatus: cacheStatusData, + message: message, + refreshEndpoint: refreshEndpoint + }; + } + + throw new Error(error.message || error.error || 'Failed to fetch radar data'); + } + + const data = await response.json(); + data.isExtendedMode = false; + return { data: data, error: null }; +} + +/** + * Main function to fetch radar data (handles both latest and extended modes) + */ +export async function fetchRadarData() { + try { + // Extended mode + if (state.settings.timespan !== 'latest') { + // Fetch cache range first if needed + if (!state.cacheRangeInfo) { + const rangeResult = await fetchCacheRange(); + if (rangeResult.error === 'network') { + throw new Error('Network error - API unavailable'); + } + state.cacheRangeInfo = rangeResult.data; + } + + if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) { + const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`; + triggerBackgroundRefresh(refreshEndpoint); + + // Try to fetch cache status + try { + const statusResponse = await fetch(`${API_BASE}`, { + signal: AbortSignal.timeout(5000) + }); + if (statusResponse.status === 404) { + const errorData = await statusResponse.json().catch(() => ({})); + return { + error: 'no_cache_data', + cacheStatus: createCacheStatusData(errorData), + message: 'No historical cache data available yet. Cache update has been triggered in the background.' + }; + } + } catch (err) { + console.debug('Could not fetch cache status:', err); + } + + return { + error: 'no_cache_data', + message: 'No historical cache data available yet. Cache update has been triggered in the background.' + }; + } + + // Calculate time range + let startTime = null; + let endTime = new Date(); + + if (state.settings.timespan === 'custom') { + const startInput = document.getElementById('start-time-input'); + const endInput = document.getElementById('end-time-input'); + if (startInput && startInput.value) startTime = new Date(startInput.value); + if (endInput && endInput.value) endTime = new Date(endInput.value); + } else { + const hours = parseInt(state.settings.timespan.replace('h', '')) || 1; + startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000)); + } + + // Fetch historical data + const result = await fetchHistoricalRadar(startTime, endTime); + if (result.error === 'network') { + throw new Error('Network error - API unavailable'); + } + + if (result.error === 'location_missing') { + return { + error: 'location_missing', + cacheStatus: createCacheStatusData(result.cacheStatus || {}), + message: 'No cached data found for this location. Cache update has been triggered in background.' + }; + } + + if (!result.frames || result.frames.length === 0) { + return { + error: 'no_frames', + message: 'No frames found for selected timespan. Try a different range or wait for more cache data.' + }; + } + + // Fetch metadata + let metadata = null; + try { + const metadataResponse = await fetch(API_BASE, { + signal: AbortSignal.timeout(5000) + }); + if (metadataResponse.ok) { + const latestData = await metadataResponse.json(); + metadata = { + weatherStation: latestData.weatherStation, + distance: latestData.distance, + observationTime: latestData.observationTime, + cacheExpiresAt: latestData.cacheExpiresAt, + nextUpdateTime: latestData.nextUpdateTime, + cacheIsValid: latestData.cacheIsValid, + isUpdating: latestData.isUpdating + }; + } + } catch (err) { + console.debug('Could not fetch metadata for extended mode:', err); + } + + // Create response object + const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1]; + return { + data: { + frames: result.frames, + lastUpdated: endTime.toISOString(), + observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(), + forecastTime: endTime.toISOString(), + weatherStation: metadata?.weatherStation || null, + distance: metadata?.distance || null, + cacheIsValid: metadata?.cacheIsValid ?? true, + cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(), + isUpdating: metadata?.isUpdating || false, + nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(), + totalFrames: result.frames.length, + isExtendedMode: true + }, + error: null + }; + } else { + // Latest mode + const result = await fetchLatestRadar(); + if (result.error) { + return result; + } + return result; + } + } catch (error) { + console.error('Error fetching radar data:', error); + if (isNetworkError(error)) { + return { error: 'network', originalError: error }; + } + return { error: 'unknown', message: error.message }; + } +} + diff --git a/wwwroot/js/radar-test/app.js b/wwwroot/js/radar-test/app.js new file mode 100644 index 0000000..975d98a --- /dev/null +++ b/wwwroot/js/radar-test/app.js @@ -0,0 +1,203 @@ +// Main application entry point +import { loadSettings, saveSettings, updateSettingsUI, applySettings } from './settings.js'; +import { fetchRadarData } from './api-client.js'; +import { updateUI, updateRelativeTimes } from './ui-updater.js'; +import { showError, showNoFramesMessage, clearError } from './ui-updater.js'; +import { handleApiDown, resetApiRetryState } from './error-handler.js'; +import { buildFrameControls, showFrame, findFrameToShowAfterRefresh } from './frame-navigation.js'; +import { play, pause, togglePlay, previousFrame, nextFrame } from './slideshow.js'; +import { setupKeyboardNavigation } from './keyboard.js'; +import { state } from './state.js'; +import { isNetworkError } from './utils.js'; + +/** + * Settings modal management + */ +function showSettings() { + const modal = document.getElementById('settings-modal'); + if (modal) modal.style.display = 'flex'; +} + +function hideSettings() { + const modal = document.getElementById('settings-modal'); + if (modal) modal.style.display = 'none'; +} + +/** + * Auto-refresh data + */ +async function refreshData() { + // Don't clear error if API is down (keep retry message visible) + if (!state.isApiDown) { + clearError(); + } + + const result = await fetchRadarData(); + + if (result.error) { + if (result.error === 'network') { + handleApiDown(result.originalError || new Error('Network error')); + return; + } + + // Handle other errors + if (result.error === 'cache_generating') { + updateUI(result.cacheStatus); + showNoFramesMessage(result.message, result.refreshEndpoint); + } else if (result.error === 'location_missing' || result.error === 'no_cache_data' || result.error === 'no_frames') { + if (result.cacheStatus) { + updateUI(result.cacheStatus); + } + showNoFramesMessage(result.message, result.refreshEndpoint); + } else { + showError(result.message || 'An error occurred'); + } + return; + } + + if (result.data) { + state.lastRefreshTime = new Date(); + resetApiRetryState(); + updateUI(result.data); + + // Build frame controls after UI update + buildFrameControls(); + + // Find and show appropriate frame + const frameToShow = findFrameToShowAfterRefresh(); + showFrame(frameToShow); + + // Restart auto-refresh if it was stopped + if (!state.refreshInterval && !state.isApiDown) { + state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000); + } + + // Restart play if it was playing before refresh + const wasPlaying = state.isPlaying; + if (wasPlaying && result.data.frames && result.data.frames.length > 0) { + setTimeout(() => { + if (!state.isPlaying) { + play(); + } + }, 100); + } else if (state.settings.autoPlay && !state.isPlaying && result.data.frames && result.data.frames.length > 0) { + // Auto-play on initial load only + setTimeout(() => play(), 500); + } + } +} + +/** + * Initialize application + */ +async function init() { + // Load settings from cookies + loadSettings(); + + // Set up event listeners + const playBtn = document.getElementById('play-btn'); + const prevBtn = document.getElementById('prev-btn'); + const nextBtn = document.getElementById('next-btn'); + + if (playBtn) playBtn.addEventListener('click', togglePlay); + if (prevBtn) prevBtn.addEventListener('click', previousFrame); + if (nextBtn) nextBtn.addEventListener('click', nextFrame); + + // Settings button + const settingsBtn = document.getElementById('settings-btn-header'); + if (settingsBtn) { + settingsBtn.addEventListener('click', showSettings); + } + + // Settings modal handlers + const saveSettingsBtn = document.getElementById('save-settings-btn'); + if (saveSettingsBtn) { + saveSettingsBtn.addEventListener('click', () => { + const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value); + state.settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0; + + const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value); + state.settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30; + state.settings.autoPlay = document.getElementById('auto-play-input').checked; + state.settings.timespan = document.getElementById('timespan-select').value; + + if (state.settings.timespan === 'custom') { + const startInput = document.getElementById('start-time-input'); + const endInput = document.getElementById('end-time-input'); + if (startInput && startInput.value) { + state.settings.customStartTime = startInput.value; + } + if (endInput && endInput.value) { + state.settings.customEndTime = endInput.value; + } + } + + saveSettings(); + hideSettings(); + + // Apply settings and restart refresh interval + applySettings(); + if (state.refreshInterval) { + clearInterval(state.refreshInterval); + state.refreshInterval = null; + } + + // Reload data with new timespan + refreshData(); + }); + } + + // Timespan select change handler + const timespanSelect = document.getElementById('timespan-select'); + if (timespanSelect) { + timespanSelect.addEventListener('change', (e) => { + state.settings.timespan = e.target.value; + const customSection = document.getElementById('custom-range-section'); + if (customSection) { + customSection.style.display = e.target.value === 'custom' ? 'block' : 'none'; + } + }); + } + + // Cancel settings button + const cancelSettingsBtn = document.getElementById('cancel-settings-btn'); + if (cancelSettingsBtn) { + cancelSettingsBtn.addEventListener('click', () => { + updateSettingsUI(); // Reset to current settings + hideSettings(); + }); + } + + // Close button handler + const closeSettingsBtn = document.getElementById('close-settings-btn'); + if (closeSettingsBtn) { + closeSettingsBtn.addEventListener('click', hideSettings); + } + + // Close modal on background click + const settingsModal = document.getElementById('settings-modal'); + if (settingsModal) { + settingsModal.addEventListener('click', (e) => { + if (e.target.id === 'settings-modal') { + hideSettings(); + } + }); + } + + // Setup keyboard navigation + setupKeyboardNavigation(); + + // Initial load + await refreshData(); + + // Auto-refresh based on settings (only if API is up) + if (!state.isApiDown) { + state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000); + } + + // Update relative times every second + setInterval(updateRelativeTimes, 1000); +} + +// Start when page loads +init(); diff --git a/wwwroot/js/radar-test/config.js b/wwwroot/js/radar-test/config.js new file mode 100644 index 0000000..12015cd --- /dev/null +++ b/wwwroot/js/radar-test/config.js @@ -0,0 +1,15 @@ +// Configuration constants +export const MAX_RETRY_ATTEMPTS = 10; +export const RETRY_DELAY_MS = 5000; // 5 seconds between retries + +// Get API base URL from global variable set by Razor view +export const API_BASE = window.API_BASE || ''; + +// Default settings +export const DEFAULT_SETTINGS = { + frameInterval: 2.0, // seconds between frames + refreshInterval: 30, // seconds between API refreshes + autoPlay: true, // auto-play on load + timespan: 'latest' // 'latest', '1h', '3h', '6h', '12h', '24h', 'custom' +}; + diff --git a/wwwroot/js/radar-test/error-handler.js b/wwwroot/js/radar-test/error-handler.js new file mode 100644 index 0000000..03cbe74 --- /dev/null +++ b/wwwroot/js/radar-test/error-handler.js @@ -0,0 +1,68 @@ +// Error handling and API retry logic +import { MAX_RETRY_ATTEMPTS, RETRY_DELAY_MS } from './config.js'; +import { state } from './state.js'; +import { updateApiStatus, showError, clearError } from './ui-updater.js'; +import { fetchRadarData } from './api-client.js'; +import { updateUI } from './ui-updater.js'; + +/** + * Reset API retry state when connection is restored + */ +export function resetApiRetryState() { + if (state.isApiDown) { + state.isApiDown = false; + state.retryAttempts = 0; + clearError(); + if (state.retryInterval) { + clearInterval(state.retryInterval); + state.retryInterval = null; + } + updateApiStatus('connected'); + } +} + +/** + * Handle API being down + */ +export function handleApiDown(error) { + if (!state.isApiDown) { + state.isApiDown = true; + state.retryAttempts = 0; + updateApiStatus('disconnected'); + } + + state.retryAttempts++; + showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`); + + // Stop auto-refresh while retrying + if (state.refreshInterval) { + clearInterval(state.refreshInterval); + state.refreshInterval = null; + } + + // Start retry mechanism + if (!state.retryInterval && state.retryAttempts < MAX_RETRY_ATTEMPTS) { + state.retryInterval = setInterval(async () => { + if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) { + clearInterval(state.retryInterval); + state.retryInterval = null; + showError('API connection failed after multiple attempts. Please check if the service is running.'); + updateApiStatus('failed'); + return; + } + + state.retryAttempts++; + showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`); + + // Try to fetch data + const result = await fetchRadarData(); + if (result.data) { + updateUI(result.data); + } + }, RETRY_DELAY_MS); + } else if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) { + showError('API connection failed after multiple attempts. Please check if the service is running.'); + updateApiStatus('failed'); + } +} + diff --git a/wwwroot/js/radar-test/frame-navigation.js b/wwwroot/js/radar-test/frame-navigation.js new file mode 100644 index 0000000..d678e67 --- /dev/null +++ b/wwwroot/js/radar-test/frame-navigation.js @@ -0,0 +1,305 @@ +// Frame navigation and display +import { formatDate, formatFrameTime, getMinutesAgo, preloadImages } from './utils.js'; +import { state } from './state.js'; + +/** + * Calculate jump amount for frame navigation + * Returns 10% of total frames (min 1, max 50) for extended mode, 0 otherwise + */ +export function getJumpAmount() { + if (!state.isExtendedMode || state.frames.length === 0) { + return 0; + } + return Math.max(1, Math.min(50, Math.round(state.frames.length / 10))); +} + +/** + * Build frame controls HTML and attach event listeners + */ +export function buildFrameControls() { + const controlsEl = document.getElementById('frame-controls'); + if (!controlsEl) return; + + // Calculate jump amount - 10% of total frames, minimum 1, maximum 50 + // Only used in extended mode (timeseries) + const jumpAmount = getJumpAmount(); + + // Build jump buttons HTML only for extended mode + const prevJumpButtonHtml = state.isExtendedMode && jumpAmount > 0 + ? `` + : ''; + const nextJumpButtonHtml = state.isExtendedMode && jumpAmount > 0 + ? `` + : ''; + + controlsEl.innerHTML = ` +
+
+ + ${prevJumpButtonHtml} + + ${nextJumpButtonHtml} + +
+
+
+ Frame: + + / ${state.frames.length - 1} +
+
+ Time: + - +
+
+ Progress: + 0% +
+
+
+ `; + + // Attach event listeners + const firstBtn = document.getElementById('frame-first-btn'); + const prevJumpBtn = document.getElementById('frame-prev-jump-btn'); + const nextJumpBtn = document.getElementById('frame-next-jump-btn'); + const lastBtn = document.getElementById('frame-last-btn'); + const slider = document.getElementById('frame-slider'); + const jumpInput = document.getElementById('frame-jump-input'); + + if (firstBtn) firstBtn.addEventListener('click', () => showFrame(0)); + if (prevJumpBtn && jumpAmount > 0) { + prevJumpBtn.addEventListener('click', () => jumpFrame(-jumpAmount)); + } + if (nextJumpBtn && jumpAmount > 0) { + nextJumpBtn.addEventListener('click', () => jumpFrame(jumpAmount)); + } + if (lastBtn) lastBtn.addEventListener('click', () => showFrame(state.frames.length - 1)); + if (slider) { + slider.addEventListener('input', (e) => updateFrameFromSlider(parseInt(e.target.value))); + } + if (jumpInput) { + jumpInput.addEventListener('change', (e) => jumpToFrame(parseInt(e.target.value))); + jumpInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') jumpToFrame(parseInt(e.target.value)); + }); + } + + // Update initial display + updateFrameSliderInfo(state.currentFrameIndex); + + // Preload all frame images + preloadImages(state.frames); +} + +/** + * Update frame slider info display + */ +export function updateFrameSliderInfo(index) { + if (index < 0 || index >= state.frames.length) return; + + const frame = state.frames[index]; + const progress = Math.round(((index + 1) / state.frames.length) * 100); + + // Update jump input + const jumpInput = document.getElementById('frame-jump-input'); + if (jumpInput) jumpInput.value = index; + + // Update slider + const slider = document.getElementById('frame-slider'); + if (slider) slider.value = index; + + // Update frame number display + const frameNumDisplay = document.getElementById('current-frame-num-display'); + if (frameNumDisplay) frameNumDisplay.textContent = index + 1; + + // Update time display + const timeDisplay = document.getElementById('current-frame-time'); + if (timeDisplay) { + timeDisplay.textContent = formatFrameTime(frame, state.isExtendedMode); + } + + // Update progress + const progressDisplay = document.getElementById('frame-progress'); + if (progressDisplay) progressDisplay.textContent = `${progress}%`; + + // Update navigation buttons + const firstBtn = document.getElementById('frame-first-btn'); + const prevJumpBtn = document.getElementById('frame-prev-jump-btn'); + const nextJumpBtn = document.getElementById('frame-next-jump-btn'); + const lastBtn = document.getElementById('frame-last-btn'); + + if (firstBtn) firstBtn.disabled = index === 0; + if (prevJumpBtn) prevJumpBtn.disabled = index === 0; + if (nextJumpBtn) nextJumpBtn.disabled = index >= state.frames.length - 1; + if (lastBtn) lastBtn.disabled = index >= state.frames.length - 1; +} + +let sliderUpdateTimeout; +/** + * Update frame from slider (with debouncing) + */ +export function updateFrameFromSlider(value) { + clearTimeout(sliderUpdateTimeout); + sliderUpdateTimeout = setTimeout(() => { + showFrame(value); + }, 50); +} + +/** + * Jump to specific frame number + */ +export function jumpToFrame(frameNum) { + const index = Math.max(0, Math.min(state.frames.length - 1, frameNum)); + showFrame(index); +} + +/** + * Jump forward/backward by N frames + */ +export function jumpFrame(offset) { + const newIndex = Math.max(0, Math.min(state.frames.length - 1, state.currentFrameIndex + offset)); + showFrame(newIndex); +} + +/** + * Build alt text for frame image + */ +function buildFrameAltText(frame, index) { + if (state.isExtendedMode) { + const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index; + const timeInfo = frame.absoluteObservationTime + ? formatDate(frame.absoluteObservationTime) + : (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : ''); + return `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`; + } else { + const minutesAgo = getMinutesAgo(frame.absoluteObservationTime); + if (minutesAgo !== null) { + return `Radar frame ${frame.frameIndex} (${minutesAgo} minutes ago)`; + } else { + // Fall back to formatted date if minutes ago calculation fails + const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : ''; + return `Radar frame ${frame.frameIndex}${timeInfo ? ' (' + timeInfo + ')' : ''}`; + } + } +} + +/** + * Build frame info text + */ +function buildFrameInfoText(frame, index) { + if (state.isExtendedMode) { + const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index; + const timeInfo = frame.absoluteObservationTime + ? formatDate(frame.absoluteObservationTime) + : (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : ''); + return `Frame ${frameNum} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`; + } else { + const minutesAgo = getMinutesAgo(frame.absoluteObservationTime); + if (minutesAgo !== null) { + return `Frame ${frame.frameIndex} of ${state.frames.length - 1} • ${minutesAgo} minutes ago`; + } else { + // Fall back to formatted date if minutes ago calculation fails + const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : ''; + return `Frame ${frame.frameIndex} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`; + } + } +} + +/** + * Show specific frame + */ +export function showFrame(index) { + if (index < 0 || index >= state.frames.length) { + console.error(`showFrame: Invalid index ${index}, frames.length = ${state.frames.length}`); + return; + } + + if (!state.frames[index]) { + console.error(`showFrame: Frame at index ${index} is undefined`); + return; + } + + state.currentFrameIndex = index; + const frame = state.frames[index]; + + // Update slider info + updateFrameSliderInfo(index); + + // Update active button state + document.querySelectorAll('.frame-btn').forEach(btn => { + btn.classList.remove('active'); + if (parseInt(btn.getAttribute('data-frame')) === index) { + btn.classList.add('active'); + } + }); + + // Update image + const imgEl = document.getElementById('radar-image'); + const loadingEl = document.getElementById('loading'); + const altText = buildFrameAltText(frame, index); + + // Check if image is already loaded (from preload) + const preloadedImg = new Image(); + preloadedImg.onload = () => { + if (imgEl) { + imgEl.src = frame.imageUrl; + imgEl.alt = altText; + } + if (loadingEl) loadingEl.style.display = 'none'; + if (imgEl) imgEl.style.display = 'block'; + }; + preloadedImg.onerror = () => { + if (imgEl) { + imgEl.src = frame.imageUrl; + imgEl.alt = altText; + } + }; + preloadedImg.src = frame.imageUrl; + + // Update frame info + const frameInfoEl = document.getElementById('frame-info'); + if (frameInfoEl) { + frameInfoEl.textContent = buildFrameInfoText(frame, index); + } +} + +/** + * Find frame to show after refresh (preserves position) + */ +export function findFrameToShowAfterRefresh() { + if (state.frames.length === 0) return 0; + + const previousFrameIndex = state.currentFrameIndex; + if (previousFrameIndex < 0 || !state.radarData?.frames) return 0; + + const previousFrame = state.radarData.frames[previousFrameIndex]; + if (!previousFrame) return 0; + + if (previousFrame.absoluteObservationTime) { + // Find frame with same absolute observation time + const matchingIndex = state.frames.findIndex(f => + f.absoluteObservationTime === previousFrame.absoluteObservationTime + ); + if (matchingIndex >= 0) return matchingIndex; + + // Find closest frame by time + const previousTime = new Date(previousFrame.absoluteObservationTime).getTime(); + let closestIndex = 0; + let closestDiff = Infinity; + state.frames.forEach((f, idx) => { + if (f.absoluteObservationTime) { + const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime); + if (diff < closestDiff) { + closestDiff = diff; + closestIndex = idx; + } + } + }); + return closestIndex; + } + + // Fallback: keep same index if valid + return previousFrameIndex < state.frames.length ? previousFrameIndex : 0; +} + diff --git a/wwwroot/js/radar-test/keyboard.js b/wwwroot/js/radar-test/keyboard.js new file mode 100644 index 0000000..8a49b05 --- /dev/null +++ b/wwwroot/js/radar-test/keyboard.js @@ -0,0 +1,59 @@ +// Keyboard navigation +import { state } from './state.js'; +import { showFrame, jumpFrame, getJumpAmount } from './frame-navigation.js'; +import { togglePlay } from './slideshow.js'; + +/** + * Setup keyboard navigation + */ +export function setupKeyboardNavigation() { + document.addEventListener('keydown', (e) => { + // Don't handle keyboard shortcuts if user is typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + + // Arrow keys for frame navigation + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + if (e.key === 'ArrowLeft') { + if (e.shiftKey) { + // Shift+Left: jump back by proportional amount (only in extended mode) + const jumpAmount = getJumpAmount(); + if (jumpAmount > 0) { + jumpFrame(-jumpAmount); + } + } else { + showFrame(Math.max(0, state.currentFrameIndex - 1)); // Left: previous frame + } + } else { + if (e.shiftKey) { + // Shift+Right: jump forward by proportional amount (only in extended mode) + const jumpAmount = getJumpAmount(); + if (jumpAmount > 0) { + jumpFrame(jumpAmount); + } + } else { + showFrame(Math.min(state.frames.length - 1, state.currentFrameIndex + 1)); // Right: next frame + } + } + } + + // Home/End for first/last frame + if (e.key === 'Home') { + e.preventDefault(); + showFrame(0); + } + if (e.key === 'End') { + e.preventDefault(); + showFrame(state.frames.length - 1); + } + + // Spacebar for play/pause + if (e.key === ' ' && state.frames.length > 0) { + e.preventDefault(); + togglePlay(); + } + }); +} + diff --git a/wwwroot/js/radar-test/settings.js b/wwwroot/js/radar-test/settings.js new file mode 100644 index 0000000..58d104d --- /dev/null +++ b/wwwroot/js/radar-test/settings.js @@ -0,0 +1,62 @@ +// Settings management +import { getCookie, setCookie } from './utils.js'; +import { state } from './state.js'; +import { fetchCacheRange } from './api-client.js'; +import { pause, play } from './slideshow.js'; + +/** + * Load settings from cookies + */ +export function loadSettings() { + const saved = getCookie('radarTestSettings'); + if (saved) { + state.settings = { ...state.settings, ...saved }; + } + updateSettingsUI(); +} + +/** + * Save settings to cookies + */ +export function saveSettings() { + setCookie('radarTestSettings', state.settings); + updateSettingsUI(); + applySettings(); +} + +/** + * Update settings UI elements + */ +export function updateSettingsUI() { + document.getElementById('frame-interval-input').value = state.settings.frameInterval; + document.getElementById('refresh-interval-input').value = state.settings.refreshInterval; + document.getElementById('auto-play-input').checked = state.settings.autoPlay; + document.getElementById('timespan-select').value = state.settings.timespan || 'latest'; + + // Show/hide custom range section + const customSection = document.getElementById('custom-range-section'); + if (customSection) { + customSection.style.display = state.settings.timespan === 'custom' ? 'block' : 'none'; + } + + // Load cache range info when settings modal opens + fetchCacheRange(); +} + +/** + * Apply settings (restart intervals if needed) + */ +export function applySettings() { + // Restart play interval if playing + if (state.isPlaying) { + pause(); + play(); + } + + // Restart refresh interval (will be handled by refreshData caller) + if (state.refreshInterval) { + clearInterval(state.refreshInterval); + state.refreshInterval = null; + } +} + diff --git a/wwwroot/js/radar-test/slideshow.js b/wwwroot/js/radar-test/slideshow.js new file mode 100644 index 0000000..5999530 --- /dev/null +++ b/wwwroot/js/radar-test/slideshow.js @@ -0,0 +1,67 @@ +// Slideshow controls (play/pause) +import { state } from './state.js'; +import { showFrame } from './frame-navigation.js'; + +/** + * Play slideshow + */ +export function play() { + if (state.frames.length === 0) return; + + state.isPlaying = true; + const playBtn = document.getElementById('play-btn'); + if (playBtn) playBtn.textContent = '⏸ Pause'; + + if (state.playInterval) { + clearInterval(state.playInterval); + } + + state.playInterval = setInterval(() => { + state.currentFrameIndex = (state.currentFrameIndex + 1) % state.frames.length; + showFrame(state.currentFrameIndex); + }, state.settings.frameInterval * 1000); +} + +/** + * Pause slideshow + */ +export function pause() { + state.isPlaying = false; + const playBtn = document.getElementById('play-btn'); + if (playBtn) playBtn.textContent = '▶ Play'; + + if (state.playInterval) { + clearInterval(state.playInterval); + state.playInterval = null; + } +} + +/** + * Toggle play/pause + */ +export function togglePlay() { + if (state.isPlaying) { + pause(); + } else { + play(); + } +} + +/** + * Go to previous frame + */ +export function previousFrame() { + pause(); + const newIndex = state.currentFrameIndex > 0 ? state.currentFrameIndex - 1 : state.frames.length - 1; + showFrame(newIndex); +} + +/** + * Go to next frame + */ +export function nextFrame() { + pause(); + const newIndex = (state.currentFrameIndex + 1) % state.frames.length; + showFrame(newIndex); +} + diff --git a/wwwroot/js/radar-test/state.js b/wwwroot/js/radar-test/state.js new file mode 100644 index 0000000..1cc1990 --- /dev/null +++ b/wwwroot/js/radar-test/state.js @@ -0,0 +1,41 @@ +// Application state management +import { DEFAULT_SETTINGS } from './config.js'; + +// Export a mutable state object - properties can be mutated even though the object binding is read-only +export const state = { + currentFrameIndex: 0, + frames: [], + radarData: null, + playInterval: null, + isPlaying: false, + lastRefreshTime: null, + refreshInterval: null, + nextClientCheckTime: null, + retryInterval: null, + isApiDown: false, + retryAttempts: 0, + cacheRangeInfo: null, + historicalData: null, + isExtendedMode: false, + settings: { ...DEFAULT_SETTINGS } +}; + +// Reset state (for testing/debugging) +export function resetState() { + state.currentFrameIndex = 0; + state.frames = []; + state.radarData = null; + state.playInterval = null; + state.isPlaying = false; + state.lastRefreshTime = null; + state.refreshInterval = null; + state.nextClientCheckTime = null; + state.retryInterval = null; + state.isApiDown = false; + state.retryAttempts = 0; + state.cacheRangeInfo = null; + state.historicalData = null; + state.isExtendedMode = false; + state.settings = { ...DEFAULT_SETTINGS }; +} + diff --git a/wwwroot/js/radar-test/ui-updater.js b/wwwroot/js/radar-test/ui-updater.js new file mode 100644 index 0000000..33cf1f8 --- /dev/null +++ b/wwwroot/js/radar-test/ui-updater.js @@ -0,0 +1,281 @@ +// UI update functions +import { formatDate, getRelativeTime, setElementText, setElementHTML, getElement } from './utils.js'; +import { state } from './state.js'; + +/** + * Update API connection status indicator + */ +export function updateApiStatus(status) { + const refreshStatusEl = getElement('refresh-status'); + const refreshIndicatorEl = getElement('refresh-indicator'); + + if (!refreshStatusEl || !refreshIndicatorEl) return; + + if (status === 'connected') { + refreshStatusEl.textContent = 'Active'; + refreshIndicatorEl.style.background = '#28a745'; + refreshIndicatorEl.style.animation = 'pulse 2s infinite'; + } else if (status === 'disconnected') { + refreshStatusEl.textContent = 'Reconnecting...'; + refreshIndicatorEl.style.background = '#ffc107'; + refreshIndicatorEl.style.animation = 'pulse 1s infinite'; + } else if (status === 'failed') { + refreshStatusEl.textContent = 'Connection Failed'; + refreshIndicatorEl.style.background = '#dc3545'; + refreshIndicatorEl.style.animation = 'none'; + } +} + +/** + * Show error message + */ +export function showError(message) { + const container = getElement('error-container'); + if (!container) return; + + const isRetrying = message.includes('Retrying'); + const errorClass = isRetrying ? 'error retrying' : 'error'; + container.innerHTML = `
${isRetrying ? '⚠️ ' : '❌ Error: '}${message}
`; +} + +/** + * Show graceful "no frames" message + */ +export function showNoFramesMessage(message, refreshEndpoint = null) { + const container = getElement('error-container'); + if (!container) return; + + let html = `
+
⏳ ${message}
`; + + if (refreshEndpoint) { + html += ``; + } + + html += `
`; + container.innerHTML = html; + + // Hide loading and show message in image container + const loadingEl = getElement('loading'); + const imageEl = getElement('radar-image'); + if (loadingEl) loadingEl.textContent = message; + if (imageEl) imageEl.style.display = 'none'; +} + +/** + * Clear error message + */ +export function clearError() { + const container = getElement('error-container'); + if (container) container.innerHTML = ''; +} + +/** + * Update relative times periodically + */ +export function updateRelativeTimes() { + if (!state.radarData) return; + + const expiresEl = getElement('cache-expires-relative'); + const nextUpdateEl = getElement('next-update-relative'); + const updateStatusDetailEl = getElement('update-status-detail'); + const nextCheckEl = getElement('next-client-check'); + const estimationNoteEl = getElement('estimation-note'); + + if (state.radarData.cacheExpiresAt && expiresEl) { + expiresEl.textContent = getRelativeTime(state.radarData.cacheExpiresAt); + } + + if (state.radarData.nextUpdateTime && nextUpdateEl) { + nextUpdateEl.textContent = getRelativeTime(state.radarData.nextUpdateTime); + } + + // Update update status detail with relative time + if (updateStatusDetailEl) { + if (state.radarData.isUpdating && state.radarData.nextUpdateTime) { + const relativeTime = getRelativeTime(state.radarData.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; + } else if (!state.radarData.cacheIsValid && !state.radarData.isUpdating && state.radarData.nextUpdateTime) { + const relativeTime = getRelativeTime(state.radarData.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; + } else if (state.radarData.cacheIsValid) { + updateStatusDetailEl.textContent = 'Cache is valid, no update needed'; + updateStatusDetailEl.title = ''; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } else { + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } + } + + // Update next client check time + if (state.nextClientCheckTime && nextCheckEl) { + nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')'; + } +} + +/** + * Update status cards with cache update information + */ +export function updateStatusForCacheUpdate(retryAfter) { + const statusEl = getElement('cache-status'); + const updateStatusDetailEl = getElement('update-status-detail'); + + if (statusEl) { + statusEl.innerHTML = 'Generating IN PROGRESS'; + } + + if (updateStatusDetailEl) { + if (retryAfter) { + const estimatedTime = new Date(Date.now() + (retryAfter * 1000)); + updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`; + setElementText('next-update', formatDate(estimatedTime.toISOString())); + } else { + updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...'; + setElementText('next-update', '-'); + } + } + + // Set other fields to indicate cache is being generated + setElementText('observation-time', '-'); + setElementText('cache-expires', '-'); + setElementText('weather-station', '-'); + setElementText('distance', '-'); + + // Update last refresh + if (state.lastRefreshTime) { + setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString())); + + // Calculate and display next client check time + state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000)); + const nextCheckEl = getElement('next-client-check'); + if (nextCheckEl) { + nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')'; + } + } +} + +/** + * Update UI with radar data (main update function) + */ +export function updateUI(data) { + // Update cache status even if no frames are available + if (data) { + const statusEl = getElement('cache-status'); + if (statusEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) { + let statusHtml = ''; + if (data.isUpdating) { + statusHtml = 'Updating IN PROGRESS'; + } else if (data.cacheIsValid) { + statusHtml = 'Valid ACTIVE'; + } else { + statusHtml = 'Invalid EXPIRED'; + } + statusEl.innerHTML = statusHtml; + } + + // Update update status detail + const updateStatusDetailEl = getElement('update-status-detail'); + const estimationNoteEl = getElement('estimation-note'); + if (updateStatusDetailEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) { + if (data.isUpdating) { + if (data.nextUpdateTime) { + const relativeTime = getRelativeTime(data.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; + } else { + updateStatusDetailEl.textContent = 'Update in progress, please wait...'; + updateStatusDetailEl.title = 'Cache update is in progress. Completion time will be estimated once progress tracking begins.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } + } else if (data.cacheIsValid) { + updateStatusDetailEl.textContent = 'Cache is valid, no update needed'; + updateStatusDetailEl.title = ''; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } else { + if (data.nextUpdateTime) { + const relativeTime = getRelativeTime(data.nextUpdateTime); + updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')'; + updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'inline'; + } else { + updateStatusDetailEl.textContent = 'Update will be triggered by background service'; + updateStatusDetailEl.title = 'The background cache management service will check and update the cache periodically.'; + if (estimationNoteEl) estimationNoteEl.style.display = 'none'; + } + } + } + + // Update other metadata fields + if (data.observationTime) setElementText('observation-time', formatDate(data.observationTime)); + if (data.cacheExpiresAt) setElementText('cache-expires', formatDate(data.cacheExpiresAt)); + if (data.nextUpdateTime) setElementText('next-update', formatDate(data.nextUpdateTime)); + if (data.weatherStation !== undefined) setElementText('weather-station', data.weatherStation || '-'); + if (data.distance !== undefined) setElementText('distance', data.distance || '-'); + } + + // Handle no frames case + if (!data || !data.frames || data.frames.length === 0) { + const currentStatus = getElement('cache-status')?.innerHTML; + if (!currentStatus || currentStatus === 'Checking...' || currentStatus.trim() === '') { + const statusEl = getElement('cache-status'); + const updateStatusDetailEl = getElement('update-status-detail'); + if (statusEl) statusEl.innerHTML = 'Generating IN PROGRESS'; + if (updateStatusDetailEl) updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...'; + } + + showNoFramesMessage('No radar frames available yet. Cache is being generated in the background. Please wait a moment and refresh.'); + state.frames = []; + const controlsEl = getElement('frame-controls'); + if (controlsEl) controlsEl.innerHTML = ''; + setElementText('play-btn', '▶ Play'); + const playBtn = getElement('play-btn'); + const prevBtn = getElement('prev-btn'); + const nextBtn = getElement('next-btn'); + if (playBtn) playBtn.disabled = true; + if (prevBtn) prevBtn.disabled = true; + if (nextBtn) nextBtn.disabled = true; + setElementText('frame-info', 'Waiting for cache to be generated...'); + return; + } + + // Update state + state.radarData = data; + state.isExtendedMode = data.isExtendedMode || false; + + // Enable controls + const playBtn = getElement('play-btn'); + const prevBtn = getElement('prev-btn'); + const nextBtn = getElement('next-btn'); + if (playBtn) playBtn.disabled = false; + if (prevBtn) prevBtn.disabled = false; + if (nextBtn) nextBtn.disabled = false; + + // Sort frames appropriately + if (state.isExtendedMode) { + state.frames = data.frames; + } else { + state.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex); + } + + // Update metadata fields (redundant updates removed since already done above) + // Update last refresh + if (state.lastRefreshTime) { + setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString())); + + state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000)); + const nextCheckEl = getElement('next-client-check'); + if (nextCheckEl) { + nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')'; + } + } + + // Frame controls will be built by the caller (to avoid circular dependencies) +} + diff --git a/wwwroot/js/radar-test/utils.js b/wwwroot/js/radar-test/utils.js new file mode 100644 index 0000000..44fa376 --- /dev/null +++ b/wwwroot/js/radar-test/utils.js @@ -0,0 +1,184 @@ +// Utility functions + +// Cookie management +export function setCookie(name, value, days = 365) { + const expires = new Date(); + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); + document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`; +} + +export function getCookie(name) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) { + try { + return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length))); + } catch (e) { + return null; + } + } + } + return null; +} + +// Date formatting utilities +export function formatDate(dateString) { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleString('en-AU', { + timeZone: 'Australia/Brisbane', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +export function getRelativeTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffMs = date - now; + const diffMins = Math.floor(diffMs / 60000); + const diffSecs = Math.floor((diffMs % 60000) / 1000); + + if (diffMs < 0) { + return Math.abs(diffMins) + ' minute(s) ago'; + } else if (diffMins > 0) { + return 'in ' + diffMins + ' minute(s)'; + } else { + return 'in ' + diffSecs + ' second(s)'; + } +} + +export function getMinutesAgo(dateString) { + if (!dateString) return null; + + // Handle both string and Date object inputs + let date; + if (typeof dateString === 'string') { + date = new Date(dateString); + } else if (dateString instanceof Date) { + date = dateString; + } else { + console.warn('getMinutesAgo: Expected string or Date, got:', typeof dateString, dateString); + return null; + } + + // Check if date is valid + if (isNaN(date.getTime())) { + console.warn('getMinutesAgo: Invalid date:', dateString, 'parsed as:', date); + return null; + } + + // Calculate difference in milliseconds + // Date.now() is UTC-based, date.getTime() is UTC-based, so this should be correct + const now = Date.now(); + const dateTime = date.getTime(); + const diffMs = now - dateTime; + + // Convert to minutes and round + const minutes = Math.round(diffMs / 60000); + + // If negative (future date), return null + if (minutes < 0) { + console.warn('getMinutesAgo: Future date detected:', dateString, 'parsed as:', date.toISOString()); + return null; + } + + // Safeguard against unreasonably large values (likely parsing errors) + // But don't cap at 60 - radar frames can legitimately be older in some cases + if (minutes > 100000) { // e.g., more than ~69 days ago + console.warn('getMinutesAgo: Unreasonably large minutes value:', minutes, 'for date:', dateString, 'parsed as:', date.toISOString()); + return null; + } + + return minutes; +} + +// Format frame time for display (consolidates duplicate logic) +export function formatFrameTime(frame, isExtendedMode) { + if (!frame.absoluteObservationTime) return '-'; + + if (isExtendedMode) { + return formatDate(frame.absoluteObservationTime); + } else { + const minutesAgo = getMinutesAgo(frame.absoluteObservationTime); + // If minutesAgo is null (invalid date or edge case), fall back to formatted date + if (minutesAgo === null) { + return formatDate(frame.absoluteObservationTime); + } + return `${minutesAgo} min ago`; + } +} + +// Network error detection (consolidates duplicate checks) +export function isNetworkError(error) { + return error.name === 'TypeError' || + error.name === 'AbortError' || + error.message.includes('fetch') || + error.message.includes('network') || + error.message.includes('Failed to fetch') || + error.message === 'Network error'; +} + +export function isNetworkErrorResponse(response) { + return response.status === 0 || response.status >= 500; +} + +// DOM helpers +export function getElement(id) { + return document.getElementById(id); +} + +export function setElementText(id, text) { + const el = getElement(id); + if (el) el.textContent = text; +} + +export function setElementHTML(id, html) { + const el = getElement(id); + if (el) el.innerHTML = html; +} + +// Create cache status data object (consolidates duplicate creation) +export function createCacheStatusData(errorData) { + const details = errorData.details || errorData; + const isUpdating = details.updateTriggered === true || + (errorData.message && errorData.message.includes('in progress')) || + (details.statusMessage && details.statusMessage.includes('in progress')); + + return { + frames: [], + isUpdating: isUpdating, + cacheIsValid: details.cacheIsValid || false, + cacheExpiresAt: details.cacheExpiresAt || null, + nextUpdateTime: details.nextUpdateTime || null + }; +} + +// Trigger background cache refresh (consolidates duplicate calls) +export function triggerBackgroundRefresh(refreshEndpoint) { + if (refreshEndpoint) { + fetch(refreshEndpoint, { + method: 'POST', + signal: AbortSignal.timeout(5000) + }).catch(err => { + console.debug('Background cache refresh trigger failed (non-critical):', err); + }); + } +} + +// Preload all frame images to prevent jiggle +export function preloadImages(frames) { + frames.forEach(frame => { + const img = new Image(); + img.src = frame.imageUrl; + }); +} +