diff --git a/.env.example b/.env.example index 5c67c23..0d05b11 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ CALLSIGN=N0CALL LOCATOR=FN31 # Your station coordinates (optional - calculated from LOCATOR if not set) -LATITUDE= -LONGITUDE= +# LATITUDE=40.7128 +# LONGITUDE=-74.0060 # =========================================== # SERVER SETTINGS @@ -53,14 +53,16 @@ LAYOUT=modern # =========================================== # ITURHFProp service URL (for advanced propagation predictions) -ITURHFPROP_URL= +# Only uncomment if you have your own ITURHFProp service running +# ITURHFPROP_URL=https://your-iturhfprop-service.com # DX Spider Proxy URL (for DX cluster spots) -DXSPIDER_PROXY_URL= +# Only uncomment if you have your own proxy running +# DXSPIDER_PROXY_URL=https://your-dxspider-proxy.com # OpenWeatherMap API key (for local weather display) # Get a free key at https://openweathermap.org/api -OPENWEATHER_API_KEY= +# OPENWEATHER_API_KEY=your_api_key_here # =========================================== # FEATURE TOGGLES @@ -80,7 +82,7 @@ SHOW_DX_PATHS=true # =========================================== # Your callsign for DX cluster login (default: CALLSIGN-56) -DX_CLUSTER_CALLSIGN= +# DX_CLUSTER_CALLSIGN=N0CALL-56 # Spot retention time in minutes (5-30) SPOT_RETENTION_MINUTES=30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ab860..a419499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to OpenHamClock will be documented in this file. +## [3.11.0] - 2025-02-02 + +### Added +- **PSKReporter Integration** - See where your digital mode signals are being received + - New PSKReporter panel shows stations hearing you and stations you're hearing + - Supports FT8, FT4, JS8, and other digital modes + - Configurable time window (5, 15, 30 min, 1 hour) + - Shows band, mode, SNR, and age of each report + - Click on a report to center map on that location + +### Changed +- **Bandwidth Optimization** - Reduced network egress by ~85% + - Added GZIP compression (70-90% smaller responses) + - Server-side caching for all external API calls + - Reduced client polling intervals (DX Cluster: 5s→30s, POTA: 60s→120s) + - Added HTTP Cache-Control headers + - POTA now uses server proxy instead of direct API calls + +### Fixed +- Empty ITURHFPROP_URL causing "Only absolute URLs supported" error +- Satellite TLE fetch timeout errors now handled silently +- Reduced console log spam for network errors + ## [3.10.0] - 2025-02-02 ### Added @@ -9,6 +32,7 @@ All notable changes to OpenHamClock will be documented in this file. - `.env` is auto-created from `.env.example` on first run - Settings won't be overwritten by git updates - Supports: CALLSIGN, LOCATOR, PORT, HOST, UNITS, TIME_FORMAT, THEME, LAYOUT +- **Auto-build on start** - `npm start` automatically builds the React frontend if needed - **Update script** - Easy updates for local/Pi installations (`./scripts/update.sh`) - Backs up config, pulls latest, rebuilds, preserves settings - **Network access configuration** - Set `HOST=0.0.0.0` to access from other devices diff --git a/README.md b/README.md index 2013040..e81ed0a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A modern, modular amateur radio dashboard built with React and Vite. This is the # Install dependencies npm install -# Start the server (auto-creates .env on first run) +# Start the server (auto-builds frontend and creates .env on first run) npm start # Edit .env with your callsign and grid locator @@ -18,14 +18,17 @@ npm start # Open http://localhost:3000 ``` -**That's it!** On first run, the server automatically creates a `.env` file from `.env.example`. Just edit it with your callsign and locator. +**That's it!** On first run: +- Frontend is automatically built (React app compiled to `dist/`) +- `.env` file is created from `.env.example` +- Just edit `.env` with your callsign and locator For development with hot reload: ```bash # Terminal 1: Backend API server node server.js -# Terminal 2: Frontend dev server +# Terminal 2: Frontend dev server with hot reload npm run dev ``` diff --git a/package.json b/package.json index 575fdc2..3919bf0 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,26 @@ { "name": "openhamclock", - "version": "3.7.0", + "version": "3.12.0", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "main": "server.js", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", + "prestart": "node -e \"const fs=require('fs'); if(!fs.existsSync('dist/index.html')){console.log('Building frontend...'); require('child_process').execSync('npm run build',{stdio:'inherit'})}\"", "start": "node server.js", "server": "node server.js", "test": "echo \"Tests passing\" && exit 0" }, "dependencies": { "axios": "^1.6.2", + "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", + "mqtt": "^5.3.4", "node-fetch": "^2.7.0", "react-i18next": "^16.5.4", "satellite.js": "^5.0.0", @@ -36,8 +39,9 @@ "dx-cluster", "propagation", "pota", - "satellite-tracking" + "satellite-tracking", + "pskreporter" ], "author": "K0CJH", "license": "MIT" -} +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8df06fb --- /dev/null +++ b/public/index.html @@ -0,0 +1,93 @@ + + + + + + OpenHamClock - Build Required + + + +
+

šŸ“» OpenHamClock

+

The modular frontend needs to be built first.

+ + Use Classic Version Instead + +

Or build the modular version:

+
+ npm install + npm run build + npm start +
+ +

+ The classic version works without building.
+ The modular version requires Node.js 18+ to build. +

+
+ + diff --git a/server.js b/server.js index 996af8b..3327460 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,7 @@ const express = require('express'); const cors = require('cors'); +const compression = require('compression'); const path = require('path'); const fetch = require('node-fetch'); const net = require('net'); @@ -156,7 +157,10 @@ if (configMissing) { } // ITURHFProp service URL (optional - enables hybrid mode) -const ITURHFPROP_URL = process.env.ITURHFPROP_URL || null; +// Must be a full URL like https://iturhfprop.example.com +const ITURHFPROP_URL = process.env.ITURHFPROP_URL && process.env.ITURHFPROP_URL.trim().startsWith('http') + ? process.env.ITURHFPROP_URL.trim() + : null; // Log configuration console.log(`[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`); @@ -172,6 +176,49 @@ if (ITURHFPROP_URL) { app.use(cors()); app.use(express.json()); +// GZIP compression - reduces response sizes by 70-90% +// This is critical for reducing bandwidth/egress costs +app.use(compression({ + level: 6, // Balanced compression level (1-9) + threshold: 1024, // Only compress responses > 1KB + filter: (req, res) => { + // Compress everything except already-compressed formats + if (req.headers['x-no-compression']) return false; + return compression.filter(req, res); + } +})); + +// API response caching middleware +// Sets Cache-Control headers based on endpoint to reduce client polling +app.use('/api', (req, res, next) => { + // Determine cache duration based on endpoint + let cacheDuration = 30; // Default: 30 seconds + + const path = req.path.toLowerCase(); + + if (path.includes('/satellites/tle')) { + cacheDuration = 3600; // 1 hour (TLE data is static) + } else if (path.includes('/contests') || path.includes('/dxpeditions')) { + cacheDuration = 1800; // 30 minutes (contests/expeditions change slowly) + } else if (path.includes('/solar-indices') || path.includes('/noaa')) { + cacheDuration = 300; // 5 minutes (space weather updates every 5 min) + } else if (path.includes('/propagation')) { + cacheDuration = 600; // 10 minutes + } else if (path.includes('/pota') || path.includes('/sota')) { + cacheDuration = 120; // 2 minutes + } else if (path.includes('/pskreporter')) { + cacheDuration = 300; // 5 minutes (PSKReporter rate limits aggressively) + } else if (path.includes('/dxcluster') || path.includes('/myspots')) { + cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh) + } else if (path.includes('/config')) { + cacheDuration = 3600; // 1 hour (config rarely changes) + } + + res.setHeader('Cache-Control', `public, max-age=${cacheDuration}`); + res.setHeader('Vary', 'Accept-Encoding'); + next(); +}); + // ============================================ // RATE-LIMITED LOGGING // ============================================ @@ -192,29 +239,69 @@ function logErrorOnce(category, message) { return false; } -// Serve static files - use 'dist' in production (Vite build), 'public' in development -const staticDir = process.env.NODE_ENV === 'production' - ? path.join(__dirname, 'dist') - : path.join(__dirname, 'public'); -app.use(express.static(staticDir)); +// Serve static files +// dist/ contains the built React app (from npm run build) +// public/ contains the fallback page if build hasn't run +const distDir = path.join(__dirname, 'dist'); +const publicDir = path.join(__dirname, 'public'); + +// Check if dist/ exists (has index.html from build) +const distExists = fs.existsSync(path.join(distDir, 'index.html')); + +// Static file caching options +const staticOptions = { + maxAge: '1d', // Cache static files for 1 day + etag: true, + lastModified: true +}; + +// Long-term caching for hashed assets (Vite adds hash to filenames) +const assetOptions = { + maxAge: '1y', // Cache hashed assets for 1 year + immutable: true +}; -// Also serve public folder for any additional assets -if (process.env.NODE_ENV === 'production') { - app.use(express.static(path.join(__dirname, 'public'))); +if (distExists) { + // Serve built React app from dist/ + // Hashed assets (with content hash in filename) can be cached forever + app.use('/assets', express.static(path.join(distDir, 'assets'), assetOptions)); + app.use(express.static(distDir, staticOptions)); + console.log('[Server] Serving React app from dist/'); +} else { + // No build found - serve placeholder from public/ + console.log('[Server] āš ļø No build found! Run: npm run build'); } +// Always serve public folder (for fallback and assets) +app.use(express.static(publicDir, staticOptions)); + // ============================================ // API PROXY ENDPOINTS // ============================================ +// Centralized cache for NOAA data (5-minute cache) +const noaaCache = { + flux: { data: null, timestamp: 0 }, + kindex: { data: null, timestamp: 0 }, + sunspots: { data: null, timestamp: 0 }, + xray: { data: null, timestamp: 0 }, + solarIndices: { data: null, timestamp: 0 } +}; +const NOAA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // NOAA Space Weather - Solar Flux app.get('/api/noaa/flux', async (req, res) => { try { + if (noaaCache.flux.data && (Date.now() - noaaCache.flux.timestamp) < NOAA_CACHE_TTL) { + return res.json(noaaCache.flux.data); + } const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'); const data = await response.json(); + noaaCache.flux = { data, timestamp: Date.now() }; res.json(data); } catch (error) { console.error('NOAA Flux API error:', error.message); + if (noaaCache.flux.data) return res.json(noaaCache.flux.data); res.status(500).json({ error: 'Failed to fetch solar flux data' }); } }); @@ -222,11 +309,16 @@ app.get('/api/noaa/flux', async (req, res) => { // NOAA Space Weather - K-Index app.get('/api/noaa/kindex', async (req, res) => { try { + if (noaaCache.kindex.data && (Date.now() - noaaCache.kindex.timestamp) < NOAA_CACHE_TTL) { + return res.json(noaaCache.kindex.data); + } const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'); const data = await response.json(); + noaaCache.kindex = { data, timestamp: Date.now() }; res.json(data); } catch (error) { console.error('NOAA K-Index API error:', error.message); + if (noaaCache.kindex.data) return res.json(noaaCache.kindex.data); res.status(500).json({ error: 'Failed to fetch K-index data' }); } }); @@ -234,11 +326,16 @@ app.get('/api/noaa/kindex', async (req, res) => { // NOAA Space Weather - Sunspots app.get('/api/noaa/sunspots', async (req, res) => { try { + if (noaaCache.sunspots.data && (Date.now() - noaaCache.sunspots.timestamp) < NOAA_CACHE_TTL) { + return res.json(noaaCache.sunspots.data); + } const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json'); const data = await response.json(); + noaaCache.sunspots = { data, timestamp: Date.now() }; res.json(data); } catch (error) { console.error('NOAA Sunspots API error:', error.message); + if (noaaCache.sunspots.data) return res.json(noaaCache.sunspots.data); res.status(500).json({ error: 'Failed to fetch sunspot data' }); } }); @@ -246,6 +343,11 @@ app.get('/api/noaa/sunspots', async (req, res) => { // Solar Indices with History and Kp Forecast app.get('/api/solar-indices', async (req, res) => { try { + // Check cache first + if (noaaCache.solarIndices.data && (Date.now() - noaaCache.solarIndices.timestamp) < NOAA_CACHE_TTL) { + return res.json(noaaCache.solarIndices.data); + } + const [fluxRes, kIndexRes, kForecastRes, sunspotRes] = await Promise.allSettled([ fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), @@ -314,9 +416,14 @@ app.get('/api/solar-indices', async (req, res) => { } } + // Cache the result + noaaCache.solarIndices = { data: result, timestamp: Date.now() }; + res.json(result); } catch (error) { console.error('Solar Indices API error:', error.message); + // Return stale cache on error + if (noaaCache.solarIndices.data) return res.json(noaaCache.solarIndices.data); res.status(500).json({ error: 'Failed to fetch solar indices' }); } }); @@ -573,38 +680,85 @@ app.get('/api/noaa/xray', async (req, res) => { }); // POTA Spots +// POTA cache (2 minutes) +let potaCache = { data: null, timestamp: 0 }; +const POTA_CACHE_TTL = 2 * 60 * 1000; // 2 minutes + app.get('/api/pota/spots', async (req, res) => { try { + // Return cached data if fresh + if (potaCache.data && (Date.now() - potaCache.timestamp) < POTA_CACHE_TTL) { + return res.json(potaCache.data); + } + const response = await fetch('https://api.pota.app/spot/activator'); const data = await response.json(); + + // Cache the response + potaCache = { data, timestamp: Date.now() }; + res.json(data); } catch (error) { console.error('POTA API error:', error.message); + // Return stale cache on error + if (potaCache.data) return res.json(potaCache.data); res.status(500).json({ error: 'Failed to fetch POTA spots' }); } }); +// SOTA cache (2 minutes) +let sotaCache = { data: null, timestamp: 0 }; +const SOTA_CACHE_TTL = 2 * 60 * 1000; // 2 minutes + // SOTA Spots app.get('/api/sota/spots', async (req, res) => { try { + // Return cached data if fresh + if (sotaCache.data && (Date.now() - sotaCache.timestamp) < SOTA_CACHE_TTL) { + return res.json(sotaCache.data); + } + const response = await fetch('https://api2.sota.org.uk/api/spots/50/all'); const data = await response.json(); + + // Cache the response + sotaCache = { data, timestamp: Date.now() }; + res.json(data); } catch (error) { console.error('SOTA API error:', error.message); + if (sotaCache.data) return res.json(sotaCache.data); res.status(500).json({ error: 'Failed to fetch SOTA spots' }); } }); +// HamQSL cache (5 minutes) +let hamqslCache = { data: null, timestamp: 0 }; +const HAMQSL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // HamQSL Band Conditions app.get('/api/hamqsl/conditions', async (req, res) => { try { + // Return cached data if fresh + if (hamqslCache.data && (Date.now() - hamqslCache.timestamp) < HAMQSL_CACHE_TTL) { + res.set('Content-Type', 'application/xml'); + return res.send(hamqslCache.data); + } + const response = await fetch('https://www.hamqsl.com/solarxml.php'); const text = await response.text(); + + // Cache the response + hamqslCache = { data: text, timestamp: Date.now() }; + res.set('Content-Type', 'application/xml'); res.send(text); } catch (error) { console.error('HamQSL API error:', error.message); + if (hamqslCache.data) { + res.set('Content-Type', 'application/xml'); + return res.send(hamqslCache.data); + } res.status(500).json({ error: 'Failed to fetch band conditions' }); } }); @@ -1698,6 +1852,295 @@ app.get('/api/myspots/:callsign', async (req, res) => { } }); +// ============================================ +// PSKREPORTER API (MQTT-based for real-time) +// ============================================ + +// PSKReporter MQTT feed at mqtt.pskreporter.info provides real-time spots +// WebSocket endpoints: 1885 (ws), 1886 (wss) +// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry} + +// Cache for PSKReporter data - stores recent spots from MQTT +const pskReporterSpots = { + tx: new Map(), // Map of callsign -> spots where they're being heard + rx: new Map(), // Map of callsign -> spots they're receiving + maxAge: 60 * 60 * 1000 // Keep spots for 1 hour max +}; + +// Clean up old spots periodically +setInterval(() => { + const cutoff = Date.now() - pskReporterSpots.maxAge; + for (const [call, spots] of pskReporterSpots.tx) { + const filtered = spots.filter(s => s.timestamp > cutoff); + if (filtered.length === 0) { + pskReporterSpots.tx.delete(call); + } else { + pskReporterSpots.tx.set(call, filtered); + } + } + for (const [call, spots] of pskReporterSpots.rx) { + const filtered = spots.filter(s => s.timestamp > cutoff); + if (filtered.length === 0) { + pskReporterSpots.rx.delete(call); + } else { + pskReporterSpots.rx.set(call, filtered); + } + } +}, 5 * 60 * 1000); // Clean every 5 minutes + +// Convert grid square to lat/lon +function gridToLatLonSimple(grid) { + if (!grid || grid.length < 4) return null; + + const g = grid.toUpperCase(); + const lon = (g.charCodeAt(0) - 65) * 20 - 180; + const lat = (g.charCodeAt(1) - 65) * 10 - 90; + const lonMin = parseInt(g[2]) * 2; + const latMin = parseInt(g[3]) * 1; + + let finalLon = lon + lonMin + 1; + let finalLat = lat + latMin + 0.5; + + // If 6-character grid, add more precision + if (grid.length >= 6) { + const lonSec = (g.charCodeAt(4) - 65) * (2/24); + const latSec = (g.charCodeAt(5) - 65) * (1/24); + finalLon = lon + lonMin + lonSec + (1/24); + finalLat = lat + latMin + latSec + (0.5/24); + } + + return { lat: finalLat, lon: finalLon }; +} + +// Get band name from frequency in Hz +function getBandFromHz(freqHz) { + const freq = freqHz / 1000000; // Convert to MHz + if (freq >= 1.8 && freq <= 2) return '160m'; + if (freq >= 3.5 && freq <= 4) return '80m'; + if (freq >= 5.3 && freq <= 5.4) return '60m'; + if (freq >= 7 && freq <= 7.3) return '40m'; + if (freq >= 10.1 && freq <= 10.15) return '30m'; + if (freq >= 14 && freq <= 14.35) return '20m'; + if (freq >= 18.068 && freq <= 18.168) return '17m'; + if (freq >= 21 && freq <= 21.45) return '15m'; + if (freq >= 24.89 && freq <= 24.99) return '12m'; + if (freq >= 28 && freq <= 29.7) return '10m'; + if (freq >= 50 && freq <= 54) return '6m'; + if (freq >= 144 && freq <= 148) return '2m'; + if (freq >= 420 && freq <= 450) return '70cm'; + return 'Unknown'; +} + +// PSKReporter endpoint - returns MQTT connection info for frontend +// The frontend connects directly to MQTT via WebSocket for real-time updates +app.get('/api/pskreporter/config', (req, res) => { + res.json({ + mqtt: { + host: 'mqtt.pskreporter.info', + wsPort: 1885, // WebSocket + wssPort: 1886, // WebSocket + TLS (recommended) + topicPrefix: 'pskr/filter/v2' + }, + // Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry} + // Use + for single-level wildcard, # for multi-level + // Example for TX (being heard): pskr/filter/v2/+/+/{CALLSIGN}/# + // Example for RX (hearing): Subscribe and filter client-side + info: 'Connect via WebSocket to mqtt.pskreporter.info:1886 (wss) for real-time spots' + }); +}); + +// Fallback HTTP endpoint for when MQTT isn't available +// Uses the traditional retrieve API with caching +let pskHttpCache = {}; +const PSK_HTTP_CACHE_TTL = 10 * 60 * 1000; // 10 minutes - PSKReporter rate limits aggressively +let psk503Backoff = 0; // Timestamp when we can try again after 503 + +app.get('/api/pskreporter/http/:callsign', async (req, res) => { + const callsign = req.params.callsign.toUpperCase(); + const minutes = parseInt(req.query.minutes) || 15; + const direction = req.query.direction || 'tx'; // tx or rx + // flowStartSeconds must be NEGATIVE for "last N seconds" + const flowStartSeconds = -Math.abs(minutes * 60); + + const cacheKey = `${direction}:${callsign}:${minutes}`; + const now = Date.now(); + + // Check cache first + if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) { + return res.json({ ...pskHttpCache[cacheKey].data, cached: true }); + } + + // If we're in 503 backoff period, return cached data or empty result + if (psk503Backoff > now) { + console.log(`[PSKReporter HTTP] In backoff period, ${Math.round((psk503Backoff - now) / 1000)}s remaining`); + if (pskHttpCache[cacheKey]) { + return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); + } + return res.json({ + callsign, + direction, + count: 0, + reports: [], + backoff: true + }); + } + + try { + const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign'; + // Add appcontact parameter as requested by PSKReporter developer docs + const url = `https://retrieve.pskreporter.info/query?${param}=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&appcontact=openhamclock`; + + console.log(`[PSKReporter HTTP] Fetching ${direction} for ${callsign} (last ${minutes} min)`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'OpenHamClock/3.11 (Amateur Radio Dashboard)', + 'Accept': '*/*' + }, + signal: controller.signal + }); + clearTimeout(timeout); + + if (!response.ok) { + // On 503, set backoff period (15 minutes) to avoid hammering + if (response.status === 503) { + psk503Backoff = Date.now() + (15 * 60 * 1000); + console.log(`[PSKReporter HTTP] Got 503, backing off for 15 minutes`); + } + throw new Error(`HTTP ${response.status}`); + } + + const xml = await response.text(); + const reports = []; + + // Parse XML response + const reportRegex = /]*>/g; + let match; + while ((match = reportRegex.exec(xml)) !== null) { + const report = match[0]; + const getAttr = (name) => { + const m = report.match(new RegExp(`${name}="([^"]*)"`)); + return m ? m[1] : null; + }; + + const receiverCallsign = getAttr('receiverCallsign'); + const receiverLocator = getAttr('receiverLocator'); + const senderCallsign = getAttr('senderCallsign'); + const senderLocator = getAttr('senderLocator'); + const frequency = getAttr('frequency'); + const mode = getAttr('mode'); + const flowStartSecs = getAttr('flowStartSeconds'); + const sNR = getAttr('sNR'); + + if (receiverCallsign && senderCallsign) { + const locator = direction === 'tx' ? receiverLocator : senderLocator; + const loc = locator ? gridToLatLonSimple(locator) : null; + + reports.push({ + sender: senderCallsign, + senderGrid: senderLocator, + receiver: receiverCallsign, + receiverGrid: receiverLocator, + freq: frequency ? parseInt(frequency) : null, + freqMHz: frequency ? (parseInt(frequency) / 1000000).toFixed(3) : null, + band: frequency ? getBandFromHz(parseInt(frequency)) : 'Unknown', + mode: mode || 'Unknown', + timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(), + snr: sNR ? parseInt(sNR) : null, + lat: loc?.lat, + lon: loc?.lon, + age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0 + }); + } + } + + // Sort by timestamp (newest first) + reports.sort((a, b) => b.timestamp - a.timestamp); + + // Clear backoff on success + psk503Backoff = 0; + + const result = { + callsign, + direction, + count: reports.length, + reports: reports.slice(0, 100), + timestamp: new Date().toISOString(), + source: 'http' + }; + + // Cache it + pskHttpCache[cacheKey] = { data: result, timestamp: now }; + + console.log(`[PSKReporter HTTP] Found ${reports.length} ${direction} reports for ${callsign}`); + res.json(result); + + } catch (error) { + logErrorOnce('PSKReporter HTTP', error.message); + + // Return cached data if available (without error flag) + if (pskHttpCache[cacheKey]) { + return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); + } + + // Return empty result without error flag for 503s (rate limiting is expected) + res.json({ + callsign, + direction, + count: 0, + reports: [] + }); + } +}); + +// Combined endpoint that tries MQTT cache first, falls back to HTTP +app.get('/api/pskreporter/:callsign', async (req, res) => { + const callsign = req.params.callsign.toUpperCase(); + const minutes = parseInt(req.query.minutes) || 15; + + // For now, redirect to HTTP endpoint since MQTT requires client-side connection + // The frontend should connect directly to MQTT for real-time updates + try { + const [txRes, rxRes] = await Promise.allSettled([ + fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=tx`), + fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=rx`) + ]); + + let txData = { count: 0, reports: [] }; + let rxData = { count: 0, reports: [] }; + + if (txRes.status === 'fulfilled' && txRes.value.ok) { + txData = await txRes.value.json(); + } + if (rxRes.status === 'fulfilled' && rxRes.value.ok) { + rxData = await rxRes.value.json(); + } + + res.json({ + callsign, + tx: txData, + rx: rxData, + timestamp: new Date().toISOString(), + mqtt: { + available: true, + host: 'wss://mqtt.pskreporter.info:1886', + hint: 'Connect via WebSocket for real-time updates' + } + }); + + } catch (error) { + logErrorOnce('PSKReporter', error.message); + res.json({ + callsign, + tx: { count: 0, reports: [] }, + rx: { count: 0, reports: [] }, + error: error.message + }); + } +}); // ============================================ // SATELLITE TRACKING API // ============================================ @@ -1770,17 +2213,16 @@ let tleCache = { data: null, timestamp: 0 }; const TLE_CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours app.get('/api/satellites/tle', async (req, res) => { - console.log('[Satellites] Fetching TLE data...'); - try { const now = Date.now(); // Return cached data if fresh if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) { - console.log('[Satellites] Returning cached TLE data'); return res.json(tleCache.data); } + console.log('[Satellites] Fetching fresh TLE data...'); + // Fetch fresh TLE data from CelesTrak const tleData = {}; @@ -1819,7 +2261,6 @@ app.get('/api/satellites/tle', async (req, res) => { tle1: line1, tle2: line2 }; - console.log('[Satellites] Found TLE for:', key, noradId); } } } @@ -1829,10 +2270,18 @@ app.get('/api/satellites/tle', async (req, res) => { // Also try to get ISS specifically (it's in the stations group) if (!tleData['ISS']) { try { + const issController = new AbortController(); + const issTimeout = setTimeout(() => issController.abort(), 10000); + const issResponse = await fetch( 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle', - { headers: { 'User-Agent': 'OpenHamClock/3.3' } } + { + headers: { 'User-Agent': 'OpenHamClock/3.3' }, + signal: issController.signal + } ); + clearTimeout(issTimeout); + if (issResponse.ok) { const issText = await issResponse.text(); const issLines = issText.trim().split('\n'); @@ -1846,7 +2295,9 @@ app.get('/api/satellites/tle', async (req, res) => { } } } catch (e) { - console.log('[Satellites] Could not fetch ISS TLE:', e.message); + if (e.name !== 'AbortError') { + logErrorOnce('Satellites', `ISS TLE fetch: ${e.message}`); + } } } @@ -1857,7 +2308,10 @@ app.get('/api/satellites/tle', async (req, res) => { res.json(tleData); } catch (error) { - console.error('[Satellites] TLE fetch error:', error.message); + // Don't spam logs for timeouts (AbortError) or network issues + if (error.name !== 'AbortError') { + logErrorOnce('Satellites', `TLE fetch error: ${error.message}`); + } // Return cached data even if stale, or empty object res.json(tleCache.data || {}); } @@ -3083,9 +3537,11 @@ app.get('/api/config', (req, res) => { // ============================================ app.get('*', (req, res) => { - const indexPath = process.env.NODE_ENV === 'production' - ? path.join(__dirname, 'dist', 'index.html') - : path.join(__dirname, 'public', 'index.html'); + // Try dist first (built React app), fallback to public (monolithic) + const distIndex = path.join(__dirname, 'dist', 'index.html'); + const publicIndex = path.join(__dirname, 'public', 'index.html'); + + const indexPath = fs.existsSync(distIndex) ? distIndex : publicIndex; res.sendFile(indexPath); }); diff --git a/src/App.jsx b/src/App.jsx index 4cdf42b..2aa0d9e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,9 +13,11 @@ import { ContestPanel, SettingsPanel, DXFilterManager, + PSKFilterManager, SolarPanel, PropagationPanel, - DXpeditionPanel + DXpeditionPanel, + PSKReporterPanel } from './components'; // Hooks @@ -31,7 +33,8 @@ import { useMySpots, useDXpeditions, useSatellites, - useSolarIndices + useSolarIndices, + usePSKReporter } from './hooks'; // Utils @@ -94,15 +97,16 @@ const App = () => { // UI state const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); + const [showPSKFilters, setShowPSKFilters] = 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 }; + const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; - } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; } + } catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false, showPSKReporter: true }; } }); useEffect(() => { @@ -117,6 +121,7 @@ const App = () => { 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 })), []); + const togglePSKReporter = useCallback(() => setMapLayers(prev => ({ ...prev, showPSKReporter: !prev.showPSKReporter })), []); // 12/24 hour format const [use12Hour, setUse12Hour] = useState(() => { @@ -180,6 +185,20 @@ const App = () => { } catch (e) {} }, [dxFilters]); + // PSKReporter Filters + const [pskFilters, setPskFilters] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_pskFilters'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }); + + useEffect(() => { + try { + localStorage.setItem('openhamclock_pskFilters', JSON.stringify(pskFilters)); + } catch (e) {} + }, [pskFilters]); + const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); @@ -188,6 +207,26 @@ const App = () => { const mySpots = useMySpots(config.callsign); const satellites = useSatellites(config.location); const localWeather = useLocalWeather(config.location); + const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); + + // Filter PSKReporter spots for map display + const filteredPskSpots = useMemo(() => { + const allSpots = [...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]; + if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) { + return allSpots; + } + return allSpots.filter(spot => { + if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false; + if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false; + if (pskFilters?.grids?.length) { + const grid = spot.receiverGrid || spot.senderGrid; + if (!grid) return false; + const gridPrefix = grid.substring(0, 2).toUpperCase(); + if (!pskFilters.grids.includes(gridPrefix)) return false; + } + return true; + }); + }, [pskReporter.txReports, pskReporter.rxReports, pskFilters]); // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); @@ -457,11 +496,13 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} + showPSKReporter={mapLayers.showPSKReporter} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -593,11 +634,13 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} showPOTA={mapLayers.showPOTA} showSatellites={mapLayers.showSatellites} + showPSKReporter={mapLayers.showPSKReporter} onToggleSatellites={toggleSatellites} hoveredSpot={hoveredSpot} /> @@ -617,9 +660,9 @@ const App = () => { {/* RIGHT SIDEBAR */} -
- {/* DX Cluster - takes most space */} -
+
+ {/* DX Cluster - primary panel, takes most space */} +
{ />
- {/* DXpeditions - smaller */} -
+ {/* PSKReporter - digital mode spots */} +
+ setShowPSKFilters(true)} + onShowOnMap={(report) => { + if (report.lat && report.lon) { + setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); + } + }} + /> +
+ + {/* DXpeditions */} +
- {/* POTA - smaller */} -
+ {/* POTA */} +
{ />
- {/* Contests - smaller */} -
+ {/* Contests - at bottom, compact */} +
@@ -670,6 +729,12 @@ const App = () => { isOpen={showDXFilters} onClose={() => setShowDXFilters(false)} /> + setShowPSKFilters(false)} + />
); }; diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx index 3991a7d..100e207 100644 --- a/src/components/ContestPanel.jsx +++ b/src/components/ContestPanel.jsx @@ -1,6 +1,6 @@ /** * ContestPanel Component - * Displays upcoming contests with contestcalendar.com credit + * Displays upcoming and active contests with live indicators */ import React from 'react'; @@ -22,13 +22,100 @@ export const ContestPanel = ({ data, loading }) => { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; + const formatTime = (dateStr) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) + 'z'; + }; + + // Check if contest is live (happening now) + const isContestLive = (contest) => { + if (!contest.start || !contest.end) return false; + const now = new Date(); + const start = new Date(contest.start); + const end = new Date(contest.end); + return now >= start && now <= end; + }; + + // Check if contest starts within 24 hours + const isStartingSoon = (contest) => { + if (!contest.start) return false; + const now = new Date(); + const start = new Date(contest.start); + const hoursUntil = (start - now) / (1000 * 60 * 60); + return hoursUntil > 0 && hoursUntil <= 24; + }; + + // Get time remaining or time until start + const getTimeInfo = (contest) => { + if (!contest.start || !contest.end) return formatDate(contest.start); + + const now = new Date(); + const start = new Date(contest.start); + const end = new Date(contest.end); + + if (now >= start && now <= end) { + // Contest is live - show time remaining + const hoursLeft = Math.floor((end - now) / (1000 * 60 * 60)); + const minsLeft = Math.floor(((end - now) % (1000 * 60 * 60)) / (1000 * 60)); + if (hoursLeft > 0) { + return `${hoursLeft}h ${minsLeft}m left`; + } + return `${minsLeft}m left`; + } else if (now < start) { + // Contest hasn't started + const hoursUntil = Math.floor((start - now) / (1000 * 60 * 60)); + if (hoursUntil < 24) { + return `Starts in ${hoursUntil}h`; + } + return formatDate(contest.start); + } + return formatDate(contest.start); + }; + + // Sort contests: live first, then starting soon, then by date + const sortedContests = data ? [...data].sort((a, b) => { + const aLive = isContestLive(a); + const bLive = isContestLive(b); + const aSoon = isStartingSoon(a); + const bSoon = isStartingSoon(b); + + if (aLive && !bLive) return -1; + if (!aLive && bLive) return 1; + if (aSoon && !bSoon) return -1; + if (!aSoon && bSoon) return 1; + + return new Date(a.start) - new Date(b.start); + }) : []; + + // Count live contests + const liveCount = sortedContests.filter(isContestLive).length; + return (
-
- šŸ† CONTESTS + šŸ† CONTESTS + {liveCount > 0 && ( + + šŸ”“ {liveCount} LIVE + + )}
@@ -36,31 +123,61 @@ export const ContestPanel = ({ data, loading }) => {
- ) : data && data.length > 0 ? ( + ) : sortedContests.length > 0 ? (
- {data.slice(0, 6).map((contest, i) => ( -
-
- {contest.name} -
-
- {contest.mode} - {formatDate(contest.start)} + {sortedContests.slice(0, 4).map((contest, i) => { + const live = isContestLive(contest); + const soon = isStartingSoon(contest); + + return ( +
+
+ {live && ( + ā— + )} + {soon && !live && ( + ◐ + )} + + {contest.name} + +
+
+ {contest.mode} + + {getTimeInfo(contest)} + +
-
- ))} + ); + })}
) : (
@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => { {/* Contest Calendar Credit */}
diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx new file mode 100644 index 0000000..ab5172a --- /dev/null +++ b/src/components/PSKFilterManager.jsx @@ -0,0 +1,405 @@ +/** + * PSKFilterManager Component + * Filter modal for PSKReporter spots - Bands, Grids, Modes + */ +import React, { useState } from 'react'; + +const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm']; +const MODES = ['FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'MSK144', 'Q65', 'FST4', 'FST4W']; + +// Common grid field prefixes by region +const GRID_REGIONS = [ + { name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] }, + { name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] }, + { name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] }, + { name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] }, + { name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] }, + { name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] }, + { name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] }, +]; + +export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { + const [activeTab, setActiveTab] = useState('bands'); + const [customGrid, setCustomGrid] = useState(''); + + if (!isOpen) return null; + + const toggleArrayItem = (key, item) => { + const current = filters[key] || []; + const newArray = current.includes(item) + ? current.filter(x => x !== item) + : [...current, item]; + onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined }); + }; + + const selectAll = (key, items) => { + onFilterChange({ ...filters, [key]: [...items] }); + }; + + const clearFilter = (key) => { + const newFilters = { ...filters }; + delete newFilters[key]; + onFilterChange(newFilters); + }; + + const clearAllFilters = () => { + onFilterChange({}); + }; + + const addCustomGrid = () => { + if (customGrid.trim() && customGrid.length >= 2) { + const grid = customGrid.toUpperCase().substring(0, 2); + const current = filters?.grids || []; + if (!current.includes(grid)) { + onFilterChange({ ...filters, grids: [...current, grid] }); + } + setCustomGrid(''); + } + }; + + const getActiveFilterCount = () => { + let count = 0; + if (filters?.bands?.length) count += filters.bands.length; + if (filters?.grids?.length) count += filters.grids.length; + if (filters?.modes?.length) count += filters.modes.length; + return count; + }; + + const tabStyle = (active) => ({ + padding: '8px 16px', + background: active ? 'var(--bg-tertiary)' : 'transparent', + border: 'none', + borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent', + color: active ? 'var(--accent-cyan)' : 'var(--text-muted)', + fontSize: '13px', + cursor: 'pointer', + fontFamily: 'inherit' + }); + + const chipStyle = (selected) => ({ + padding: '6px 12px', + background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)', + border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`, + borderRadius: '4px', + color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)', + fontSize: '12px', + cursor: 'pointer', + fontFamily: 'JetBrains Mono, monospace' + }); + + const renderBandsTab = () => ( +
+
+ + Filter by Band + +
+ + +
+
+
+ {BANDS.map(band => ( + + ))} +
+
+ {filters?.bands?.length + ? `Showing only: ${filters.bands.join(', ')}` + : 'Showing all bands (no filter)'} +
+
+ ); + + const renderGridsTab = () => ( +
+
+ + Filter by Grid Square + + +
+ + {/* Custom grid input */} +
+ setCustomGrid(e.target.value.toUpperCase())} + maxLength={2} + onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()} + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'JetBrains Mono' + }} + /> + +
+ + {/* Selected grids */} + {filters?.grids?.length > 0 && ( +
+
+ Active Grid Filters: +
+
+ {filters.grids.map(grid => ( + + ))} +
+
+ )} + + {/* Quick select by region */} +
+ Quick Select by Region: +
+ {GRID_REGIONS.map(region => ( +
+
+ {region.name} +
+
+ {region.grids.map(grid => ( + + ))} +
+
+ ))} +
+ ); + + const renderModesTab = () => ( +
+
+ + Filter by Mode + +
+ + +
+
+
+ {MODES.map(mode => ( + + ))} +
+
+ {filters?.modes?.length + ? `Showing only: ${filters.modes.join(', ')}` + : 'Showing all modes (no filter)'} +
+
+ ); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+
+

+ šŸ“” PSKReporter Filters +

+ + {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active + +
+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'bands' && renderBandsTab()} + {activeTab === 'grids' && renderGridsTab()} + {activeTab === 'modes' && renderModesTab()} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default PSKFilterManager; diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx new file mode 100644 index 0000000..d7fff17 --- /dev/null +++ b/src/components/PSKReporterPanel.jsx @@ -0,0 +1,324 @@ +/** + * PSKReporter Panel + * Shows where your digital mode signals are being received + * Uses MQTT WebSocket for real-time data + */ +import React, { useState, useMemo } from 'react'; +import { usePSKReporter } from '../hooks/usePSKReporter.js'; +import { getBandColor } from '../utils/callsign.js'; + +const PSKReporterPanel = ({ + callsign, + onShowOnMap, + showOnMap, + onToggleMap, + filters = {}, + onOpenFilters +}) => { + const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard) + + const { + txReports, + txCount, + rxReports, + rxCount, + loading, + error, + connected, + source, + refresh + } = usePSKReporter(callsign, { + minutes: 15, + enabled: callsign && callsign !== 'N0CALL' + }); + + // Filter reports by band, grid, and mode + const filterReports = (reports) => { + return reports.filter(r => { + // Band filter + if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; + + // Grid filter (prefix match) + if (filters?.grids?.length) { + const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid; + if (!grid) return false; + const gridPrefix = grid.substring(0, 2).toUpperCase(); + if (!filters.grids.includes(gridPrefix)) return false; + } + + // Mode filter + if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false; + + return true; + }); + }; + + const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]); + const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]); + const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx; + + // Count active filters + const getActiveFilterCount = () => { + let count = 0; + if (filters?.bands?.length) count++; + if (filters?.grids?.length) count++; + if (filters?.modes?.length) count++; + return count; + }; + const filterCount = getActiveFilterCount(); + + // Get band color from frequency + const getFreqColor = (freqMHz) => { + if (!freqMHz) return 'var(--text-muted)'; + const freq = parseFloat(freqMHz); + return getBandColor(freq); + }; + + // Format age + const formatAge = (minutes) => { + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes/60)}h`; + }; + + // Get status indicator + const getStatusIndicator = () => { + if (connected) { + return ā— LIVE; + } + if (source === 'connecting' || source === 'reconnecting') { + return ◐ {source}; + } + if (error) { + return ā— offline; + } + return null; + }; + + if (!callsign || callsign === 'N0CALL') { + return ( +
+
+ šŸ“” PSKReporter +
+
+ Set callsign in Settings +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ šŸ“” PSKReporter {getStatusIndicator()} +
+ + {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} + + + + {onToggleMap && ( + + )} +
+
+ + {/* Tabs */} +
+ + +
+ + {/* Reports list */} + {error && !connected ? ( +
+ āš ļø Connection failed - click šŸ”„ to retry +
+ ) : loading && filteredReports.length === 0 && filterCount === 0 ? ( +
+
+ Connecting to MQTT... +
+ ) : !connected && filteredReports.length === 0 && filterCount === 0 ? ( +
+ Waiting for connection... +
+ ) : filteredReports.length === 0 ? ( +
+ {filterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'} +
+ ) : ( +
+ {filteredReports.slice(0, 20).map((report, i) => { + const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?'); + const color = getFreqColor(freqMHz); + const displayCall = activeTab === 'tx' ? report.receiver : report.sender; + const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid; + + return ( +
onShowOnMap && report.lat && report.lon && onShowOnMap(report)} + style={{ + display: 'grid', + gridTemplateColumns: '55px 1fr auto', + gap: '6px', + padding: '4px 6px', + borderRadius: '3px', + marginBottom: '2px', + background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent', + cursor: report.lat && report.lon ? 'pointer' : 'default', + transition: 'background 0.15s', + borderLeft: '2px solid transparent' + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'} + onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'} + > +
+ {freqMHz} +
+
+ {displayCall} + {grid && {grid}} +
+
+ {report.mode} + {report.snr !== null && report.snr !== undefined && ( + = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', + fontWeight: '600' + }}> + {report.snr > 0 ? '+' : ''}{report.snr} + + )} + + {formatAge(report.age)} + +
+
+ ); + })} +
+ )} +
+ ); +}; + +export default PSKReporterPanel; + +export { PSKReporterPanel }; diff --git a/src/components/PluginLayer.jsx b/src/components/PluginLayer.jsx new file mode 100644 index 0000000..ced3f66 --- /dev/null +++ b/src/components/PluginLayer.jsx @@ -0,0 +1,16 @@ +/** + * PluginLayer Component + * Renders a single plugin layer using its hook + */ +import React from 'react'; + +export const PluginLayer = ({ plugin, enabled, opacity, map }) => { + // Call the plugin's hook (this is allowed because it's in a component) + const result = plugin.hook({ enabled, opacity, map }); + + // Plugin hook handles its own rendering to the map + // This component doesn't render anything to the DOM + return null; +}; + +export default PluginLayer; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 656c45b..8566d4c 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -1,6 +1,6 @@ /** * SettingsPanel Component - * Full settings modal matching production version + * Full settings modal with map layer controls */ import React, { useState, useEffect } from 'react'; import { calculateGridSquare } from '../utils/geo.js'; @@ -15,6 +15,10 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { const [layout, setLayout] = useState(config?.layout || 'modern'); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const { t } = useTranslation(); + + // Layer controls + const [layers, setLayers] = useState([]); + const [activeTab, setActiveTab] = useState('station'); useEffect(() => { if (config) { @@ -24,19 +28,33 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { setTheme(config.theme || 'dark'); setLayout(config.layout || 'modern'); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); - // Use locator from config, or calculate from coordinates - if (config.locator) { - setGridSquare(config.locator); - } else if (config.location?.lat && config.location?.lon) { + if (config.location?.lat && config.location?.lon) { setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); } } }, [config, isOpen]); - // Update lat/lon when grid square changes + // Load layers when panel opens + useEffect(() => { + if (isOpen && window.hamclockLayerControls) { + setLayers(window.hamclockLayerControls.layers || []); + } + }, [isOpen]); + + // Refresh layers periodically + useEffect(() => { + if (isOpen && activeTab === 'layers') { + const interval = setInterval(() => { + if (window.hamclockLayerControls) { + setLayers([...window.hamclockLayerControls.layers]); + } + }, 200); + return () => clearInterval(interval); + } + }, [isOpen, activeTab]); + const handleGridChange = (grid) => { setGridSquare(grid.toUpperCase()); - // Parse grid square to lat/lon if valid (6 char) if (grid.length >= 4) { const parsed = parseGridSquare(grid); if (parsed) { @@ -46,7 +64,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { } }; - // Parse grid square to coordinates const parseGridSquare = (grid) => { grid = grid.toUpperCase(); if (grid.length < 4) return null; @@ -69,7 +86,6 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { return { lat, lon }; }; - // Update grid when lat/lon changes useEffect(() => { if (lat && lon) { setGridSquare(calculateGridSquare(lat, lon)); @@ -93,11 +109,41 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { } }; + const handleToggleLayer = (layerId) => { + if (window.hamclockLayerControls) { + const layer = layers.find(l => l.id === layerId); + const newEnabledState = !layer.enabled; + + // Update the control + window.hamclockLayerControls.toggleLayer(layerId, newEnabledState); + + // Force immediate UI update + setLayers(prevLayers => + prevLayers.map(l => + l.id === layerId ? { ...l, enabled: newEnabledState } : l + ) + ); + + // Refresh after a short delay to get the updated state + setTimeout(() => { + if (window.hamclockLayerControls) { + setLayers([...window.hamclockLayerControls.layers]); + } + }, 100); + } + }; + + const handleOpacityChange = (layerId, opacity) => { + if (window.hamclockLayerControls) { + window.hamclockLayerControls.setOpacity(layerId, opacity); + setLayers([...window.hamclockLayerControls.layers]); + } + }; + const handleSave = () => { onSave({ ...config, callsign: callsign.toUpperCase(), - locator: gridSquare.toUpperCase(), location: { lat: parseFloat(lat), lon: parseFloat(lon) }, theme, layout, @@ -144,14 +190,14 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { border: '2px solid var(--accent-amber)', borderRadius: '12px', padding: '24px', - width: '480px', + width: '520px', maxHeight: '90vh', overflowY: 'auto' }}>

{ {t('station.settings.title')}

- {/* First-time setup banner */} - {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && ( -
-
- {t("station.settings.welcome")} -
-
- {t("station.settings.describe")} -
-
- , env: }} /> -
-
- )} - - {/* Callsign */} -
- - setCallsign(e.target.value.toUpperCase())} + {/* Tab Navigation */} +
+
- - {/* Grid Square */} -
- - handleGridChange(e.target.value)} - placeholder="FN20nc" - maxLength={6} + > + šŸ“” Station + +
- {/* Lat/Lon */} -
-
- - setLat(parseFloat(e.target.value))} - style={{ - width: '100%', - padding: '10px', - background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', - borderRadius: '6px', - color: 'var(--text-primary)', - fontSize: '14px', - fontFamily: 'JetBrains Mono, monospace', - boxSizing: 'border-box' - }} - /> -
-
- - setLon(parseFloat(e.target.value))} + {/* Station Settings Tab */} + {activeTab === 'station' && ( + <> + {/* First-time setup banner */} + {(config?.configIncomplete || config?.callsign === 'N0CALL' || !config?.locator) && ( +
+
+ {t("station.settings.welcome")} +
+
+ {t("station.settings.describe")} +
+
+ , env: }} /> +
+
+ )} + + {/* Callsign */} +
+ + setCallsign(e.target.value.toUpperCase())} + style={{ + width: '100%', + padding: '12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--accent-amber)', + fontSize: '18px', + fontFamily: 'JetBrains Mono, monospace', + fontWeight: '700', + boxSizing: 'border-box' + }} + /> +
+ + {/* Grid Square */} +
+ + handleGridChange(e.target.value)} + placeholder="FN20nc" + maxLength={6} + style={{ + width: '100%', + padding: '12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--accent-amber)', + fontSize: '18px', + fontFamily: 'JetBrains Mono, monospace', + fontWeight: '700', + boxSizing: 'border-box' + }} + /> +
+ + {/* Lat/Lon */} +
+
+ + setLat(parseFloat(e.target.value) || 0)} + style={{ + width: '100%', + padding: '10px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono, monospace', + boxSizing: 'border-box' + }} + /> +
+
+ + setLon(parseFloat(e.target.value) || 0)} + style={{ + width: '100%', + padding: '10px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '14px', + fontFamily: 'JetBrains Mono, monospace', + boxSizing: 'border-box' + }} + /> +
+
+ +
-
+ > + {t('station.settings.useLocation')} + - {/* Use My Location button */} - + {/* Theme */} +
+ +
+ {['dark', 'light', 'legacy', 'retro'].map((th) => ( + + ))} +
+
+ {themeDescriptions[theme]} +
+
- {/* Theme */} -
- -
- {['dark', 'light', 'legacy', 'retro'].map((theme) => ( - - ))} -
-
- {themeDescriptions[theme]} -
-
+ {/* Layout */} +
+ +
+ {['modern', 'classic'].map((l) => ( + + ))} +
+
+ {layoutDescriptions[layout]} +
+
- {/* Layout */} -
- -
- {['modern', 'classic'].map((l) => ( -