New Logo
Updated cache system now 15/30mins but scope updates every minute
Font update
This commit is contained in:
fraxle
2026-06-04 14:07:34 +01:00
parent efc9b0eed9
commit ede875f0d0
100 changed files with 231 additions and 106 deletions
+12 -7
View File
@@ -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
View File
@@ -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)}`}
+5 -5
View File
@@ -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>`;
})}
+56 -19
View File
@@ -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,
};
}
+2 -1
View File
@@ -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,
+30 -17
View File
@@ -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 };
}