diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 72db51e..3545dc4 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -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 (
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 }; };