Updated logo
Added average seasonal temps compare
This commit is contained in:
fraxle
2026-07-07 13:23:08 +01:00
parent fd7ed5dfcc
commit 0b260affd7
7 changed files with 151 additions and 24 deletions
+9 -4
View File
@@ -176,7 +176,7 @@ export function UTCIForecast() {
// useAppState. See hooks/useAppState.js for the full reading order. // useAppState. See hooks/useAppState.js for the full reading order.
const { const {
location, setLocationAndSave, recentLocations, location, setLocationAndSave, recentLocations,
forecast, airQuality, loading, error, now, fetchedAt, forecast, airQuality, loading, error, now, fetchedAt, normals,
searchQuery, setSearchQuery, searchResults, setSearchResults, searching, searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
selectedDay, setSelectedDay, selectedDay, setSelectedDay,
proPromptDay, setProPromptDay, proPromptDay, setProPromptDay,
@@ -469,6 +469,9 @@ export function UTCIForecast() {
// Farming uses these for seasonal sow/harvest advice (week's dates + weather). // Farming uses these for seasonal sow/harvest advice (week's dates + weather).
activeProfile === 'farming' ? days : null, activeProfile === 'farming' ? days : null,
location?.lat, location?.lat,
// Climate normals + selected day's date drive the "vs seasonal average" row.
normals,
days[selectedDay]?.key,
); );
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
@@ -539,7 +542,7 @@ export function UTCIForecast() {
<nav class="utci-topnav" id="site-nav"> <nav class="utci-topnav" id="site-nav">
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div> <div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
<div class="nav-logo" aria-hidden="true"> <div class="nav-logo" aria-hidden="true">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span> <span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span> <span class="nav-logo-tag">See the world the way your skin does.</span>
</div> </div>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
@@ -648,7 +651,7 @@ export function UTCIForecast() {
<div class="utci-header"> <div class="utci-header">
<div class="header-left"> <div class="header-left">
<h1 class="utci-title"> <h1 class="utci-title">
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span> <span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
</h1> </h1>
<div class="utci-tagline">See the world the way your skin does.</div> <div class="utci-tagline">See the world the way your skin does.</div>
<div class="utci-loc-search" ref=${searchWrapRef}> <div class="utci-loc-search" ref=${searchWrapRef}>
@@ -1646,7 +1649,8 @@ export function UTCIForecast() {
const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp'); const hasFeltAnchor = glanceSummary.some(i => i.label === 'Peak felt temp');
const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null; const comfortItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Comfortable window') : null;
const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null; const drivingItem = hasFeltAnchor ? glanceSummary.find(i => i.label === 'Good driving time') : null;
const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time'))); const climateItem = hasFeltAnchor ? glanceSummary.find(i => i.label === '1991-2020 Average') : null;
const restSummary = glanceSummary.filter(i => !(hasFeltAnchor && (i.label === 'Comfortable window' || i.label === 'Good driving time' || i.label === '1991-2020 Average')));
return [ return [
...restSummary.flatMap(item => [ ...restSummary.flatMap(item => [
renderRow(item), renderRow(item),
@@ -1655,6 +1659,7 @@ export function UTCIForecast() {
? [ ? [
...heatEvents.map(e => renderRow(e, 'ev-')), ...heatEvents.map(e => renderRow(e, 'ev-')),
...(drivingItem ? [renderRow(drivingItem)] : []), ...(drivingItem ? [renderRow(drivingItem)] : []),
...(climateItem ? [renderRow(climateItem)] : []),
...(comfortItem ? [renderRow(comfortItem)] : []), ...(comfortItem ? [renderRow(comfortItem)] : []),
] ]
: []), : []),
+1 -1
View File
@@ -29,6 +29,6 @@ export function Wordmark({ fontSize = '34px' }) {
letterSpacing: '-0.01em', letterSpacing: '-0.01em',
color: '#1e1208', color: '#1e1208',
}}> }}>
<span>Sun</span><svg viewBox="0 0 174 173" aria-hidden="true" style=${{ width: '0.82em', height: '0.82em', flexShrink: 0 }}><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id=${clipId}><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path=${`url(#${clipId})`}><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span>Scope</span> <span>Sun</span><svg viewBox="0 0 174 173" aria-hidden="true" style=${{ width: '0.82em', height: '0.82em', flexShrink: 0 }}><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id=${clipId}><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path=${`url(#${clipId})`}><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span>Scope</span>
</div>`; </div>`;
} }
+33 -1
View File
@@ -381,7 +381,7 @@ export function computeWhyFeelsLike(row, env) {
// variant - active sub-variant key e.g. "running", "beach" (or null) // variant - active sub-variant key e.g. "running", "beach" (or null)
// skinType - Fitzpatrick skin type key for UV burn time // skinType - Fitzpatrick skin type key for UV burn time
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null) { export function computeGlanceSummary(todayRows, profile, variant, skinType, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null, normals = null, dayKey = null) {
if (!todayRows || todayRows.length === 0) return []; if (!todayRows || todayRows.length === 0) return [];
const show = (key) => !cols || !!cols[key]; const show = (key) => !cols || !!cols[key];
@@ -491,6 +491,33 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
alert: maxRainProb >= 60, alert: maxRainProb >= 60,
}; };
// ── Vs seasonal average (climate anomaly) ──────────────────────────────
// Shown across every profile: how far this day's mean air temp sits above or
// below the 1991-2020 normal for the date. A small, honest climate-change cue.
const climateItem = (() => {
if (!normals) return [];
const key = dayKey || todayRows[0]?.iso?.slice(0, 10);
if (!key) return [];
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1);
const normal = normals[doy];
if (normal == null) return [];
const taVals = todayRows.map(r => r.Ta).filter(v => v != null);
if (taVals.length === 0) return [];
const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length;
const anomaly = dayMean - normal;
const mag = Math.abs(anomaly);
const value = mag < 0.5
? 'About average'
: `${anomaly >= 0 ? '+' : ''}${mag.toFixed(1)}° ${anomaly >= 0 ? 'warmer' : 'cooler'}`;
return [{
icon: '🌡',
label: '1991-2020 Average',
value,
alert: anomaly >= 5,
}];
})();
// ── Good Driving Time ────────────────────────────────────────────────── // ── Good Driving Time ──────────────────────────────────────────────────
// Shown whenever a road speed (not Static) is selected and the vehicle cabin // Shown whenever a road speed (not Static) is selected and the vehicle cabin
// column is active - so it appears in both the Vehicle and Driver profiles. // column is active - so it appears in both the Vehicle and Driver profiles.
@@ -527,6 +554,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [ return [
...climateItem,
{ {
icon: '⏱', icon: '⏱',
label: 'Best field work', label: 'Best field work',
@@ -563,6 +591,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 29); const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 29);
return [ return [
...climateItem,
...(show('vehicleT') ? [{ ...(show('vehicleT') ? [{
icon: '🌡', icon: '🌡',
label: 'Peak cabin temp', label: 'Peak cabin temp',
@@ -601,6 +630,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0));
return [ return [
...climateItem,
...(show('indoorT') ? [{ ...(show('indoorT') ? [{
icon: '🌡', icon: '🌡',
label: 'Peak indoor (unmanaged)', label: 'Peak indoor (unmanaged)',
@@ -648,6 +678,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
: null; : null;
return [ return [
...climateItem,
{ {
icon: '⏱', icon: '⏱',
label: `Best ${variant} window`, label: `Best ${variant} window`,
@@ -690,6 +721,7 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
: null; : null;
return [ return [
...climateItem,
...(drivingShown ? [drivingItem] : []), ...(drivingShown ? [drivingItem] : []),
{ {
icon: '🌤', icon: '🌤',
+2 -2
View File
@@ -100,7 +100,7 @@ export function useAppState() {
return localStorage.getItem('sunscope_pro') === '1'; return localStorage.getItem('sunscope_pro') === '1';
}); });
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro); const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
useEffect(() => { track('visit'); }, []); useEffect(() => { track('visit'); }, []);
@@ -617,7 +617,7 @@ export function useAppState() {
// location // location
location, setLocationAndSave, recentLocations, location, setLocationAndSave, recentLocations,
// forecast // forecast
forecast, airQuality, loading, error, now, fetchedAt, forecast, airQuality, loading, error, now, fetchedAt, normals,
// search // search
searchQuery, setSearchQuery, searchQuery, setSearchQuery,
searchResults, setSearchResults, searchResults, setSearchResults,
+88 -1
View File
@@ -39,6 +39,7 @@ const FIFTEEN_MIN_MS = 15 * 60 * 1000;
const THIRTY_MIN_MS = 30 * 60 * 1000; const THIRTY_MIN_MS = 30 * 60 * 1000;
const SIX_HOURS_MS = 6 * 60 * 60 * 1000; const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
const ONE_MIN_MS = 1 * 60 * 1000; const ONE_MIN_MS = 1 * 60 * 1000;
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
function buildForecastUrl(loc) { function buildForecastUrl(loc) {
return `https://api.open-meteo.com/v1/forecast` + return `https://api.open-meteo.com/v1/forecast` +
@@ -85,6 +86,85 @@ function soilCacheKey(loc) {
return `sunscope_soil_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; return `sunscope_soil_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
} }
// ─── CLIMATE NORMALS (ERA5 archive) ───────────────────────────────────────
// Open-Meteo's Historical Archive gives daily mean temps back to 1940. We pull
// the WMO reference period (1991-2020) once per location and reduce it into a
// 366-entry day-of-year table of "normal" daily-mean temperature, so the app
// can show how far the forecast for a day sits above/below its seasonal norm.
// Normals don't change, so this caches for a long time.
function buildClimateUrl(loc) {
return `https://archive-api.open-meteo.com/v1/archive` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&start_date=1991-01-01&end_date=2020-12-31` +
`&daily=temperature_2m_mean&timezone=auto`;
}
// Coarser key (~2 dp ≈ 1 km) so nearby lookups share one cached climatology.
function climateCacheKey(loc) {
return `sunscope_normals_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`;
}
// Cumulative days before each month on a leap-year calendar, so every date
// maps to a stable day-of-year slot (Mar 1 is always 60, Feb 29 is 59).
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
function doyFromKey(key) {
const m = parseInt(key.slice(5, 7), 10);
const d = parseInt(key.slice(8, 10), 10);
return LEAP_CUM[m - 1] + (d - 1); // 0..365
}
// Reduce raw daily means into a smoothed 366-entry day-of-year normals table.
function reduceNormals(daily) {
const times = daily?.time;
const means = daily?.temperature_2m_mean;
if (!times || !means) return null;
const sums = new Array(366).fill(0);
const counts = new Array(366).fill(0);
for (let i = 0; i < times.length; i++) {
const v = means[i];
if (v == null) continue;
const doy = doyFromKey(times[i]);
sums[doy] += v;
counts[doy] += 1;
}
const raw = sums.map((s, i) => (counts[i] > 0 ? s / counts[i] : null));
// ±7-day circular smoothing to remove single-day sampling noise.
const WIN = 7;
const smooth = new Array(366).fill(null);
for (let i = 0; i < 366; i++) {
let s = 0, n = 0;
for (let k = -WIN; k <= WIN; k++) {
const v = raw[(i + k + 366) % 366];
if (v != null) { s += v; n += 1; }
}
if (n > 0) smooth[i] = s / n;
}
return smooth;
}
// Fetches (or reads cached) climate normals for a location. Returns a
// 366-entry array of daily-mean °C, or null on any failure (feature hides).
async function fetchClimateNormals(loc) {
const key = climateCacheKey(loc);
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, normals } = JSON.parse(cached);
if (normals && Date.now() - ts < SIXTY_DAYS_MS) return normals;
}
} catch (e) { /* ignore bad cache */ }
try {
const r = await fetch(buildClimateUrl(loc));
if (!r.ok) return null;
const json = await r.json();
const normals = reduceNormals(json.daily);
if (!normals) return null;
try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), normals })); } catch (e) {}
return normals;
} catch (e) { return null; } // network failure — feature just hides
}
// Fetches ICON soil hourly data with its own cache. // Fetches ICON soil hourly data with its own cache.
// Returns stale cache of any age if the fetch fails — caller uses this as last resort. // Returns stale cache of any age if the fetch fails — caller uses this as last resort.
async function fetchSoilHourly(loc) { async function fetchSoilHourly(loc) {
@@ -130,6 +210,7 @@ export function useForecast(location, isPro = false) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
const [fetchedAt, setFetchedAt] = useState(null); const [fetchedAt, setFetchedAt] = useState(null);
const [normals, setNormals] = useState(null);
// Mirrors the latest forecast so the loader's catch block can tell whether // Mirrors the latest forecast so the loader's catch block can tell whether
// data is already on screen, even inside stale setInterval closures. // data is already on screen, even inside stale setInterval closures.
@@ -236,6 +317,12 @@ export function useForecast(location, isPro = false) {
useEffect(() => { useEffect(() => {
loadForecast(location); loadForecast(location);
loadAirQuality(location); loadAirQuality(location);
// Climate normals are supplementary — load them without blocking. Clear
// first so a previous location's normals don't linger if this fetch fails.
setNormals(null);
if (location?.lat != null) {
fetchClimateNormals(location).then(n => { if (n) setNormals(n); });
}
}, [location]); }, [location]);
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ───── // ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
@@ -276,5 +363,5 @@ export function useForecast(location, isPro = false) {
return () => clearInterval(id); return () => clearInterval(id);
}, [location, isPro]); }, [location, isPro]);
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev }; return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
} }
+4
View File
@@ -143,5 +143,9 @@
"profiles": { "profiles": {
"basic": 1 "basic": 1
} }
},
"2026-07-07": {
"visits": 10,
"profiles": []
} }
} }
+8 -9
View File
@@ -2,22 +2,21 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 174 173" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> <svg width="100%" height="100%" viewBox="0 0 174 173" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"> <g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)">
<circle cx="681.984" cy="199.45" r="83.299" style="fill:rgb(245,237,214);stroke:rgb(30,18,8);stroke-width:9.13px;"/> <circle cx="681.984" cy="199.45" r="83.299" style="fill:rgb(245,237,214);"/>
<g>
<clipPath id="_clip1"> <clipPath id="_clip1">
<circle cx="681.984" cy="199.45" r="83.299"/> <circle cx="681.984" cy="199.45" r="83.299"/>
</clipPath> </clipPath>
<g clip-path="url(#_clip1)"> <g clip-path="url(#_clip1)">
<g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"> <g id="Sun" transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)">
<path d="M666.992,233.077C666.395,230.658 666.178,228.632 666.178,226.03C666.178,208.679 680.265,194.592 697.616,194.592C714.967,194.592 729.054,208.679 729.054,226.03C729.054,228.412 728.989,230.935 728.486,233.166" style="fill:none;stroke:rgb(200,146,42);stroke-width:7.48px;"/> <path d="M666.992,233.077C666.395,230.658 666.178,228.632 666.178,226.03C666.178,208.679 680.265,194.592 697.616,194.592C714.967,194.592 729.054,208.679 729.054,226.03C729.054,228.412 728.989,230.935 728.486,233.166" style="fill:rgb(244,176,71);stroke:rgb(244,176,71);stroke-width:7.48px;"/>
</g> </g>
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"> <g id="Ar-right" transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)">
<path d="M724.932,291.813C777.359,291.813 826.586,305.625 869.17,329.804" style="fill:none;stroke:rgb(30,18,8);stroke-width:13.64px;"/> <path d="M724.932,291.813C777.359,291.813 826.586,305.625 869.17,329.804" style="fill:none;stroke:black;stroke-width:13.64px;"/>
</g>
<g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)">
<path d="M580.844,329.719C623.393,305.592 672.566,291.813 724.932,291.813" style="fill:none;stroke:rgb(30,18,8);stroke-width:13.64px;"/>
</g> </g>
<g id="Arc-left" transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)">
<path d="M580.844,329.719C623.393,305.592 672.566,291.813 724.932,291.813" style="fill:none;stroke:black;stroke-width:13.64px;"/>
</g> </g>
</g> </g>
<circle cx="681.984" cy="199.45" r="83.299" style="fill:none;stroke:black;stroke-width:9.13px;"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB