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 ── */}
-
-
setPanelMode('psk')} style={modeBtn('psk', 'var(--accent-primary)')}>
- 📡 PSKReporter
+ {/* Mode toggle */}
+
+ setPanelMode('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}>
+ PSKReporter
- setPanelMode('wsjtx')} style={modeBtn('wsjtx', '#a78bfa')}>
- 🔊 WSJT-X
+ setPanelMode('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}>
+ WSJT-X
-
- {/* Controls row - differs per mode */}
+
+ {/* Right controls */}
+ {/* PSK: status dot + filter + refresh */}
{panelMode === 'psk' && (
<>
-
- {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
-
- {getStatusIndicator()}
- 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
- border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
- color: filterCount > 0 ? '#ffaa00' : '#888',
- padding: '2px 6px', borderRadius: '4px', fontSize: '10px', cursor: 'pointer'
- }}>🔍
+ {statusDot && (
+ {statusDot.char}
+ )}
+ 0, '#ffaa00')}>
+ {pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'}
+
🔄
- {onToggleMap && (
- 🗺️ {showOnMap ? 'ON' : 'OFF'}
- )}
>
)}
+
+ {/* 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 }
)}
- setBandFilter(e.target.value)} style={{
- background: 'var(--bg-tertiary)', color: 'var(--text-primary)',
- border: '1px solid var(--border-color)', borderRadius: '3px',
- fontSize: '10px', padding: '1px 2px', cursor: 'pointer'
- }}>
- {wsjtxBands.map(b => {b === 'all' ? 'All' : b} )}
+ 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 => (
+ {o.label}
+ ))}
- setShowCQ(!showCQ)} style={{
- background: showCQ ? '#60a5fa33' : 'transparent',
- color: showCQ ? '#60a5fa' : 'var(--text-muted)',
- border: `1px solid ${showCQ ? '#60a5fa55' : 'var(--border-color)'}`,
- borderRadius: '3px', fontSize: '10px', padding: '1px 4px', cursor: 'pointer'
- }}>CQ
- {onToggleWSJTXMap && (
- 🗺️ {showWSJTXOnMap ? 'ON' : 'OFF'}
- )}
>
)}
+
+ {/* Map toggle (always visible) */}
+ {handleMapToggle && (
+
+ 🗺️
+
+ )}
- {/* === PSKReporter View === */}
- {panelMode === 'psk' && (
- <>
- {(!callsign || callsign === 'N0CALL') ? (
-
- Set callsign in Settings
-
- ) : (
- <>
- {/* PSK Tabs */}
-
- setActiveTab('tx')} style={{
- flex: 1, padding: '4px 6px',
- background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
- border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
- borderRadius: '3px', color: activeTab === 'tx' ? '#4ade80' : '#888',
- cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono'
- }}>
- 📤 Being Heard ({filterCount > 0 ? filteredTx.length : txCount})
-
- setActiveTab('rx')} style={{
- flex: 1, padding: '4px 6px',
- background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
- border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
- borderRadius: '3px', color: activeTab === 'rx' ? '#60a5fa' : '#888',
- cursor: 'pointer', fontSize: '10px', fontFamily: 'JetBrains Mono'
- }}>
- 📥 Hearing ({filterCount > 0 ? filteredRx.length : rxCount})
-
-
-
- {/* 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' ? (
+ <>
+ setActiveTab('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
+ ▲ Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
+
+ setActiveTab('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
+ ▼ Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
+
+ >
+ ) : (
+ <>
+ setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
+ Decodes ({filteredDecodes.length})
+
+ setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
+ QSOs ({wsjtxQsos.length})
+
+ >
+ )}
+
- {/* === WSJT-X View === */}
- {panelMode === 'wsjtx' && (
- <>
- {/* WSJT-X Tabs */}
-
- {[
- { key: 'decodes', label: `Decodes (${wsjtxDecodes.length})` },
- { key: 'qsos', label: `QSOs (${wsjtxQsos.length})` },
- ].map(tab => (
- setWsjtxTab(tab.key)}
- style={{
- flex: 1,
- background: wsjtxTab === tab.key ? 'var(--bg-tertiary)' : 'transparent',
- color: wsjtxTab === tab.key ? '#a78bfa' : 'var(--text-muted)',
- border: 'none',
- borderBottom: wsjtxTab === tab.key ? '2px solid #a78bfa' : '2px solid transparent',
- fontSize: '10px', padding: '3px 6px', cursor: 'pointer',
- borderRadius: '3px 3px 0 0',
- }}
- >
- {tab.label}
-
- ))}
-
+ {/* ── 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 ? (
+
+ ) : 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 */}
+
+
+ 🕐 Timezone
+
+
setTimezone(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '6px',
+ color: timezone ? 'var(--accent-green)' : 'var(--text-muted)',
+ fontSize: '14px',
+ fontFamily: 'JetBrains Mono, monospace',
+ cursor: 'pointer'
+ }}
+ >
+ Auto (browser default)
+
+ Eastern (New York)
+ Central (Chicago)
+ Mountain (Denver)
+ Pacific (Los Angeles)
+ Alaska
+ Hawaii
+ Arizona (no DST)
+ Saskatchewan (no DST)
+ Atlantic (Halifax)
+ Newfoundland
+ Ontario (Toronto)
+ Manitoba (Winnipeg)
+ Alberta (Edmonton)
+ BC (Vancouver)
+ Mexico City
+
+
+ UK (London)
+ Ireland (Dublin)
+ Central Europe (Paris)
+ Germany (Berlin)
+ Italy (Rome)
+ Spain (Madrid)
+ Netherlands (Amsterdam)
+ Belgium (Brussels)
+ Sweden (Stockholm)
+ Finland (Helsinki)
+ Greece (Athens)
+ Romania (Bucharest)
+ Russia (Moscow)
+ Poland (Warsaw)
+ Switzerland (Zurich)
+ Portugal (Lisbon)
+
+
+ Japan (Tokyo)
+ Korea (Seoul)
+ China (Shanghai)
+ Hong Kong
+ Taiwan (Taipei)
+ Singapore
+ India (Kolkata)
+ UAE (Dubai)
+ Saudi Arabia (Riyadh)
+ Iran (Tehran)
+ Thailand (Bangkok)
+ Indonesia (Jakarta)
+ Philippines (Manila)
+ Australia Eastern (Sydney)
+ Australia Central (Adelaide)
+ Australia Western (Perth)
+ New Zealand (Auckland)
+ Fiji
+
+
+ Brazil (São Paulo)
+ Argentina (Buenos Aires)
+ Chile (Santiago)
+ Colombia (Bogotá)
+ Peru (Lima)
+ Venezuela (Caracas)
+
+
+ Egypt (Cairo)
+ South Africa (Johannesburg)
+ Nigeria (Lagos)
+ Kenya (Nairobi)
+ Morocco (Casablanca)
+
+
+ UTC
+ Iceland (Reykjavik)
+ Azores
+ Maldives
+ Mauritius
+
+
+
+ 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 */}
{t('station.settings.dx.title')}
diff --git a/src/utils/config.js b/src/utils/config.js
index 715a633..27edc4d 100644
--- a/src/utils/config.js
+++ b/src/utils/config.js
@@ -16,6 +16,7 @@ export const DEFAULT_CONFIG = {
units: 'imperial', // 'imperial' or 'metric'
theme: 'dark', // 'dark', 'light', 'legacy', or 'retro'
layout: 'modern', // 'modern' or 'classic'
+ timezone: '', // IANA timezone (e.g. 'America/Regina') — empty = browser default
use12Hour: true,
showSatellites: true,
showPota: true,
@@ -104,6 +105,7 @@ export const loadConfig = () => {
units: serverConfig.units || config.units,
theme: serverConfig.theme || config.theme,
layout: serverConfig.layout || config.layout,
+ timezone: serverConfig.timezone || config.timezone,
use12Hour: serverConfig.timeFormat === '12',
showSatellites: serverConfig.showSatellites ?? config.showSatellites,
showPota: serverConfig.showPota ?? config.showPota,