diff --git a/src/components/BandConditionsPanel.jsx b/src/components/BandConditionsPanel.jsx
index 732964d..92613bf 100644
--- a/src/components/BandConditionsPanel.jsx
+++ b/src/components/BandConditionsPanel.jsx
@@ -20,7 +20,7 @@ export const BandConditionsPanel = ({ data, loading }) => {
return (
-
π‘ BAND CONDITIONS
+
β BAND CONDITIONS
{loading ? (
diff --git a/src/components/ContestPanel.jsx b/src/components/ContestPanel.jsx
index 100e207..7b9a399 100644
--- a/src/components/ContestPanel.jsx
+++ b/src/components/ContestPanel.jsx
@@ -102,7 +102,7 @@ export const ContestPanel = ({ data, loading }) => {
color: 'var(--accent-primary)',
fontWeight: '700'
}}>
-
π CONTESTS
+
β CONTESTS
{liveCount > 0 && (
- π DX CLUSTER β LIVE
+ DX CLUSTER β LIVE
{spots.length}/{totalSpots || spots.length}
diff --git a/src/components/DXFilterManager.jsx b/src/components/DXFilterManager.jsx
index bd3f218..377da25 100644
--- a/src/components/DXFilterManager.jsx
+++ b/src/components/DXFilterManager.jsx
@@ -416,7 +416,7 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
}}>
- π DX Cluster Filters
+ β DX Cluster Filters
{getActiveFilterCount()} filters active
@@ -462,7 +462,7 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
-
+
{/* Tab Content */}
diff --git a/src/components/DXpeditionPanel.jsx b/src/components/DXpeditionPanel.jsx
index a8806a9..f7060e6 100644
--- a/src/components/DXpeditionPanel.jsx
+++ b/src/components/DXpeditionPanel.jsx
@@ -24,7 +24,7 @@ export const DXpeditionPanel = ({ data, loading }) => {
marginBottom: '6px',
fontSize: '11px'
}}>
-
π DXPEDITIONS
+
β DXPEDITIONS
{data && (
{data.active > 0 && {data.active} active}
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
index db8df60..3545dc4 100644
--- a/src/components/Header.jsx
+++ b/src/components/Header.jsx
@@ -3,7 +3,7 @@
* Top bar with callsign, clocks, weather, and controls
*/
import React from 'react';
-
+import { IconGear, IconExpand, IconShrink } from './Icons.jsx';
export const Header = ({
config,
utcTime,
@@ -42,7 +42,7 @@ export const Header = ({
>
{config.callsign}
-
v3.7.0
+ {config.version &&
v{config.version}}
{/* UTC Clock */}
@@ -78,10 +78,11 @@ export const Header = ({
{/* Weather & Solar Stats */}
{localWeather?.data && (() => {
- const t = localWeather.data.temp;
- const unit = localWeather.data.tempUnit || 'F';
- const tempF = unit === 'C' ? Math.round(t * 9/5 + 32) : t;
- const tempC = unit === 'F' ? Math.round((t - 32) * 5/9) : t;
+ // Always compute both F and C from the raw Celsius source
+ // This avoids Β±1Β° rounding drift when toggling units
+ const rawC = localWeather.data.rawTempC;
+ const tempF = Math.round(rawC * 9 / 5 + 32);
+ const tempC = Math.round(rawC);
const windLabel = localWeather.data.windUnit || 'mph';
return (
@@ -146,7 +147,7 @@ export const Header = ({
whiteSpace: 'nowrap'
}}
>
- β Settings
+ Settings
diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx
new file mode 100644
index 0000000..00f3fb7
--- /dev/null
+++ b/src/components/Icons.jsx
@@ -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 }) => (
+
+);
+
+// Refresh / Reload
+export const IconRefresh = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Map
+export const IconMap = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Gear / Settings
+export const IconGear = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Globe / World
+export const IconGlobe = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Satellite
+export const IconSatellite = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Antenna / Radio
+export const IconAntenna = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Sun
+export const IconSun = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Moon
+export const IconMoon = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Trophy / Contest
+export const IconTrophy = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Tent / POTA / Camping
+export const IconTent = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Earth / DXpedition
+export const IconEarth = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Pin / Location
+export const IconPin = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Tag / Label
+export const IconTag = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Fullscreen expand
+export const IconExpand = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+// Fullscreen shrink
+export const IconShrink = ({ size = defaults.size, color = defaults.color, ...props }) => (
+
+);
+
+export default {
+ IconSearch, IconRefresh, IconMap, IconGear, IconGlobe, IconSatellite,
+ IconAntenna, IconSun, IconMoon, IconTrophy, IconTent, IconEarth,
+ IconPin, IconTag, IconExpand, IconShrink,
+};
diff --git a/src/components/LocationPanel.jsx b/src/components/LocationPanel.jsx
index 26c2a5f..3dba44d 100644
--- a/src/components/LocationPanel.jsx
+++ b/src/components/LocationPanel.jsx
@@ -21,7 +21,7 @@ export const LocationPanel = ({
return (
-
π LOCATIONS
+
β LOCATIONS
{/* DE Location */}
diff --git a/src/components/POTAPanel.jsx b/src/components/POTAPanel.jsx
index 8e466bc..56ce3e6 100644
--- a/src/components/POTAPanel.jsx
+++ b/src/components/POTAPanel.jsx
@@ -14,9 +14,10 @@ export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => {
marginBottom: '6px',
fontSize: '11px'
}}>
- ποΈ POTA ACTIVATORS
+ β³ POTA ACTIVATORS
diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx
index ab5172a..e31a0b0 100644
--- a/src/components/PSKFilterManager.jsx
+++ b/src/components/PSKFilterManager.jsx
@@ -310,7 +310,7 @@ export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =
}}>
- π‘ PSKReporter Filters
+ β PSKReporter Filters
{getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx
index b73d7e4..bf18575 100644
--- a/src/components/PSKReporterPanel.jsx
+++ b/src/components/PSKReporterPanel.jsx
@@ -10,6 +10,7 @@
import React, { useState, useMemo } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js';
import { getBandColor } from '../utils/callsign.js';
+import { IconSearch, IconRefresh, IconMap } from './Icons.jsx';
const PSKReporterPanel = ({
callsign,
@@ -182,10 +183,10 @@ const PSKReporterPanel = ({
}}>
{/* Mode toggle */}
-
@@ -198,14 +199,14 @@ const PSKReporterPanel = ({
{statusDot && (
{statusDot.char}
)}
- 0, '#ffaa00')}>
- {pskFilterCount > 0 ? `π${pskFilterCount}` : 'π'}
+ 0, '#ffaa00')} title="Filter spots by band, mode, or grid">
+ {pskFilterCount > 0 ? pskFilterCount : ''}
π
+ }} title="Reconnect to PSKReporter">
>
)}
@@ -241,8 +242,8 @@ const PSKReporterPanel = ({
{/* Map toggle (always visible) */}
{handleMapToggle && (
-
- πΊοΈ
+
+
)}
@@ -252,19 +253,19 @@ const PSKReporterPanel = ({
{panelMode === 'psk' ? (
<>
- setActiveTabPersist('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
+ setActiveTabPersist('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')} title="Stations hearing your signal">
β² Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
- setActiveTabPersist('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
+ setActiveTabPersist('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')} title="Stations you are hearing">
βΌ Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
>
) : (
<>
- setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
+ setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')} title="Live WSJT-X decodes">
Decodes ({filteredDecodes.length})
- setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
+ setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')} title="Logged QSOs from WSJT-X">
QSOs ({wsjtxQsos.length})
>
@@ -283,7 +284,7 @@ const PSKReporterPanel = ({
) : error && !connected ? (
- Connection failed β tap π
+ Connection failed β tap refresh β»
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
diff --git a/src/components/PropagationPanel.jsx b/src/components/PropagationPanel.jsx
index 71e6396..c6bb226 100644
--- a/src/components/PropagationPanel.jsx
+++ b/src/components/PropagationPanel.jsx
@@ -34,7 +34,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
if (loading || !propagation) {
return (
-
π‘ VOACAP
+
β VOACAP
Loading predictions...
@@ -81,7 +81,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
- {viewMode === 'bands' ? 'π BAND CONDITIONS' : 'π‘ VOACAP'}
+ {viewMode === 'bands' ? 'β« BAND CONDITIONS' : 'β VOACAP'}
{hasRealData && viewMode !== 'bands' && β}
@@ -143,7 +143,7 @@ export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
{hasRealData
- ? `π‘ ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
+ ? `β ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
: 'β‘ estimated'
}
diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx
index b0795bf..58f7109 100644
--- a/src/components/SettingsPanel.jsx
+++ b/src/components/SettingsPanel.jsx
@@ -232,7 +232,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontFamily: 'JetBrains Mono, monospace'
}}
>
- π‘ Station
+ β Station
setActiveTab('layers')}
@@ -249,7 +249,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
fontFamily: 'JetBrains Mono, monospace'
}}
>
- πΊοΈ Map Layers
+ β Map Layers
@@ -592,7 +592,7 @@ export const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
{/* Language */}
{LANGUAGES.map((lang) => (
diff --git a/src/components/SolarPanel.jsx b/src/components/SolarPanel.jsx
index 6930d06..43c938e 100644
--- a/src/components/SolarPanel.jsx
+++ b/src/components/SolarPanel.jsx
@@ -7,7 +7,7 @@ import { getMoonPhase } from '../utils/geo.js';
const MODES = ['image', 'indices', 'xray', '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' };
// Flare class from flux value (W/mΒ²)
@@ -389,13 +389,13 @@ export const SolarPanel = ({ solarIndices }) => {
-
π New
+
β New
{nextNew}
-
π Full
+
β Full
{nextFull}
diff --git a/src/components/SpaceWeatherPanel.jsx b/src/components/SpaceWeatherPanel.jsx
index 7fa71fd..d0aaca1 100644
--- a/src/components/SpaceWeatherPanel.jsx
+++ b/src/components/SpaceWeatherPanel.jsx
@@ -15,7 +15,7 @@ export const SpaceWeatherPanel = ({ data, loading }) => {
return (
-
βοΈ SPACE WEATHER
+
βΌ SPACE WEATHER
{loading ? (
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx
index 4347b98..11e8843 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -13,6 +13,7 @@ import {
import { filterDXPaths, getBandColor } from '../utils/callsign.js';
import { getAllLayers } from '../plugins/layerRegistry.js';
+import { IconSatellite, IconTag, IconSun, IconMoon } from './Icons.jsx';
import PluginLayer from './PluginLayer.jsx';
import { DXNewsTicker } from './DXNewsTicker.jsx';
@@ -299,24 +300,24 @@ export const WorldMap = ({
const sunPos = getSunPosition(new Date());
const sunIcon = L.divIcon({
className: 'custom-marker sun-marker',
- html: 'β',
+ html: 'βΌ',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
sunMarkerRef.current = L.marker([sunPos.lat, sunPos.lon], { icon: sunIcon })
- .bindPopup(`
β Subsolar Point${sunPos.lat.toFixed(2)}Β°, ${sunPos.lon.toFixed(2)}Β°`)
+ .bindPopup(`
βΌ Subsolar Point${sunPos.lat.toFixed(2)}Β°, ${sunPos.lon.toFixed(2)}Β°`)
.addTo(map);
// Moon marker
const moonPos = getMoonPosition(new Date());
const moonIcon = L.divIcon({
className: 'custom-marker moon-marker',
- html: 'π',
+ html: 'β½',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
moonMarkerRef.current = L.marker([moonPos.lat, moonPos.lon], { icon: moonIcon })
- .bindPopup(`
π Sublunar Point${moonPos.lat.toFixed(2)}Β°, ${moonPos.lon.toFixed(2)}Β°`)
+ .bindPopup(`
β½ Sublunar Point${moonPos.lat.toFixed(2)}Β°, ${moonPos.lon.toFixed(2)}Β°`)
.addTo(map);
}, [deLocation, dxLocation]);
@@ -480,14 +481,14 @@ export const WorldMap = ({
// Add satellite marker icon
const icon = L.divIcon({
className: '',
- html: `
π° ${sat.name}`,
+ html: `
β ${sat.name}`,
iconSize: null,
iconAnchor: [0, 0]
});
const marker = L.marker([sat.lat, sat.lon], { icon })
.bindPopup(`
-
π° ${sat.name}
+
β ${sat.name}
| Mode: | ${sat.mode || 'Unknown'} |
| Alt: | ${sat.alt} km |
@@ -777,6 +778,7 @@ export const WorldMap = ({
{onToggleSatellites && (
- π° SAT {showSatellites ? 'ON' : 'OFF'}
+ β SAT {showSatellites ? 'ON' : 'OFF'}
)}
@@ -800,6 +802,7 @@ export const WorldMap = ({
{onToggleDXLabels && showDXPaths && (
- π·οΈ CALLS {showDXLabels ? 'ON' : 'OFF'}
+ β CALLS {showDXLabels ? 'ON' : 'OFF'}
)}
@@ -856,8 +859,8 @@ export const WorldMap = ({
)}
- β DE
- β DX
+ β DE
+ β DX
{showPOTA && (
@@ -865,8 +868,8 @@ export const WorldMap = ({
)}
- β Sun
- π Moon
+ βΌ Sun
+ β½ Moon
diff --git a/src/hooks/useLocalWeather.js b/src/hooks/useLocalWeather.js
index d0afd86..3746f16 100644
--- a/src/hooks/useLocalWeather.js
+++ b/src/hooks/useLocalWeather.js
@@ -1,6 +1,11 @@
/**
* useLocalWeather Hook
* 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 FβC (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';
@@ -43,25 +48,31 @@ function windDirection(deg) {
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') => {
- const [data, setData] = useState(null);
+ const [rawData, setRawData] = useState(null);
const [loading, setLoading] = useState(true);
+ // Fetch always in metric β only depends on location, NOT tempUnit
useEffect(() => {
if (!location?.lat || !location?.lon) return;
const fetchWeather = async () => {
try {
- const isMetric = tempUnit === 'C';
const params = [
`latitude=${location.lat}`,
`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',
'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',
- `temperature_unit=${isMetric ? 'celsius' : 'fahrenheit'}`,
- `wind_speed_unit=${isMetric ? 'kmh' : 'mph'}`,
- `precipitation_unit=${isMetric ? 'mm' : 'inch'}`,
+ 'temperature_unit=celsius',
+ 'wind_speed_unit=kmh',
+ 'precipitation_unit=mm',
'timezone=auto',
'forecast_days=3',
'forecast_hours=24',
@@ -72,83 +83,8 @@ export const useLocalWeather = (location, tempUnit = 'F') => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
- const current = result.current || {};
- const code = current.weather_code;
- const weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: 'π‘οΈ' };
- const daily = result.daily || {};
- const hourly = result.hourly || {};
-
- // Build hourly forecast (next 24h in 3h intervals)
- const hourlyForecast = [];
- if (hourly.time && hourly.temperature_2m) {
- for (let i = 0; i < Math.min(24, hourly.time.length); i += 3) {
- const hCode = hourly.weather_code?.[i];
- const hWeather = WEATHER_CODES[hCode] || { desc: '', icon: 'π‘οΈ' };
- hourlyForecast.push({
- time: new Date(hourly.time[i]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
- temp: Math.round(hourly.temperature_2m[i]),
- precipProb: hourly.precipitation_probability?.[i] || 0,
- icon: hWeather.icon,
- });
- }
- }
-
- // Build daily forecast
- const dailyForecast = [];
- if (daily.time) {
- for (let i = 0; i < Math.min(3, daily.time.length); i++) {
- const dCode = daily.weather_code?.[i];
- const dWeather = WEATHER_CODES[dCode] || { desc: '', icon: 'π‘οΈ' };
- dailyForecast.push({
- date: new Date(daily.time[i] + 'T12:00:00').toLocaleDateString([], { weekday: 'short' }),
- high: Math.round(daily.temperature_2m_max?.[i] || 0),
- low: Math.round(daily.temperature_2m_min?.[i] || 0),
- precipProb: daily.precipitation_probability_max?.[i] || 0,
- precipSum: daily.precipitation_sum?.[i] || 0,
- icon: dWeather.icon,
- desc: dWeather.desc,
- windMax: Math.round(daily.wind_speed_10m_max?.[i] || 0),
- uvMax: daily.uv_index_max?.[i] || 0,
- });
- }
- }
-
- setData({
- // Current conditions
- temp: Math.round(current.temperature_2m || 0),
- feelsLike: Math.round(current.apparent_temperature || 0),
- description: weather.desc,
- icon: weather.icon,
- humidity: Math.round(current.relative_humidity_2m || 0),
- dewPoint: Math.round(current.dew_point_2m || 0),
- pressure: current.pressure_msl ? current.pressure_msl.toFixed(1) : null,
- cloudCover: current.cloud_cover || 0,
- windSpeed: Math.round(current.wind_speed_10m || 0),
- windDir: windDirection(current.wind_direction_10m),
- windDirDeg: current.wind_direction_10m || 0,
- windGusts: Math.round(current.wind_gusts_10m || 0),
- precipitation: current.precipitation || 0,
- uvIndex: current.uv_index || 0,
- visibility: current.visibility
- ? isMetric
- ? (current.visibility / 1000).toFixed(1) // meters to km
- : (current.visibility / 1609.34).toFixed(1) // meters to miles
- : null,
- isDay: current.is_day === 1,
- weatherCode: code,
- // Today's highs/lows
- todayHigh: daily.temperature_2m_max?.[0] ? Math.round(daily.temperature_2m_max[0]) : null,
- todayLow: daily.temperature_2m_min?.[0] ? Math.round(daily.temperature_2m_min[0]) : null,
- // Forecasts
- hourly: hourlyForecast,
- daily: dailyForecast,
- // Timezone
- timezone: result.timezone || '',
- // Units
- tempUnit: isMetric ? 'C' : 'F',
- windUnit: isMetric ? 'km/h' : 'mph',
- visUnit: isMetric ? 'km' : 'mi',
- });
+ // Store raw metric values β conversion happens at read time
+ setRawData(result);
} catch (err) {
console.error('Weather error:', err);
} finally {
@@ -159,7 +95,107 @@ export const useLocalWeather = (location, tempUnit = 'F') => {
fetchWeather();
const interval = setInterval(fetchWeather, 15 * 60 * 1000); // 15 minutes
return () => clearInterval(interval);
- }, [location?.lat, location?.lon, tempUnit]);
+ }, [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 weather = WEATHER_CODES[code] || { desc: 'Unknown', icon: 'π‘οΈ' };
+
+ // 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)
+ const hourlyForecast = [];
+ if (hourly.time && hourly.temperature_2m) {
+ for (let i = 0; i < Math.min(24, hourly.time.length); i += 3) {
+ const hCode = hourly.weather_code?.[i];
+ const hWeather = WEATHER_CODES[hCode] || { desc: '', icon: 'π‘οΈ' };
+ hourlyForecast.push({
+ time: new Date(hourly.time[i]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }),
+ temp: convTemp(hourly.temperature_2m[i]),
+ precipProb: hourly.precipitation_probability?.[i] || 0,
+ icon: hWeather.icon,
+ });
+ }
+ }
+
+ // Build daily forecast
+ const dailyForecast = [];
+ if (daily.time) {
+ for (let i = 0; i < Math.min(3, daily.time.length); i++) {
+ const dCode = daily.weather_code?.[i];
+ const dWeather = WEATHER_CODES[dCode] || { desc: '', icon: 'π‘οΈ' };
+ dailyForecast.push({
+ date: new Date(daily.time[i] + 'T12:00:00').toLocaleDateString([], { weekday: 'short' }),
+ high: convTemp(daily.temperature_2m_max?.[i]),
+ low: convTemp(daily.temperature_2m_min?.[i]),
+ precipProb: daily.precipitation_probability_max?.[i] || 0,
+ precipSum: isMetric
+ ? (daily.precipitation_sum?.[i] || 0)
+ : parseFloat(mmToInch(daily.precipitation_sum?.[i] || 0).toFixed(2)),
+ icon: dWeather.icon,
+ desc: dWeather.desc,
+ windMax: convWind(daily.wind_speed_10m_max?.[i]),
+ uvMax: daily.uv_index_max?.[i] || 0,
+ });
+ }
+ }
+
+ // Raw Celsius values for Header's dual F/C display
+ const rawTempC = current.temperature_2m || 0;
+
+ return {
+ // Current conditions (converted to user's preferred unit)
+ temp: convTemp(current.temperature_2m),
+ feelsLike: convTemp(current.apparent_temperature),
+ description: weather.desc,
+ icon: weather.icon,
+ humidity: Math.round(current.relative_humidity_2m || 0),
+ dewPoint: convTemp(current.dew_point_2m),
+ pressure: current.pressure_msl ? current.pressure_msl.toFixed(1) : null,
+ cloudCover: current.cloud_cover || 0,
+ windSpeed: convWind(current.wind_speed_10m),
+ windDir: windDirection(current.wind_direction_10m),
+ windDirDeg: current.wind_direction_10m || 0,
+ windGusts: convWind(current.wind_gusts_10m),
+ precipitation: isMetric
+ ? (current.precipitation || 0)
+ : parseFloat(mmToInch(current.precipitation || 0).toFixed(2)),
+ uvIndex: current.uv_index || 0,
+ visibility: current.visibility
+ ? isMetric
+ ? (current.visibility / 1000).toFixed(1) // meters to km
+ : kmToMi(current.visibility / 1000).toFixed(1) // meters to km to miles
+ : null,
+ isDay: current.is_day === 1,
+ weatherCode: code,
+ // Today's highs/lows
+ todayHigh: convTemp(daily.temperature_2m_max?.[0]),
+ todayLow: convTemp(daily.temperature_2m_min?.[0]),
+ // Forecasts
+ hourly: hourlyForecast,
+ daily: dailyForecast,
+ // Timezone
+ timezone: rawData.timezone || '',
+ // Units (for display labels)
+ tempUnit: isMetric ? 'C' : 'F',
+ windUnit: isMetric ? 'km/h' : 'mph',
+ visUnit: isMetric ? 'km' : 'mi',
+ // Raw Celsius for Header's dual display (avoids double-rounding)
+ rawTempC,
+ rawFeelsLikeC: current.apparent_temperature || 0,
+ };
+ })();
return { data, loading };
};
diff --git a/src/utils/config.js b/src/utils/config.js
index 27edc4d..a3f965d 100644
--- a/src/utils/config.js
+++ b/src/utils/config.js
@@ -116,6 +116,11 @@ export const loadConfig = () => {
// Mark if config needs setup (no callsign set anywhere)
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;
};