Merge pull request #59 from accius/Modular-Staging

Modular staging
pull/65/head
accius 2 days ago committed by GitHub
commit cfa2c68a87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -20,7 +20,7 @@ export const BandConditionsPanel = ({ data, loading }) => {
return ( return (
<div className="panel" style={{ padding: '12px' }}> <div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">📡 BAND CONDITIONS</div> <div className="panel-header"> BAND CONDITIONS</div>
{loading ? ( {loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" /> <div className="loading-spinner" />

@ -102,7 +102,7 @@ export const ContestPanel = ({ data, loading }) => {
color: 'var(--accent-primary)', color: 'var(--accent-primary)',
fontWeight: '700' fontWeight: '700'
}}> }}>
<span>🏆 CONTESTS</span> <span> CONTESTS</span>
{liveCount > 0 && ( {liveCount > 0 && (
<span style={{ <span style={{
background: 'rgba(239, 68, 68, 0.3)', background: 'rgba(239, 68, 68, 0.3)',

@ -4,6 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import { getBandColor } from '../utils/callsign.js'; import { getBandColor } from '../utils/callsign.js';
import { IconSearch, IconMap, IconGlobe } from './Icons.jsx';
export const DXClusterPanel = ({ export const DXClusterPanel = ({
data, data,
@ -52,11 +53,12 @@ export const DXClusterPanel = ({
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
<span>🌐 DX CLUSTER <span style={{ color: 'var(--accent-green)', fontSize: '10px' }}> LIVE</span></span> <span><IconGlobe size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />DX CLUSTER <span style={{ color: 'var(--accent-green)', fontSize: '10px' }}> LIVE</span></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>{spots.length}/{totalSpots || spots.length}</span> <span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>{spots.length}/{totalSpots || spots.length}</span>
<button <button
onClick={onOpenFilters} onClick={onOpenFilters}
title="Filter DX spots by band, mode, or continent"
style={{ style={{
background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)', background: filterCount > 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`, border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
@ -68,10 +70,11 @@ export const DXClusterPanel = ({
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
🔍 Filters <IconSearch size={10} style={{ verticalAlign: 'middle', marginRight: '3px' }} />Filters
</button> </button>
<button <button
onClick={onToggleMap} onClick={onToggleMap}
title={showOnMap ? 'Hide DX spots on map' : 'Show DX spots on map'}
style={{ style={{
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)', background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`, border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
@ -83,7 +86,7 @@ export const DXClusterPanel = ({
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
🗺 {showOnMap ? 'ON' : 'OFF'} <IconMap size={10} style={{ verticalAlign: 'middle', marginRight: '3px' }} />{showOnMap ? 'ON' : 'OFF'}
</button> </button>
</div> </div>
</div> </div>

@ -416,7 +416,7 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
}}> }}>
<div> <div>
<div style={{ fontSize: '18px', fontWeight: '700', color: 'var(--accent-cyan)' }}> <div style={{ fontSize: '18px', fontWeight: '700', color: 'var(--accent-cyan)' }}>
🔍 DX Cluster Filters DX Cluster Filters
</div> </div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}> <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
{getActiveFilterCount()} filters active {getActiveFilterCount()} filters active
@ -462,7 +462,7 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
<button onClick={() => setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>Modes</button> <button onClick={() => setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>Modes</button>
<button onClick={() => setActiveTab('watchlist')} style={tabStyle(activeTab === 'watchlist')}>Watchlist</button> <button onClick={() => setActiveTab('watchlist')} style={tabStyle(activeTab === 'watchlist')}>Watchlist</button>
<button onClick={() => setActiveTab('exclude')} style={tabStyle(activeTab === 'exclude')}>Exclude</button> <button onClick={() => setActiveTab('exclude')} style={tabStyle(activeTab === 'exclude')}>Exclude</button>
<button onClick={() => setActiveTab('settings')} style={tabStyle(activeTab === 'settings')}> Settings</button> <button onClick={() => setActiveTab('settings')} style={tabStyle(activeTab === 'settings')}> Settings</button>
</div> </div>
{/* Tab Content */} {/* Tab Content */}

@ -24,7 +24,7 @@ export const DXpeditionPanel = ({ data, loading }) => {
marginBottom: '6px', marginBottom: '6px',
fontSize: '11px' fontSize: '11px'
}}> }}>
<span>🌍 DXPEDITIONS</span> <span> DXPEDITIONS</span>
{data && ( {data && (
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}> <span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>} {data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>}

@ -3,7 +3,7 @@
* Top bar with callsign, clocks, weather, and controls * Top bar with callsign, clocks, weather, and controls
*/ */
import React from 'react'; import React from 'react';
import { IconGear, IconExpand, IconShrink } from './Icons.jsx';
export const Header = ({ export const Header = ({
config, config,
utcTime, utcTime,
@ -42,7 +42,7 @@ export const Header = ({
> >
{config.callsign} {config.callsign}
</span> </span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>v3.7.0</span> {config.version && <span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>v{config.version}</span>}
</div> </div>
{/* UTC Clock */} {/* UTC Clock */}
@ -78,10 +78,11 @@ export const Header = ({
{/* Weather & Solar Stats */} {/* Weather & Solar Stats */}
<div style={{ display: 'flex', gap: '12px', fontSize: '13px', fontFamily: 'JetBrains Mono, Consolas, monospace', whiteSpace: 'nowrap', flexShrink: 0 }}> <div style={{ display: 'flex', gap: '12px', fontSize: '13px', fontFamily: 'JetBrains Mono, Consolas, monospace', whiteSpace: 'nowrap', flexShrink: 0 }}>
{localWeather?.data && (() => { {localWeather?.data && (() => {
const t = localWeather.data.temp; // Always compute both F and C from the raw Celsius source
const unit = localWeather.data.tempUnit || 'F'; // This avoids ±1° rounding drift when toggling units
const tempF = unit === 'C' ? Math.round(t * 9/5 + 32) : t; const rawC = localWeather.data.rawTempC;
const tempC = unit === 'F' ? Math.round((t - 32) * 5/9) : t; const tempF = Math.round(rawC * 9 / 5 + 32);
const tempC = Math.round(rawC);
const windLabel = localWeather.data.windUnit || 'mph'; const windLabel = localWeather.data.windUnit || 'mph';
return ( return (
<div title={`${localWeather.data.description} • Wind: ${localWeather.data.windSpeed} ${windLabel}`}> <div title={`${localWeather.data.description} • Wind: ${localWeather.data.windSpeed} ${windLabel}`}>
@ -146,7 +147,7 @@ export const Header = ({
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}} }}
> >
Settings <IconGear size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />Settings
</button> </button>
<button <button
onClick={onFullscreenToggle} onClick={onFullscreenToggle}
@ -162,7 +163,10 @@ export const Header = ({
}} }}
title={isFullscreen ? "Exit Fullscreen (Esc)" : "Enter Fullscreen"} title={isFullscreen ? "Exit Fullscreen (Esc)" : "Enter Fullscreen"}
> >
{isFullscreen ? '⛶ Exit' : '⛶ Full'} {isFullscreen
? <><IconShrink size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />Exit</>
: <><IconExpand size={12} style={{ verticalAlign: 'middle', marginRight: '4px' }} />Full</>
}
</button> </button>
</div> </div>
</div> </div>

@ -0,0 +1,170 @@
/**
* SVG Icons for OpenHamClock
*
* Cross-platform icons that render identically on all browsers and operating systems.
* Replaces emoji which render as tofu/boxes on Linux Chromium without emoji fonts.
*
* All icons accept: size (default 14), color (default 'currentColor'), style, className
*/
import React from 'react';
const defaults = { size: 14, color: 'currentColor' };
// Magnifying glass / Search / Filter
export const IconSearch = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" {...props}>
<circle cx="6.5" cy="6.5" r="4.5" />
<line x1="10" y1="10" x2="14" y2="14" />
</svg>
);
// Refresh / Reload
export const IconRefresh = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13.5 2.5v4h-4" />
<path d="M2.5 13.5v-4h4" />
<path d="M3.5 5.5a5.5 5.5 0 0 1 9.1-1l.9.9" />
<path d="M12.5 10.5a5.5 5.5 0 0 1-9.1 1l-.9-.9" />
</svg>
);
// Map
export const IconMap = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M1 3.5l4.5-2 5 2.5 4.5-2v11l-4.5 2-5-2.5L1 14.5z" />
<line x1="5.5" y1="1.5" x2="5.5" y2="12" />
<line x1="10.5" y1="4" x2="10.5" y2="14.5" />
</svg>
);
// Gear / Settings
export const IconGear = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" {...props}>
<circle cx="8" cy="8" r="2.2" />
<path d="M8 1.5l.7 1.8a4.5 4.5 0 0 1 1.6.9l1.9-.4 1 1.7-1.2 1.4c.1.4.1.7 0 1.1l1.2 1.4-1 1.7-1.9-.4a4.5 4.5 0 0 1-1.6.9L8 14.5l-.7-1.8a4.5 4.5 0 0 1-1.6-.9l-1.9.4-1-1.7 1.2-1.4c-.1-.4-.1-.7 0-1.1L3.8 6.6l1-1.7 1.9.4a4.5 4.5 0 0 1 1.6-.9z" />
</svg>
);
// Globe / World
export const IconGlobe = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" {...props}>
<circle cx="8" cy="8" r="6.5" />
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" />
<line x1="1.5" y1="8" x2="14.5" y2="8" />
</svg>
);
// Satellite
export const IconSatellite = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="5" y="5" width="6" height="6" rx="1" transform="rotate(45 8 8)" />
<line x1="2" y1="2" x2="4.5" y2="4.5" />
<line x1="11.5" y1="11.5" x2="14" y2="14" />
<path d="M3.5 6.5 A4 4 0 0 0 6.5 3.5" />
</svg>
);
// Antenna / Radio
export const IconAntenna = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="8" y1="6" x2="8" y2="15" />
<line x1="5" y1="15" x2="11" y2="15" />
<path d="M4 4a5.5 5.5 0 0 1 8 0" />
<path d="M2 2a9 9 0 0 1 12 0" />
<circle cx="8" cy="6" r="1" fill={color} stroke="none" />
</svg>
);
// Sun
export const IconSun = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" {...props}>
<circle cx="8" cy="8" r="3" />
<line x1="8" y1="1" x2="8" y2="3" />
<line x1="8" y1="13" x2="8" y2="15" />
<line x1="1" y1="8" x2="3" y2="8" />
<line x1="13" y1="8" x2="15" y2="8" />
<line x1="3.05" y1="3.05" x2="4.46" y2="4.46" />
<line x1="11.54" y1="11.54" x2="12.95" y2="12.95" />
<line x1="3.05" y1="12.95" x2="4.46" y2="11.54" />
<line x1="11.54" y1="4.46" x2="12.95" y2="3.05" />
</svg>
);
// Moon
export const IconMoon = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13.5 8.5a6 6 0 1 1-6-6 4.5 4.5 0 0 0 6 6z" />
</svg>
);
// Trophy / Contest
export const IconTrophy = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M5 2h6v5a3 3 0 0 1-6 0z" />
<path d="M5 4H3a1.5 1.5 0 0 0 0 3h2" />
<path d="M11 4h2a1.5 1.5 0 0 1 0 3h-2" />
<line x1="8" y1="10" x2="8" y2="12" />
<line x1="5.5" y1="14" x2="10.5" y2="14" />
<line x1="6" y1="12" x2="10" y2="12" />
</svg>
);
// Tent / POTA / Camping
export const IconTent = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M8 2L1.5 14h13z" />
<path d="M8 2v12" />
<path d="M6 14l2-5 2 5" />
</svg>
);
// Earth / DXpedition
export const IconEarth = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" {...props}>
<circle cx="8" cy="8" r="6.5" />
<path d="M1.5 6h13M1.5 10h13" />
<ellipse cx="8" cy="8" rx="3" ry="6.5" />
</svg>
);
// Pin / Location
export const IconPin = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M8 1.5A4.5 4.5 0 0 0 3.5 6c0 3.5 4.5 8.5 4.5 8.5s4.5-5 4.5-8.5A4.5 4.5 0 0 0 8 1.5z" />
<circle cx="8" cy="6" r="1.5" />
</svg>
);
// Tag / Label
export const IconTag = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M1.5 1.5h6l7 7-6 6-7-7z" />
<circle cx="5" cy="5" r="1" fill={color} stroke="none" />
</svg>
);
// Fullscreen expand
export const IconExpand = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="10,1 15,1 15,6" />
<polyline points="6,15 1,15 1,10" />
<line x1="15" y1="1" x2="9.5" y2="6.5" />
<line x1="1" y1="15" x2="6.5" y2="9.5" />
</svg>
);
// Fullscreen shrink
export const IconShrink = ({ size = defaults.size, color = defaults.color, ...props }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="5,10 0,15" />
<polyline points="11,6 16,1" />
<polyline points="6,11 6,15 2,15" />
<polyline points="10,5 10,1 14,1" />
</svg>
);
export default {
IconSearch, IconRefresh, IconMap, IconGear, IconGlobe, IconSatellite,
IconAntenna, IconSun, IconMoon, IconTrophy, IconTent, IconEarth,
IconPin, IconTag, IconExpand, IconShrink,
};

@ -21,7 +21,7 @@ export const LocationPanel = ({
return ( return (
<div className="panel" style={{ padding: '12px' }}> <div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">📍 LOCATIONS</div> <div className="panel-header"> LOCATIONS</div>
{/* DE Location */} {/* DE Location */}
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>

@ -14,9 +14,10 @@ export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => {
marginBottom: '6px', marginBottom: '6px',
fontSize: '11px' fontSize: '11px'
}}> }}>
<span>🏕 POTA ACTIVATORS</span> <span> POTA ACTIVATORS</span>
<button <button
onClick={onToggleMap} onClick={onToggleMap}
title={showOnMap ? 'Hide POTA activators on map' : 'Show POTA activators on map'}
style={{ style={{
background: showOnMap ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)', background: showOnMap ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#aa66ff' : '#666'}`, border: `1px solid ${showOnMap ? '#aa66ff' : '#666'}`,
@ -28,7 +29,7 @@ export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
🗺 {showOnMap ? 'ON' : 'OFF'} Map {showOnMap ? 'ON' : 'OFF'}
</button> </button>
</div> </div>

@ -310,7 +310,7 @@ export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =
}}> }}>
<div> <div>
<h3 style={{ margin: 0, fontSize: '16px', color: 'var(--text-primary)' }}> <h3 style={{ margin: 0, fontSize: '16px', color: 'var(--text-primary)' }}>
📡 PSKReporter Filters PSKReporter Filters
</h3> </h3>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}> <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
{getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active

@ -10,6 +10,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { usePSKReporter } from '../hooks/usePSKReporter.js';
import { getBandColor } from '../utils/callsign.js'; import { getBandColor } from '../utils/callsign.js';
import { IconSearch, IconRefresh, IconMap } from './Icons.jsx';
const PSKReporterPanel = ({ const PSKReporterPanel = ({
callsign, callsign,
@ -182,10 +183,10 @@ const PSKReporterPanel = ({
}}> }}>
{/* Mode toggle */} {/* Mode toggle */}
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<button onClick={() => setPanelModePersist('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}> <button onClick={() => setPanelModePersist('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')} title="Internet-based reception reports via PSKReporter.info">
PSKReporter PSKReporter
</button> </button>
<button onClick={() => setPanelModePersist('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}> <button onClick={() => setPanelModePersist('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')} title="Local WSJT-X decodes via UDP relay">
WSJT-X WSJT-X
</button> </button>
</div> </div>
@ -198,14 +199,14 @@ const PSKReporterPanel = ({
{statusDot && ( {statusDot && (
<span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span> <span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span>
)} )}
<button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')}> <button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')} title="Filter spots by band, mode, or grid">
{pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'} <IconSearch size={11} style={{ verticalAlign: 'middle' }} />{pskFilterCount > 0 ? pskFilterCount : ''}
</button> </button>
<button onClick={refresh} disabled={loading} style={{ <button onClick={refresh} disabled={loading} style={{
...iconBtn(false), ...iconBtn(false),
opacity: loading ? 0.4 : 1, opacity: loading ? 0.4 : 1,
cursor: loading ? 'not-allowed' : 'pointer', cursor: loading ? 'not-allowed' : 'pointer',
}}>🔄</button> }} title="Reconnect to PSKReporter"><IconRefresh size={11} style={{ verticalAlign: 'middle' }} /></button>
</> </>
)} )}
@ -241,8 +242,8 @@ const PSKReporterPanel = ({
{/* Map toggle (always visible) */} {/* Map toggle (always visible) */}
{handleMapToggle && ( {handleMapToggle && (
<button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')}> <button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')} title={isMapOn ? 'Hide spots on map' : 'Show spots on map'}>
🗺 <IconMap size={11} style={{ verticalAlign: 'middle' }} />
</button> </button>
)} )}
</div> </div>
@ -252,19 +253,19 @@ const PSKReporterPanel = ({
<div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}> <div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
{panelMode === 'psk' ? ( {panelMode === 'psk' ? (
<> <>
<button onClick={() => setActiveTabPersist('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}> <button onClick={() => setActiveTabPersist('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')} title="Stations hearing your signal">
Heard ({pskFilterCount > 0 ? filteredTx.length : txCount}) Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
</button> </button>
<button onClick={() => setActiveTabPersist('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}> <button onClick={() => setActiveTabPersist('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')} title="Stations you are hearing">
Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount}) Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
</button> </button>
</> </>
) : ( ) : (
<> <>
<button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}> <button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')} title="Live WSJT-X decodes">
Decodes ({filteredDecodes.length}) Decodes ({filteredDecodes.length})
</button> </button>
<button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}> <button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')} title="Logged QSOs from WSJT-X">
QSOs ({wsjtxQsos.length}) QSOs ({wsjtxQsos.length})
</button> </button>
</> </>
@ -283,7 +284,7 @@ const PSKReporterPanel = ({
</div> </div>
) : error && !connected ? ( ) : error && !connected ? (
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}> <div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed tap 🔄 Connection failed tap refresh
</div> </div>
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? ( ) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}> <div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}>

@ -34,7 +34,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
if (loading || !propagation) { if (loading || !propagation) {
return ( return (
<div className="panel"> <div className="panel">
<div className="panel-header">📡 VOACAP</div> <div className="panel-header"> VOACAP</div>
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}> <div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Loading predictions... Loading predictions...
</div> </div>
@ -81,7 +81,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}> <div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span> <span>
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'} {viewMode === 'bands' ? '◫ BAND CONDITIONS' : '⌇ VOACAP'}
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}></span>} {hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}></span>}
</span> </span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}> <span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
@ -143,7 +143,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
</div> </div>
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}> <span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
{hasRealData {hasRealData
? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}` ? ` ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
: '⚡ estimated' : '⚡ estimated'
} }
</span> </span>

@ -232,7 +232,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontFamily: 'JetBrains Mono, monospace' fontFamily: 'JetBrains Mono, monospace'
}} }}
> >
📡 Station Station
</button> </button>
<button <button
onClick={() => setActiveTab('layers')} onClick={() => setActiveTab('layers')}
@ -249,7 +249,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontFamily: 'JetBrains Mono, monospace' fontFamily: 'JetBrains Mono, monospace'
}} }}
> >
🗺 Map Layers Map Layers
</button> </button>
</div> </div>
@ -592,7 +592,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* Language */} {/* Language */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}> <label style={{ display: 'block', marginBottom: '8px', color: 'var(--text-muted)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px' }}>
🌐 {t('station.settings.language')} {t('station.settings.language')}
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px' }}>
{LANGUAGES.map((lang) => ( {LANGUAGES.map((lang) => (

@ -7,7 +7,7 @@ import { getMoonPhase } from '../utils/geo.js';
const MODES = ['image', 'indices', 'xray', 'lunar']; const MODES = ['image', 'indices', 'xray', 'lunar'];
const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX', lunar: 'LUNAR' }; const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX', lunar: 'LUNAR' };
const MODE_ICONS = { image: '📊', indices: '📈', xray: '🌙', lunar: '☀️' }; const MODE_ICONS = { image: '◫', indices: '⊞', xray: '☽', lunar: '☼' };
const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show lunar phase', lunar: 'Show solar image' }; const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show lunar phase', lunar: 'Show solar image' };
// Flare class from flux value (W/m²) // Flare class from flux value (W/m²)
@ -389,13 +389,13 @@ export const SolarPanel = ({ solarIndices }) => {
<div style={{ <div style={{
background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center', background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center',
}}> }}>
<div style={{ color: 'var(--text-muted)' }}>🌑 New</div> <div style={{ color: 'var(--text-muted)' }}> New</div>
<div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextNew}</div> <div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextNew}</div>
</div> </div>
<div style={{ <div style={{
background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center', background: 'var(--bg-tertiary)', borderRadius: '4px', padding: '4px 8px', textAlign: 'center',
}}> }}>
<div style={{ color: 'var(--text-muted)' }}>🌕 Full</div> <div style={{ color: 'var(--text-muted)' }}> Full</div>
<div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextFull}</div> <div style={{ color: 'var(--text-secondary)', fontWeight: '600' }}>{nextFull}</div>
</div> </div>
</div> </div>

@ -15,7 +15,7 @@ export const SpaceWeatherPanel = ({ data, loading }) => {
return ( return (
<div className="panel" style={{ padding: '12px' }}> <div className="panel" style={{ padding: '12px' }}>
<div className="panel-header"> SPACE WEATHER</div> <div className="panel-header"> SPACE WEATHER</div>
{loading ? ( {loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" /> <div className="loading-spinner" />

@ -13,6 +13,7 @@ import {
import { filterDXPaths, getBandColor } from '../utils/callsign.js'; import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js'; import { getAllLayers } from '../plugins/layerRegistry.js';
import { IconSatellite, IconTag, IconSun, IconMoon } from './Icons.jsx';
import PluginLayer from './PluginLayer.jsx'; import PluginLayer from './PluginLayer.jsx';
import { DXNewsTicker } from './DXNewsTicker.jsx'; import { DXNewsTicker } from './DXNewsTicker.jsx';
@ -299,24 +300,24 @@ export const WorldMap = ({
const sunPos = getSunPosition(new Date()); const sunPos = getSunPosition(new Date());
const sunIcon = L.divIcon({ const sunIcon = L.divIcon({
className: 'custom-marker sun-marker', className: 'custom-marker sun-marker',
html: '', html: '',
iconSize: [24, 24], iconSize: [24, 24],
iconAnchor: [12, 12] iconAnchor: [12, 12]
}); });
sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon }) sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
.bindPopup(`<b> Subsolar Point</b><br>${sunPos.lat.toFixed(2)}°, ${sunPos.lon.toFixed(2)}°`) .bindPopup(`<b> Subsolar Point</b><br>${sunPos.lat.toFixed(2)}°, ${sunPos.lon.toFixed(2)}°`)
.addTo(map); .addTo(map);
// Moon marker // Moon marker
const moonPos = getMoonPosition(new Date()); const moonPos = getMoonPosition(new Date());
const moonIcon = L.divIcon({ const moonIcon = L.divIcon({
className: 'custom-marker moon-marker', className: 'custom-marker moon-marker',
html: '🌙', html: '',
iconSize: [24, 24], iconSize: [24, 24],
iconAnchor: [12, 12] iconAnchor: [12, 12]
}); });
moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon }) moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
.bindPopup(`<b>🌙 Sublunar Point</b><br>${moonPos.lat.toFixed(2)}°, ${moonPos.lon.toFixed(2)}°`) .bindPopup(`<b> Sublunar Point</b><br>${moonPos.lat.toFixed(2)}°, ${moonPos.lon.toFixed(2)}°`)
.addTo(map); .addTo(map);
}, [deLocation, dxLocation]); }, [deLocation, dxLocation]);
@ -480,14 +481,14 @@ export const WorldMap = ({
// Add satellite marker icon // Add satellite marker icon
const icon = L.divIcon({ const icon = L.divIcon({
className: '', className: '',
html: `<span style="display:inline-block;background:${sat.visible ? satColor : satColorDark};color:${sat.visible ? '#000' : '#fff'};padding:4px 8px;border-radius:4px;font-size:11px;font-family:'JetBrains Mono',monospace;white-space:nowrap;border:2px solid ${sat.visible ? '#fff' : '#666'};font-weight:bold;box-shadow:0 2px 4px rgba(0,0,0,0.4);">🛰 ${sat.name}</span>`, html: `<span style="display:inline-block;background:${sat.visible ? satColor : satColorDark};color:${sat.visible ? '#000' : '#fff'};padding:4px 8px;border-radius:4px;font-size:11px;font-family:'JetBrains Mono',monospace;white-space:nowrap;border:2px solid ${sat.visible ? '#fff' : '#666'};font-weight:bold;box-shadow:0 2px 4px rgba(0,0,0,0.4);"> ${sat.name}</span>`,
iconSize: null, iconSize: null,
iconAnchor: [0, 0] iconAnchor: [0, 0]
}); });
const marker = L.marker([sat.lat, sat.lon], { icon }) const marker = L.marker([sat.lat, sat.lon], { icon })
.bindPopup(` .bindPopup(`
<b>🛰 ${sat.name}</b><br> <b> ${sat.name}</b><br>
<table style="font-size: 11px;"> <table style="font-size: 11px;">
<tr><td>Mode:</td><td><b>${sat.mode || 'Unknown'}</b></td></tr> <tr><td>Mode:</td><td><b>${sat.mode || 'Unknown'}</b></td></tr>
<tr><td>Alt:</td><td>${sat.alt} km</td></tr> <tr><td>Alt:</td><td>${sat.alt} km</td></tr>
@ -777,6 +778,7 @@ export const WorldMap = ({
{onToggleSatellites && ( {onToggleSatellites && (
<button <button
onClick={onToggleSatellites} onClick={onToggleSatellites}
title={showSatellites ? 'Hide satellite tracks' : 'Show satellite tracks'}
style={{ style={{
position: 'absolute', position: 'absolute',
top: '10px', top: '10px',
@ -792,7 +794,7 @@ export const WorldMap = ({
zIndex: 1000 zIndex: 1000
}} }}
> >
🛰 SAT {showSatellites ? 'ON' : 'OFF'} SAT {showSatellites ? 'ON' : 'OFF'}
</button> </button>
)} )}
@ -800,6 +802,7 @@ export const WorldMap = ({
{onToggleDXLabels && showDXPaths && ( {onToggleDXLabels && showDXPaths && (
<button <button
onClick={onToggleDXLabels} onClick={onToggleDXLabels}
title={showDXLabels ? 'Hide callsign labels on map' : 'Show callsign labels on map'}
style={{ style={{
position: 'absolute', position: 'absolute',
top: '10px', top: '10px',
@ -815,7 +818,7 @@ export const WorldMap = ({
zIndex: 1000 zIndex: 1000
}} }}
> >
🏷 CALLS {showDXLabels ? 'ON' : 'OFF'} CALLS {showDXLabels ? 'ON' : 'OFF'}
</button> </button>
)} )}
@ -856,8 +859,8 @@ export const WorldMap = ({
</div> </div>
)} )}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span style={{ background: '#00aaff', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}> DE</span> <span style={{ background: 'var(--accent-amber)', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}> DE</span>
<span style={{ background: '#ff8800', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}> DX</span> <span style={{ background: '#00aaff', color: '#000', padding: '2px 5px', borderRadius: '3px', fontWeight: '600' }}> DX</span>
</div> </div>
{showPOTA && ( {showPOTA && (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
@ -865,8 +868,8 @@ export const WorldMap = ({
</div> </div>
)} )}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<span style={{ color: '#ffcc00' }}> Sun</span> <span style={{ color: '#ffcc00' }}> Sun</span>
<span style={{ color: '#aaaaaa' }}>🌙 Moon</span> <span style={{ color: '#aaaaaa' }}> Moon</span>
</div> </div>
</div> </div>
</div> </div>

@ -1,6 +1,11 @@
/** /**
* useLocalWeather Hook * useLocalWeather Hook
* Fetches detailed weather data from Open-Meteo API (free, no API key) * Fetches detailed weather data from Open-Meteo API (free, no API key)
*
* Always fetches in metric (Celsius, km/h, mm) and converts client-side.
* This prevents rounding drift when toggling FC (the old approach refetched
* the API in the new unit, Math.round'd, then back-converted in the header,
* causing ±1° drift each toggle).
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@ -43,25 +48,31 @@ function windDirection(deg) {
return dirs[Math.round(deg / 22.5) % 16]; return dirs[Math.round(deg / 22.5) % 16];
} }
// Conversion helpers — always from Celsius/metric base
const cToF = (c) => c * 9 / 5 + 32;
const kmhToMph = (k) => k * 0.621371;
const mmToInch = (mm) => mm * 0.0393701;
const kmToMi = (km) => km * 0.621371;
export const useLocalWeather = (location, tempUnit = 'F') => { export const useLocalWeather = (location, tempUnit = 'F') => {
const [data, setData] = useState(null); const [rawData, setRawData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Fetch always in metric — only depends on location, NOT tempUnit
useEffect(() => { useEffect(() => {
if (!location?.lat || !location?.lon) return; if (!location?.lat || !location?.lon) return;
const fetchWeather = async () => { const fetchWeather = async () => {
try { try {
const isMetric = tempUnit === 'C';
const params = [ const params = [
`latitude=${location.lat}`, `latitude=${location.lat}`,
`longitude=${location.lon}`, `longitude=${location.lon}`,
'current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m,precipitation,uv_index,visibility,dew_point_2m,is_day', 'current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m,wind_gusts_10m,precipitation,uv_index,visibility,dew_point_2m,is_day',
'daily=temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,weather_code,sunrise,sunset,uv_index_max,wind_speed_10m_max', 'daily=temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,weather_code,sunrise,sunset,uv_index_max,wind_speed_10m_max',
'hourly=temperature_2m,precipitation_probability,weather_code', 'hourly=temperature_2m,precipitation_probability,weather_code',
`temperature_unit=${isMetric ? 'celsius' : 'fahrenheit'}`, 'temperature_unit=celsius',
`wind_speed_unit=${isMetric ? 'kmh' : 'mph'}`, 'wind_speed_unit=kmh',
`precipitation_unit=${isMetric ? 'mm' : 'inch'}`, 'precipitation_unit=mm',
'timezone=auto', 'timezone=auto',
'forecast_days=3', 'forecast_days=3',
'forecast_hours=24', 'forecast_hours=24',
@ -72,11 +83,36 @@ export const useLocalWeather = (location, tempUnit = 'F') => {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json(); const result = await response.json();
const current = result.current || {}; // Store raw metric values — conversion happens at read time
setRawData(result);
} catch (err) {
console.error('Weather error:', err);
} finally {
setLoading(false);
}
};
fetchWeather();
const interval = setInterval(fetchWeather, 15 * 60 * 1000); // 15 minutes
return () => clearInterval(interval);
}, [location?.lat, location?.lon]); // Note: no tempUnit dependency
// Convert raw API data to display data based on current tempUnit
// This runs on every render where tempUnit changes — instant, no API call
const data = (() => {
if (!rawData) return null;
const isMetric = tempUnit === 'C';
const current = rawData.current || {};
const daily = rawData.daily || {};
const hourly = rawData.hourly || {};
const code = current.weather_code; const code = current.weather_code;
const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: '🌡️' }; const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: '🌡️' };
const daily = result.daily || {};
const hourly = result.hourly || {}; // Temperature conversion (raw is always Celsius)
const convTemp = (c) => c == null ? null : Math.round(isMetric ? c : cToF(c));
// Wind conversion (raw is always km/h)
const convWind = (k) => k == null ? null : Math.round(isMetric ? k : kmhToMph(k));
// Build hourly forecast (next 24h in 3h intervals) // Build hourly forecast (next 24h in 3h intervals)
const hourlyForecast = []; const hourlyForecast = [];
@ -86,7 +122,7 @@ export const useLocalWeather = (location, tempUnit = 'F') => {
const hWeather = WEATHER_CODES[hCode] || { desc: '', icon: '🌡️' }; const hWeather = WEATHER_CODES[hCode] || { desc: '', icon: '🌡️' };
hourlyForecast.push({ hourlyForecast.push({
time: new Date(hourly.time[i]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }), time: new Date(hourly.time[i]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
temp: Math.round(hourly.temperature_2m[i]), temp: convTemp(hourly.temperature_2m[i]),
precipProb: hourly.precipitation_probability?.[i] || 0, precipProb: hourly.precipitation_probability?.[i] || 0,
icon: hWeather.icon, icon: hWeather.icon,
}); });
@ -101,65 +137,65 @@ export const useLocalWeather = (location, tempUnit = 'F') => {
const dWeather = WEATHER_CODES[dCode] || { desc: '', icon: '🌡️' }; const dWeather = WEATHER_CODES[dCode] || { desc: '', icon: '🌡️' };
dailyForecast.push({ dailyForecast.push({
date: new Date(daily.time[i] + 'T12:00:00').toLocaleDateString([], { weekday: 'short' }), date: new Date(daily.time[i] + 'T12:00:00').toLocaleDateString([], { weekday: 'short' }),
high: Math.round(daily.temperature_2m_max?.[i] || 0), high: convTemp(daily.temperature_2m_max?.[i]),
low: Math.round(daily.temperature_2m_min?.[i] || 0), low: convTemp(daily.temperature_2m_min?.[i]),
precipProb: daily.precipitation_probability_max?.[i] || 0, precipProb: daily.precipitation_probability_max?.[i] || 0,
precipSum: daily.precipitation_sum?.[i] || 0, precipSum: isMetric
? (daily.precipitation_sum?.[i] || 0)
: parseFloat(mmToInch(daily.precipitation_sum?.[i] || 0).toFixed(2)),
icon: dWeather.icon, icon: dWeather.icon,
desc: dWeather.desc, desc: dWeather.desc,
windMax: Math.round(daily.wind_speed_10m_max?.[i] || 0), windMax: convWind(daily.wind_speed_10m_max?.[i]),
uvMax: daily.uv_index_max?.[i] || 0, uvMax: daily.uv_index_max?.[i] || 0,
}); });
} }
} }
setData({ // Raw Celsius values for Header's dual F/C display
// Current conditions const rawTempC = current.temperature_2m || 0;
temp: Math.round(current.temperature_2m || 0),
feelsLike: Math.round(current.apparent_temperature || 0), return {
// Current conditions (converted to user's preferred unit)
temp: convTemp(current.temperature_2m),
feelsLike: convTemp(current.apparent_temperature),
description: weather.desc, description: weather.desc,
icon: weather.icon, icon: weather.icon,
humidity: Math.round(current.relative_humidity_2m || 0), humidity: Math.round(current.relative_humidity_2m || 0),
dewPoint: Math.round(current.dew_point_2m || 0), dewPoint: convTemp(current.dew_point_2m),
pressure: current.pressure_msl ? current.pressure_msl.toFixed(1) : null, pressure: current.pressure_msl ? current.pressure_msl.toFixed(1) : null,
cloudCover: current.cloud_cover || 0, cloudCover: current.cloud_cover || 0,
windSpeed: Math.round(current.wind_speed_10m || 0), windSpeed: convWind(current.wind_speed_10m),
windDir: windDirection(current.wind_direction_10m), windDir: windDirection(current.wind_direction_10m),
windDirDeg: current.wind_direction_10m || 0, windDirDeg: current.wind_direction_10m || 0,
windGusts: Math.round(current.wind_gusts_10m || 0), windGusts: convWind(current.wind_gusts_10m),
precipitation: current.precipitation || 0, precipitation: isMetric
? (current.precipitation || 0)
: parseFloat(mmToInch(current.precipitation || 0).toFixed(2)),
uvIndex: current.uv_index || 0, uvIndex: current.uv_index || 0,
visibility: current.visibility visibility: current.visibility
? isMetric ? isMetric
? (current.visibility / 1000).toFixed(1) // meters to km ? (current.visibility / 1000).toFixed(1) // meters to km
: (current.visibility / 1609.34).toFixed(1) // meters to miles : kmToMi(current.visibility / 1000).toFixed(1) // meters to km to miles
: null, : null,
isDay: current.is_day === 1, isDay: current.is_day === 1,
weatherCode: code, weatherCode: code,
// Today's highs/lows // Today's highs/lows
todayHigh: daily.temperature_2m_max?.[0] ? Math.round(daily.temperature_2m_max[0]) : null, todayHigh: convTemp(daily.temperature_2m_max?.[0]),
todayLow: daily.temperature_2m_min?.[0] ? Math.round(daily.temperature_2m_min[0]) : null, todayLow: convTemp(daily.temperature_2m_min?.[0]),
// Forecasts // Forecasts
hourly: hourlyForecast, hourly: hourlyForecast,
daily: dailyForecast, daily: dailyForecast,
// Timezone // Timezone
timezone: result.timezone || '', timezone: rawData.timezone || '',
// Units // Units (for display labels)
tempUnit: isMetric ? 'C' : 'F', tempUnit: isMetric ? 'C' : 'F',
windUnit: isMetric ? 'km/h' : 'mph', windUnit: isMetric ? 'km/h' : 'mph',
visUnit: isMetric ? 'km' : 'mi', visUnit: isMetric ? 'km' : 'mi',
}); // Raw Celsius for Header's dual display (avoids double-rounding)
} catch (err) { rawTempC,
console.error('Weather error:', err); rawFeelsLikeC: current.apparent_temperature || 0,
} finally {
setLoading(false);
}
}; };
})();
fetchWeather();
const interval = setInterval(fetchWeather, 15 * 60 * 1000); // 15 minutes
return () => clearInterval(interval);
}, [location?.lat, location?.lon, tempUnit]);
return { data, loading }; return { data, loading };
}; };

@ -116,6 +116,11 @@ export const loadConfig = () => {
// Mark if config needs setup (no callsign set anywhere) // Mark if config needs setup (no callsign set anywhere)
config.configIncomplete = (config.callsign === 'N0CALL' || !config.locator); config.configIncomplete = (config.callsign === 'N0CALL' || !config.locator);
// Always inject version from server (not a user preference — server is source of truth)
if (serverConfig?.version) {
config.version = serverConfig.version;
}
return config; return config;
}; };

Loading…
Cancel
Save

Powered by TurnKey Linux.