diff --git a/.env.example b/.env.example index 7f8f2a7..bd024ae 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ THEME=dark # Layout: 'modern' or 'classic' LAYOUT=modern +# Timezone: IANA timezone identifier +# Set this if your local time shows incorrectly (e.g. same as UTC). +# This is common with privacy browsers like Librewolf that spoof timezone. +# Examples: America/New_York, America/Regina, Europe/London, Asia/Tokyo +# Leave blank or commented out to use browser default. +# TZ=America/New_York + # =========================================== # OPTIONAL - External Services # =========================================== diff --git a/Dockerfile b/Dockerfile index ee30b65..94a9377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,9 @@ RUN chown -R openhamclock:nodejs /app # Switch to non-root user USER openhamclock -# Expose port +# Expose ports (3000 = web, 2237 = WSJT-X UDP) EXPOSE 3000 +EXPOSE 2237/udp # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/docker-compose.yml b/docker-compose.yml index 308dc5a..8784829 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,13 @@ services: container_name: openhamclock ports: - "3000:3000" + - "2237:2237/udp" # WSJT-X UDP — point WSJT-X to 127.0.0.1:2237 environment: - NODE_ENV=production - PORT=3000 + # Uncomment and set your timezone (IANA format) + # This ensures correct local time display, especially with privacy browsers + # - TZ=America/New_York restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] @@ -16,9 +20,6 @@ services: timeout: 10s retries: 3 start_period: 10s - # Uncomment to set timezone - # environment: - # - TZ=America/Denver # For development with hot reload: # docker compose -f docker-compose.dev.yml up diff --git a/server.js b/server.js index 8dafe7b..a87c940 100644 --- a/server.js +++ b/server.js @@ -3600,6 +3600,9 @@ app.get('/api/config', (req, res) => { // Whether config is incomplete (show setup wizard) configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare, + // Server timezone (from TZ env var or system) + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '', + // Feature availability features: { spaceWeather: true, diff --git a/src/App.jsx b/src/App.jsx index 558acf2..8b52cb8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -259,11 +259,18 @@ const App = () => { setDxLocation({ lat: coords.lat, lon: coords.lon }); }, []); - // Format times + // Format times — use explicit timezone if configured (fixes privacy browsers like Librewolf + // that spoof timezone to UTC via privacy.resistFingerprinting) 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' }); + const localTimeOpts = { hour12: use12Hour }; + const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' }; + if (config.timezone) { + localTimeOpts.timeZone = config.timezone; + localDateOpts.timeZone = config.timezone; + } + const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts); + const localDate = currentTime.toLocaleDateString('en-US', localDateOpts); // Scale for small screens const [scale, setScale] = useState(1); diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index 3c380d7..a092628 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -1,7 +1,11 @@ /** - * PSKReporter Panel - * Shows where your digital mode signals are being received - * Toggles between PSKReporter (internet) and WSJT-X (local UDP) views + * PSKReporter + WSJT-X Panel + * Digital mode spots — toggle between internet (PSKReporter) and local (WSJT-X UDP) + * + * Layout: + * Row 1: Segmented mode toggle | map + filter controls + * Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs) + * Content: Scrolling spot/decode list */ import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; @@ -25,36 +29,28 @@ const PSKReporterPanel = ({ showWSJTXOnMap, onToggleWSJTXMap }) => { - const [panelMode, setPanelMode] = useState('psk'); // 'psk' | 'wsjtx' - const [activeTab, setActiveTab] = useState('tx'); // PSK: tx | rx - const [wsjtxTab, setWsjtxTab] = useState('decodes'); // WSJT-X: decodes | qsos - const [bandFilter, setBandFilter] = useState('all'); - const [showCQ, setShowCQ] = useState(false); + const [panelMode, setPanelMode] = useState('psk'); + const [activeTab, setActiveTab] = useState('tx'); + const [wsjtxTab, setWsjtxTab] = useState('decodes'); + const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name + // PSKReporter hook const { - txReports, - txCount, - rxReports, - rxCount, - loading, - error, - connected, - source, - refresh + txReports, txCount, rxReports, rxCount, + loading, error, connected, source, refresh } = usePSKReporter(callsign, { minutes: 15, enabled: callsign && callsign !== 'N0CALL' }); - // PSK filter logic + // ── PSK filtering ── const filterReports = (reports) => { return reports.filter(r => { if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; 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; + if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false; } if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false; return true; @@ -64,401 +60,378 @@ const PSKReporterPanel = ({ const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]); const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]); const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx; + const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length; - 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(); - - const getFreqColor = (freqMHz) => { - if (!freqMHz) return 'var(--text-muted)'; - return getBandColor(parseFloat(freqMHz)); - }; - - const formatAge = (minutes) => { - if (minutes < 1) return 'now'; - if (minutes < 60) return `${minutes}m`; - return `${Math.floor(minutes/60)}h`; - }; - - const getStatusIndicator = () => { - if (connected) return ● LIVE; - if (source === 'connecting' || source === 'reconnecting') return ◐ {source}; - if (error) return ● offline; - return null; - }; + const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz)); + const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`; - // WSJT-X helpers + // ── WSJT-X helpers ── const activeClients = Object.entries(wsjtxClients); - const primaryClient = activeClients.length > 0 ? activeClients[0][1] : null; + const primaryClient = activeClients[0]?.[1] || null; - const wsjtxBands = useMemo(() => { - const bands = new Set(wsjtxDecodes.map(d => d.band).filter(Boolean)); - return ['all', ...Array.from(bands).sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999))]; + // Build unified filter options: All, CQ Only, then each available band + const wsjtxFilterOptions = useMemo(() => { + const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))] + .sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999)); + return [ + { value: 'all', label: 'All decodes' }, + { value: 'cq', label: 'CQ only' }, + ...bands.map(b => ({ value: b, label: b })) + ]; }, [wsjtxDecodes]); const filteredDecodes = useMemo(() => { let filtered = [...wsjtxDecodes]; - if (bandFilter !== 'all') filtered = filtered.filter(d => d.band === bandFilter); - if (showCQ) filtered = filtered.filter(d => d.type === 'CQ'); + if (wsjtxFilter === 'cq') { + filtered = filtered.filter(d => d.type === 'CQ'); + } else if (wsjtxFilter !== 'all') { + // Band filter + filtered = filtered.filter(d => d.band === wsjtxFilter); + } return filtered.reverse(); - }, [wsjtxDecodes, bandFilter, showCQ]); + }, [wsjtxDecodes, wsjtxFilter]); const getSnrColor = (snr) => { - if (snr === null || snr === undefined) return 'var(--text-muted)'; + if (snr == null) return 'var(--text-muted)'; if (snr >= 0) return '#4ade80'; if (snr >= -10) return '#fbbf24'; if (snr >= -18) return '#fb923c'; return '#ef4444'; }; - const getMsgColor = (decode) => { - if (decode.type === 'CQ') return '#60a5fa'; - if (decode.exchange === 'RR73' || decode.exchange === '73' || decode.exchange === 'RRR') return '#4ade80'; - if (decode.exchange?.startsWith('R')) return '#fbbf24'; + const getMsgColor = (d) => { + if (d.type === 'CQ') return '#60a5fa'; + if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80'; + if (d.exchange?.startsWith('R')) return '#fbbf24'; return 'var(--text-primary)'; }; - // Mode switch button style - const modeBtn = (mode, color) => ({ - padding: '2px 8px', - background: panelMode === mode ? `${color}22` : 'transparent', - border: `1px solid ${panelMode === mode ? color : 'var(--border-color)'}`, - color: panelMode === mode ? color : 'var(--text-muted)', + // Active map toggle for current mode + const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap; + const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap; + + // Compact status dot + const statusDot = connected + ? { color: '#4ade80', char: '●' } + : (source === 'connecting' || source === 'reconnecting') + ? { color: '#fbbf24', char: '◐' } + : error ? { color: '#ef4444', char: '●' } : null; + + // ── Shared styles ── + const segBtn = (active, color) => ({ + padding: '3px 10px', + background: active ? `${color}18` : 'transparent', + color: active ? color : 'var(--text-muted)', + border: 'none', + borderBottom: active ? `2px solid ${color}` : '2px solid transparent', + fontSize: '11px', + fontWeight: active ? '700' : '400', + cursor: 'pointer', + letterSpacing: '0.02em', + }); + + const subTabBtn = (active, color) => ({ + flex: 1, + padding: '3px 4px', + background: active ? `${color}20` : 'transparent', + border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`, + borderRadius: '3px', + color: active ? color : 'var(--text-muted)', + cursor: 'pointer', + fontSize: '10px', + fontWeight: active ? '600' : '400', + }); + + const iconBtn = (active, activeColor = '#4488ff') => ({ + background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)', + border: `1px solid ${active ? activeColor : '#555'}`, + color: active ? activeColor : '#777', + padding: '2px 6px', borderRadius: '3px', fontSize: '10px', cursor: 'pointer', - fontWeight: panelMode === mode ? '700' : '400', + lineHeight: 1, }); return (
- {/* Mode switcher header */} + {/* ── Row 1: Mode toggle + controls ── */}
-
- -
- - {/* Controls row - differs per mode */} + + {/* Right controls */}
+ {/* PSK: status dot + filter + refresh */} {panelMode === 'psk' && ( <> - - {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} - - {getStatusIndicator()} - + {statusDot && ( + {statusDot.char} + )} + - {onToggleMap && ( - - )} )} + + {/* WSJT-X: mode/band info + unified filter */} {panelMode === 'wsjtx' && ( <> {primaryClient && ( - - {primaryClient.mode || ''} {primaryClient.band || ''} - {primaryClient.transmitting && TX} - {primaryClient.decoding && RX} + + {primaryClient.mode} {primaryClient.band} + {primaryClient.transmitting && TX} )} - setWsjtxFilter(e.target.value)} + style={{ + background: 'var(--bg-tertiary)', + color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)', + border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`, + borderRadius: '3px', + fontSize: '10px', + padding: '1px 4px', + cursor: 'pointer', + maxWidth: '90px', + }} + > + {wsjtxFilterOptions.map(o => ( + + ))} - - {onToggleWSJTXMap && ( - - )} )} + + {/* Map toggle (always visible) */} + {handleMapToggle && ( + + )}
- {/* === PSKReporter View === */} - {panelMode === 'psk' && ( - <> - {(!callsign || callsign === 'N0CALL') ? ( -
- Set callsign in Settings -
- ) : ( - <> - {/* PSK Tabs */} -
- - -
- - {/* PSK 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)} -
-
- ); - })} -
- )} - - )} - - )} + {/* ── Row 2: Sub-tabs ── */} +
+ {panelMode === 'psk' ? ( + <> + + + + ) : ( + <> + + + + )} +
- {/* === WSJT-X View === */} - {panelMode === 'wsjtx' && ( - <> - {/* WSJT-X Tabs */} -
- {[ - { key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` }, - { key: 'qsos', label: `QSOs (${wsjtxQsos.length})` }, - ].map(tab => ( - - ))} -
+ {/* ── Content area ── */} +
- {/* No WSJT-X connected */} - {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? ( -
-
Waiting for WSJT-X...
-
- Settings → Reporting → UDP Server -
- Address: {'{server IP}'}   Port: {wsjtxPort || 2237} + {/* === PSKReporter content === */} + {panelMode === 'psk' && ( + <> + {(!callsign || callsign === 'N0CALL') ? ( +
+ Set your callsign in Settings to see reports
-
- ) : ( - <> - {/* Decodes / QSOs content */} + ) : error && !connected ? ( +
+ Connection failed — tap 🔄 +
+ ) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? ( +
+
+ Connecting... +
+ ) : filteredReports.length === 0 ? ( +
+ {pskFilterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'} +
+ ) : ( + filteredReports.slice(0, 25).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)} + style={{ + display: 'grid', + gridTemplateColumns: '52px 1fr auto', + gap: '5px', + padding: '3px 4px', + borderRadius: '2px', + background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent', + cursor: report.lat && report.lon ? 'pointer' : 'default', + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'} + onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'} + > + {freqMHz} + + {displayCall} + {grid && {grid}} + + + {report.mode} + {report.snr != null && ( + = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> + {report.snr > 0 ? '+' : ''}{report.snr} + + )} + {formatAge(report.age)} + +
+ ); + }) + )} + + )} + + {/* === WSJT-X content === */} + {panelMode === 'wsjtx' && ( + <> + {/* No client connected */} + {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
- {wsjtxTab === 'decodes' && ( - <> - {filteredDecodes.length === 0 ? ( -
- {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening for decodes...'} -
- ) : ( - filteredDecodes.map((d, i) => ( -
- {d.time} - - {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} - - {d.dt} - {d.freq} - {d.message} -
- )) - )} - +
Waiting for WSJT-X...
+
+ In WSJT-X: Settings → Reporting → UDP Server +
+ Address: 127.0.0.1   Port: {wsjtxPort || 2237} +
+
+ ) : wsjtxTab === 'decodes' ? ( + <> + {filteredDecodes.length === 0 ? ( +
+ {wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'} +
+ ) : ( + filteredDecodes.map((d, i) => ( +
+ {d.time} + + {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} + + {d.dt} + {d.freq} + {d.message} +
+ )) )} - - {wsjtxTab === 'qsos' && ( - <> - {wsjtxQsos.length === 0 ? ( -
- No QSOs logged yet -
- ) : ( - [...wsjtxQsos].reverse().map((q, i) => ( -
- - {q.dxCall} - - {q.band} - {q.mode} - {q.reportSent}/{q.reportRecv} - {q.dxGrid && {q.dxGrid}} -
- )) - )} - + + ) : ( + /* QSOs tab */ + <> + {wsjtxQsos.length === 0 ? ( +
+ No QSOs logged yet +
+ ) : ( + [...wsjtxQsos].reverse().map((q, i) => ( +
+ {q.dxCall} + {q.band} + {q.mode} + {q.reportSent}/{q.reportRecv} + {q.dxGrid && {q.dxGrid}} +
+ )) )} -
+ + )} + + )} +
- {/* WSJT-X status bar */} - {activeClients.length > 0 && ( -
- - {activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} - - {primaryClient?.dialFrequency && ( - - {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz - - )} -
- )} - + {/* ── WSJT-X status footer ── */} + {panelMode === 'wsjtx' && activeClients.length > 0 && ( +
+ {activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} + {primaryClient?.dialFrequency && ( + {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz )} - +
)}
); }; export default PSKReporterPanel; - export { PSKReporterPanel }; diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index 63770fc..b0795bf 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -14,6 +14,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { const [lon, setLon] = useState(config?.location?.lon || 0); const [theme, setTheme] = useState(config?.theme || 'dark'); const [layout, setLayout] = useState(config?.layout || 'modern'); + const [timezone, setTimezone] = useState(config?.timezone || ''); const [dxClusterSource, setDxClusterSource] = useState(config?.dxClusterSource || 'dxspider-proxy'); const { t, i18n } = useTranslation(); @@ -28,6 +29,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { setLon(config.location?.lon || 0); setTheme(config.theme || 'dark'); setLayout(config.layout || 'modern'); + setTimezone(config.timezone || ''); setDxClusterSource(config.dxClusterSource || 'dxspider-proxy'); if (config.location?.lat && config.location?.lon) { setGridSquare(calculateGridSquare(config.location.lat, config.location.lon)); @@ -148,6 +150,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => { location: { lat: parseFloat(lat), lon: parseFloat(lon) }, theme, layout, + timezone, dxClusterSource }); onClose(); @@ -451,6 +454,112 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* DX Cluster Source */} +
+ + +
+ Set this if your local time shows incorrectly (e.g. same as UTC). + Privacy browsers like Librewolf may spoof your timezone. + {timezone ? '' : ' Currently using browser default.'} +
+
+ + {/* DX Cluster Source - original */}