/** * OpenHamClock - Main Application Component * Amateur Radio Dashboard v3.7.0 */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; // Components import { Header, WorldMap, DXClusterPanel, POTAPanel, ContestPanel, SettingsPanel, DXFilterManager, SolarPanel, PropagationPanel, DXpeditionPanel } from './components'; // Hooks import { useSpaceWeather, useBandConditions, useDXCluster, useDXPaths, usePOTASpots, useContests, useLocalWeather, usePropagation, useMySpots, useDXpeditions, useSatellites, useSolarIndices } from './hooks'; // Utils import { loadConfig, saveConfig, applyTheme, fetchServerConfig, calculateGridSquare, calculateSunTimes } from './utils'; const App = () => { // Configuration state - initially use defaults, then load from server const [config, setConfig] = useState(loadConfig); const [configLoaded, setConfigLoaded] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); const [startTime] = useState(Date.now()); const [uptime, setUptime] = useState('0d 0h 0m'); // Load server configuration on startup (only matters for first-time users) useEffect(() => { const initConfig = async () => { // Fetch server config (provides defaults for new users without localStorage) await fetchServerConfig(); // Load config - localStorage takes priority over server config const loadedConfig = loadConfig(); setConfig(loadedConfig); setConfigLoaded(true); // Only show settings if user has no saved config AND no valid callsign // This prevents the popup from appearing every refresh const hasLocalStorage = localStorage.getItem('openhamclock_config'); if (!hasLocalStorage && loadedConfig.callsign === 'N0CALL') { setShowSettings(true); } }; initConfig(); }, []); // DX Location with localStorage persistence const [dxLocation, setDxLocation] = useState(() => { try { const stored = localStorage.getItem('openhamclock_dxLocation'); if (stored) { const parsed = JSON.parse(stored); if (parsed.lat && parsed.lon) return parsed; } } catch (e) {} return config.defaultDX; }); useEffect(() => { try { localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation)); } catch (e) {} }, [dxLocation]); // UI state const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Map layer visibility const [mapLayers, setMapLayers] = useState(() => { try { const stored = localStorage.getItem('openhamclock_mapLayers'); const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; } }); useEffect(() => { try { localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers)); } catch (e) {} }, [mapLayers]); const [hoveredSpot, setHoveredSpot] = useState(null); const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []); const toggleDXLabels = useCallback(() => setMapLayers(prev => ({ ...prev, showDXLabels: !prev.showDXLabels })), []); const togglePOTA = useCallback(() => setMapLayers(prev => ({ ...prev, showPOTA: !prev.showPOTA })), []); const toggleSatellites = useCallback(() => setMapLayers(prev => ({ ...prev, showSatellites: !prev.showSatellites })), []); // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { try { return localStorage.getItem('openhamclock_use12Hour') === 'true'; } catch (e) { return false; } }); useEffect(() => { try { localStorage.setItem('openhamclock_use12Hour', use12Hour.toString()); } catch (e) {} }, [use12Hour]); const handleTimeFormatToggle = useCallback(() => setUse12Hour(prev => !prev), []); // Fullscreen const handleFullscreenToggle = useCallback(() => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {}); } else { document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {}); } }, []); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', handler); return () => document.removeEventListener('fullscreenchange', handler); }, []); useEffect(() => { applyTheme(config.theme || 'dark'); }, []); // Config save handler - persists to localStorage const handleSaveConfig = (newConfig) => { setConfig(newConfig); saveConfig(newConfig); applyTheme(newConfig.theme || 'dark'); console.log('[Config] Saved to localStorage:', newConfig.callsign); }; // Data hooks const spaceWeather = useSpaceWeather(); const bandConditions = useBandConditions(spaceWeather.data); const solarIndices = useSolarIndices(); const potaSpots = usePOTASpots(); // DX Filters const [dxFilters, setDxFilters] = useState(() => { try { const stored = localStorage.getItem('openhamclock_dxFilters'); return stored ? JSON.parse(stored) : {}; } catch (e) { return {}; } }); useEffect(() => { try { localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters)); } catch (e) {} }, [dxFilters]); const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); const contests = useContests(); const propagation = usePropagation(config.location, dxLocation); const mySpots = useMySpots(config.callsign); const satellites = useSatellites(config.location); const localWeather = useLocalWeather(config.location); // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); const deSunTimes = useMemo(() => calculateSunTimes(config.location.lat, config.location.lon, currentTime), [config.location, currentTime]); const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]); // Time update useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); const elapsed = Date.now() - startTime; const d = Math.floor(elapsed / 86400000); const h = Math.floor((elapsed % 86400000) / 3600000); const m = Math.floor((elapsed % 3600000) / 60000); setUptime(`${d}d ${h}h ${m}m`); }, 1000); return () => clearInterval(timer); }, [startTime]); const handleDXChange = useCallback((coords) => { setDxLocation({ lat: coords.lat, lon: coords.lon }); }, []); // Format times const utcTime = currentTime.toISOString().substr(11, 8); const localTime = currentTime.toLocaleTimeString('en-US', { hour12: use12Hour }); const utcDate = currentTime.toISOString().substr(0, 10); const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); // Scale for small screens const [scale, setScale] = useState(1); useEffect(() => { const calculateScale = () => { const minWidth = 1200; const minHeight = 800; const scaleX = window.innerWidth / minWidth; const scaleY = window.innerHeight / minHeight; setScale(Math.min(scaleX, scaleY, 1)); }; calculateScale(); window.addEventListener('resize', calculateScale); return () => window.removeEventListener('resize', calculateScale); }, []); return (