2.6
New Logo Updated cache system now 15/30mins but scope updates every minute Font update
This commit is contained in:
+12
-7
@@ -81,6 +81,7 @@ export function UTCIForecast() {
|
||||
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
||||
hourlyRows, days, utcOffsetMs,
|
||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||
liveElev,
|
||||
activeEvents, lensEvent, selectedDayEvents,
|
||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||
bannerVisible, bannerStageRef,
|
||||
@@ -203,6 +204,8 @@ export function UTCIForecast() {
|
||||
// ─── 3b. PANEL COMPUTATIONS ──────────────────────────────────────────
|
||||
// Derived from currentRow and the active day - no side effects.
|
||||
const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]);
|
||||
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
|
||||
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
|
||||
const glanceSummary = computeGlanceSummary(
|
||||
days[selectedDay]?.rows ?? [],
|
||||
activeProfile,
|
||||
@@ -254,8 +257,8 @@ export function UTCIForecast() {
|
||||
<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-logo" aria-hidden="true">
|
||||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">SUN</span><span class="nav-logo-scope">Scope</span></span>
|
||||
<span class="nav-logo-tag">See the sun the way your skin does.</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.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-tag">See the world the way your skin does.</span>
|
||||
</div>
|
||||
<div class="nav-links" id="nav-drawer">
|
||||
<a href="./index.html">Forecast</a>
|
||||
@@ -347,9 +350,9 @@ export function UTCIForecast() {
|
||||
<div class="utci-header">
|
||||
<div class="header-left">
|
||||
<h1 class="utci-title">
|
||||
<span class="title-sun">SUN</span><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.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>
|
||||
</h1>
|
||||
<div class="utci-tagline">See the sun 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-current-loc">
|
||||
<button
|
||||
@@ -382,7 +385,9 @@ export function UTCIForecast() {
|
||||
</span>
|
||||
</div>
|
||||
${fetchedAt && !loading && html`
|
||||
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
|
||||
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
|
||||
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>`}
|
||||
${searchOpen && html`
|
||||
<div class="utci-search-wrap utci-search-overlay">
|
||||
<input
|
||||
@@ -428,8 +433,8 @@ export function UTCIForecast() {
|
||||
value=${currentRow?.utciAdj ?? null}
|
||||
cat=${currentCat}
|
||||
loading=${loading}
|
||||
elev=${currentRow?.elev ?? 0}
|
||||
dt=${currentRow?.dt ?? new Date()}
|
||||
elev=${liveElev ?? currentRow?.elev ?? 0}
|
||||
dt=${now}
|
||||
glob=${currentRow?.glob ?? 0}
|
||||
activeEvent=${lensEvent}
|
||||
utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null}
|
||||
|
||||
+11
-1
@@ -390,8 +390,18 @@ export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
|
||||
return html`
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||
${category === 'clear' && html`
|
||||
${category === 'clear' && elev >= 0 && html`
|
||||
${sun(size * 0.50, size * 0.38, size * 0.16)}`}
|
||||
${category === 'clear' && elev < 0 && html`
|
||||
<path d=${`M ${size*0.45} ${size*0.15}
|
||||
A ${size*0.20} ${size*0.20} 0 1 1 ${size*0.45} ${size*0.55}
|
||||
A ${size*0.235} ${size*0.235} 0 1 0 ${size*0.45} ${size*0.15} Z`}
|
||||
fill=${brass} opacity="0.85" />
|
||||
<circle cx=${size*0.82} cy=${size*0.22} r=${size*0.030} fill=${brass} opacity="0.75" />
|
||||
<circle cx=${size*0.20} cy=${size*0.28} r=${size*0.022} fill=${brass} opacity="0.65" />
|
||||
<circle cx=${size*0.75} cy=${size*0.60} r=${size*0.025} fill=${brass} opacity="0.70" />
|
||||
<circle cx=${size*0.18} cy=${size*0.66} r=${size*0.018} fill=${brass} opacity="0.55" />
|
||||
<circle cx=${size*0.88} cy=${size*0.48} r=${size*0.018} fill=${brass} opacity="0.55" />`}
|
||||
${category === 'wispy' && html`
|
||||
${mist(size * 0.18, brass)}
|
||||
${line(size * 0.18, size * 0.56, size * 0.60, size * 0.56, muted, sw * 0.7, 0.6)}`}
|
||||
|
||||
@@ -395,17 +395,17 @@ export function DayTabs({
|
||||
${dayHi !== null && html`
|
||||
<span style=${{
|
||||
display: 'block',
|
||||
fontFamily: 'Fraunces, serif',
|
||||
fontStyle: 'italic',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontStyle: 'normal',
|
||||
fontSize: '13px',
|
||||
marginTop: '2px',
|
||||
letterSpacing: 0,
|
||||
textTransform: 'none',
|
||||
textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)',
|
||||
}}>
|
||||
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
|
||||
<span style=${{ color: '#c0622a', fontWeight: 800 }}>${dayHi}°</span>
|
||||
<span style=${{ opacity: 0.5 }}> / </span>
|
||||
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
|
||||
<span style=${{ color: '#3a6080', fontWeight: 800 }}>${dayLo}°</span>
|
||||
</span>`}
|
||||
</button>`;
|
||||
})}
|
||||
|
||||
@@ -116,11 +116,41 @@ export function checkHeatSpike(rows) {
|
||||
// wet + calm → rain icon (🌧️)
|
||||
// windy + wet → storm icon (🌩️), and the wind/rain icons are suppressed
|
||||
// for that hour so a single hour never shows two icons.
|
||||
const GUST_BLUSTERY = 25 / 2.237; // m/s (= 25 mph) - matches the table's gust highlight
|
||||
const GUST_STORM = 20; // m/s ≈ 45 mph - storm-force
|
||||
const GUST_BLUSTERY = 25 / 2.237; // m/s (= 25 mph) - Beaufort Force 6 threshold
|
||||
const GUST_STORM = 20; // m/s ≈ 45 mph - Beaufort Force 8+ threshold
|
||||
const PRECIP_WET = 5; // mm/h - meaningful rain
|
||||
const PRECIP_HEAVY = 10; // mm/h - heavy rain
|
||||
|
||||
// Beaufort scale lookup - force number and name from gust speed in m/s
|
||||
function beaufortForce(ms) {
|
||||
const mph = ms * 2.237;
|
||||
if (mph >= 73) return { force: 12, name: 'Hurricane Force' };
|
||||
if (mph >= 64) return { force: 11, name: 'Violent Storm' };
|
||||
if (mph >= 55) return { force: 10, name: 'Storm Force' };
|
||||
if (mph >= 47) return { force: 9, name: 'Strong Gale' };
|
||||
if (mph >= 39) return { force: 8, name: 'Gale' };
|
||||
if (mph >= 32) return { force: 7, name: 'Near Gale' };
|
||||
return { force: 6, name: 'Strong Breeze' };
|
||||
}
|
||||
|
||||
const BEAUFORT_DESC = {
|
||||
6: 'Large branches in motion; umbrellas used with difficulty.',
|
||||
7: 'Whole trees in motion; inconvenience felt when walking against the wind.',
|
||||
8: 'Twigs break off trees and generally impedes progress.',
|
||||
9: 'Slight structural damage possible — chimney pots and slates at risk.',
|
||||
10: 'Trees uprooted; considerable structural damage expected.',
|
||||
11: 'Very rarely experienced inland; widespread damage expected.',
|
||||
12: 'Devastating conditions — avoid all outdoor activity.',
|
||||
};
|
||||
|
||||
// Banner colour scales with severity
|
||||
function windBannerColors(force) {
|
||||
if (force >= 11) return { color: '#0c1020', textColor: '#c0d8ff' };
|
||||
if (force >= 9) return { color: '#101828', textColor: '#c0d4f0' };
|
||||
if (force >= 7) return { color: '#141e2e', textColor: '#c0d0e8' };
|
||||
return { color: '#1a2030', textColor: '#c0d8f0' };
|
||||
}
|
||||
|
||||
const gustOf = r => r.gust ?? r.va ?? 0;
|
||||
const precipOf = r => r.precip ?? 0;
|
||||
const isWindyHour = r => { const g = gustOf(r); return isFinite(g) && g >= GUST_BLUSTERY; };
|
||||
@@ -135,20 +165,24 @@ export function checkWind(rows) {
|
||||
|
||||
const maxGust = Math.max(...affected.map(gustOf).filter(isFinite));
|
||||
const mph = Math.round(maxGust * 2.237);
|
||||
const isStorm = maxGust >= GUST_STORM;
|
||||
const { force, name } = beaufortForce(maxGust);
|
||||
const desc = BEAUFORT_DESC[force] || '';
|
||||
const { color, textColor } = windBannerColors(force);
|
||||
|
||||
const title = force >= 10 ? `${name} Winds`
|
||||
: force >= 8 ? `${name} Warning`
|
||||
: name;
|
||||
|
||||
return {
|
||||
id: 'wind',
|
||||
emoji: '💨',
|
||||
title: isStorm ? 'Storm-Force Winds' : 'Blustery Conditions',
|
||||
message: isStorm
|
||||
? `Storm-force gusts to ${mph} mph expected — secure loose objects and take care outdoors.`
|
||||
: `Windy spell — gusts to ${mph} mph at peak. Hold onto hats and secure loose items outdoors.`,
|
||||
color: '#1a2030',
|
||||
textColor: '#c0d8f0',
|
||||
emoji: force >= 10 ? '🌪️' : '💨',
|
||||
title,
|
||||
message: `Gusts to ${mph} mph (Beaufort Force ${force}). ${desc}`,
|
||||
color,
|
||||
textColor,
|
||||
type: 'weather',
|
||||
nightOnly: false,
|
||||
isoRange, // only show cell icon during the windy hours
|
||||
isoRange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,20 +219,23 @@ export function checkStorm(rows) {
|
||||
const maxGust = Math.max(...affected.map(gustOf).filter(isFinite));
|
||||
const maxPrecip = Math.max(...affected.map(precipOf).filter(isFinite));
|
||||
const mph = Math.round(maxGust * 2.237);
|
||||
const isSevere = maxGust >= GUST_STORM || maxPrecip >= PRECIP_HEAVY;
|
||||
const { force, name } = beaufortForce(maxGust);
|
||||
const { color, textColor } = windBannerColors(force);
|
||||
|
||||
const title = force >= 10 ? `${name} — Severe Storm`
|
||||
: force >= 8 ? `${name} with Rain`
|
||||
: 'Storm Conditions';
|
||||
|
||||
return {
|
||||
id: 'storm',
|
||||
emoji: '🌩️',
|
||||
title: isSevere ? 'Severe Storm' : 'Storm Conditions',
|
||||
message: isSevere
|
||||
? `Severe storm — driving rain to ${maxPrecip.toFixed(1)} mm/h and gusts to ${mph} mph. Avoid travel if you can.`
|
||||
: `Stormy spell — wind and rain together, gusts to ${mph} mph with ${maxPrecip.toFixed(1)} mm/h rain. Take care outdoors.`,
|
||||
color: '#141a26',
|
||||
textColor: '#c0d0e8',
|
||||
title,
|
||||
message: `Gusts to ${mph} mph (Beaufort Force ${force}) with ${maxPrecip.toFixed(1)} mm/h rain. ${BEAUFORT_DESC[force] || ''} Take care outdoors.`,
|
||||
color,
|
||||
textColor,
|
||||
type: 'weather',
|
||||
nightOnly: false,
|
||||
isoRange, // only show cell icon during the stormy hours
|
||||
isoRange,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function useAppState() {
|
||||
return localStorage.getItem('sunscope_pro') === '1';
|
||||
});
|
||||
|
||||
const { forecast, airQuality, loading, error, now, fetchedAt } = useForecast(location, isPro);
|
||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
@@ -637,6 +637,7 @@ export function useAppState() {
|
||||
// computation
|
||||
hourlyRows, days, utcOffsetMs,
|
||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||
liveElev,
|
||||
// banner + events
|
||||
activeEvents, lensEvent, selectedDayEvents,
|
||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||
|
||||
@@ -32,11 +32,13 @@
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
|
||||
import { solarElevationDeg } from '../physics.js';
|
||||
|
||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const FIFTEEN_MIN_MS = 15 * 60 * 1000;
|
||||
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const TWO_MIN_MS = 2 * 60 * 1000;
|
||||
const ONE_MIN_MS = 1 * 60 * 1000;
|
||||
|
||||
function buildForecastUrl(loc) {
|
||||
return `https://api.open-meteo.com/v1/forecast` +
|
||||
@@ -137,7 +139,7 @@ export function useForecast(location, isPro = false) {
|
||||
// ─── FORECAST LOADER ──────────────────────────────────────────────
|
||||
//
|
||||
// Waterfall:
|
||||
// 1. Cache fresh (< 5 min) → serve immediately, no fetch needed
|
||||
// 1. Cache fresh (< refresh interval) → serve immediately, no fetch needed
|
||||
// 2. Cache stale / missing → fetch main auto API
|
||||
// 3. Main API soil is null → try ICON for soil data
|
||||
// 4. ICON fails → fall back to stale cache (any age)
|
||||
@@ -148,21 +150,26 @@ export function useForecast(location, isPro = false) {
|
||||
const key = forecastCacheKey(loc);
|
||||
let staleCache = null;
|
||||
|
||||
// Step 1 — serve from cache if fresh enough
|
||||
// Step 1 — stale-while-revalidate: serve any cached data immediately,
|
||||
// then continue to fetch fresh data silently in the background.
|
||||
// Only show the loading spinner if there is nothing cached at all.
|
||||
const cacheMaxAge = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (cached) {
|
||||
const { ts, data } = JSON.parse(cached);
|
||||
if (Date.now() - ts < FIVE_MIN_MS) {
|
||||
setForecast(data);
|
||||
setFetchedAt(new Date(ts));
|
||||
return;
|
||||
}
|
||||
staleCache = { ts, data }; // keep for step 4
|
||||
staleCache = { ts, data };
|
||||
// Always show cached data immediately - no spinner
|
||||
setForecast(data);
|
||||
setFetchedAt(new Date(ts));
|
||||
// If fresh enough, no need to re-fetch
|
||||
if (Date.now() - ts < cacheMaxAge) return;
|
||||
}
|
||||
} catch (e) { /* ignore bad cache */ }
|
||||
|
||||
setLoading(true); setError(null);
|
||||
// Only show spinner if nothing could be served from cache
|
||||
if (!staleCache) { setLoading(true); }
|
||||
setError(null);
|
||||
try {
|
||||
// Step 2 — fetch main forecast
|
||||
const r = await fetch(buildForecastUrl(loc));
|
||||
@@ -231,17 +238,23 @@ export function useForecast(location, isPro = false) {
|
||||
loadAirQuality(location);
|
||||
}, [location]);
|
||||
|
||||
// ─── 2-MINUTE CLOCK TICK (current-row highlight only) ─────────────
|
||||
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), TWO_MIN_MS);
|
||||
const id = setInterval(() => setNow(new Date()), ONE_MIN_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ─── AUTO REFRESH (Pro: 5 min, free: 15 min) ─────────────────────
|
||||
// Open-Meteo updates roughly every 15 min; Pro users get a tighter
|
||||
// poll so the current-conditions row stays fresher.
|
||||
// ─── LIVE SOLAR ELEVATION (updates every minute with now tick) ─────
|
||||
const [liveElev, setLiveElev] = useState(0);
|
||||
useEffect(() => {
|
||||
const refreshMs = isPro ? FIVE_MIN_MS : FIFTEEN_MIN_MS;
|
||||
if (location?.lat == null) return;
|
||||
setLiveElev(solarElevationDeg(location.lat, location.lon, now));
|
||||
}, [now, location]);
|
||||
|
||||
// ─── AUTO REFRESH (Pro: 15 min, free: 30 min) ─────────────────────
|
||||
// Open-Meteo updates roughly every 15 min; Pro gets tighter polling.
|
||||
useEffect(() => {
|
||||
const refreshMs = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
|
||||
const id = setInterval(() => {
|
||||
async function refresh() {
|
||||
const key = forecastCacheKey(location);
|
||||
@@ -263,5 +276,5 @@ export function useForecast(location, isPro = false) {
|
||||
return () => clearInterval(id);
|
||||
}, [location, isPro]);
|
||||
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt };
|
||||
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
|
||||
}
|
||||
Reference in New Issue
Block a user