Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels.
Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab.
Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest.
Anchors on the reference page — 35 row ids in columns.html, one per column key.
New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added.
Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
This commit is contained in:
Fraxle
2026-08-03 18:06:09 +01:00
parent 2035edad23
commit 8f161dc8cb
18 changed files with 668 additions and 123 deletions
+103 -9
View File
@@ -39,7 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';
@@ -88,6 +88,28 @@ function interpolateRowAt(rows, t) {
};
}
// Turn a raw fetch failure into something a person can act on. The banner only
// ever appears when nothing at all has loaded (see the loadForecast waterfall
// in hooks/useForecast.js), so "showing your last saved forecast" is never the
// right thing to say here - there isn't one.
function friendlyError(msg) {
const m = String(msg || '');
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return "You're offline. Reconnect and try again.";
}
if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
return "Couldn't reach the weather service. Check your connection and try again.";
}
const status = m.match(/HTTP (\d{3})/);
if (status) {
const code = Number(status[1]);
if (code === 429) return 'Too many requests just now. Give it a minute and try again.';
if (code >= 500) return "The weather service isn't responding. Try again in a moment.";
return `The weather service rejected the request (error ${code}).`;
}
return m;
}
// ── Draggable 24-hour timeline ──────────────────────────────────────────
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
const trackRef = useRef(null);
@@ -179,7 +201,9 @@ export function UTCIForecast() {
// useAppState. See hooks/useAppState.js for the full reading order.
const {
location, setLocationAndSave, recentLocations,
forecast, airQuality, loading, error, now, fetchedAt, normals,
useMyLocation, locating, locateError, setLocateError,
shareForecast, shareState,
forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
selectedDay, setSelectedDay,
proPromptDay, setProPromptDay,
@@ -539,6 +563,14 @@ export function UTCIForecast() {
: null;
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
// CAMS air quality runs out well before the 14-day forecast does, so the AQI
// and Pollen columns hit a wall partway along the day tabs. Without saying so
// the empty cells read as a bug, which is worse than the missing data.
const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon);
const aqBeyondNote = aqBeyond
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
: null;
const glanceSummary = computeGlanceSummary(
days[selectedDay]?.rows ?? [],
activeProfile,
@@ -553,6 +585,9 @@ export function UTCIForecast() {
normals,
days[selectedDay]?.key,
);
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
// rendered in its own "The Week Ahead" box beneath it.
const weekAhead = computeBestDay(days, activeProfile, outdoorsVariant, nowLocalISO);
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
const glanceDate = (() => {
@@ -829,10 +864,51 @@ export function UTCIForecast() {
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
aria-label="Use my current location"
title="Use my current location"
disabled=${locating}
onClick=${useMyLocation}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="utci-loc-action"
aria-label="Share this forecast"
title="Copy a link to this forecast"
onClick=${shareForecast}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
${(locateError || shareState) && html`
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
${locateError
? locateError
: shareState === 'copied' ? 'Link copied' : "Couldn't copy the link"}
${locateError && html`
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
onClick=${() => setLocateError(null)}>×</button>`}
</div>`}
${fetchedAt && !loading && html`
<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' })}
@@ -1014,8 +1090,12 @@ export function UTCIForecast() {
</div>
${error && html`
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
${error}
<div class="utci-status" role="alert" aria-live="polite"
style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
<span class="utci-status-msg">⚠ ${friendlyError(error)}</span>
<button type="button" class="utci-status-retry" onClick=${retry} disabled=${loading}>
${loading ? 'Retrying…' : 'Retry'}
</button>
</div>`}
${loading && !error && html`
<div class="acquiring-overlay">
@@ -1377,8 +1457,8 @@ export function UTCIForecast() {
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class="col-unit">EU idx</span></th>`}
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class="col-unit">grains/m³</span></th>`}
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'EU idx'}</span></th>`}
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'grains/m³'}</span></th>`}
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
@@ -1655,7 +1735,7 @@ export function UTCIForecast() {
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`;
return html`<td class=${groupStart('aqi')} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
return html`<td class=${groupStart('aqi')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.pollen && (() => {
const pollenMap = {
@@ -1679,7 +1759,7 @@ export function UTCIForecast() {
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`;
return html`<td class=${groupStart('pollen')} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
return html`<td class=${groupStart('pollen')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.uvA && html`
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
@@ -1737,6 +1817,16 @@ export function UTCIForecast() {
];
})()}
</div>`}
${weekAhead.length > 0 && html`
<div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div>
${weekAhead.map(({ icon, label, value, grp }) => html`
<div class=${`insight-row${grp ? ` insight-row--${grp}` : ''}`} key=${label}>
<span class="insight-icon">${icon}</span>
<span class="insight-label">${label}</span>
<span class="insight-value">${value}</span>
</div>`)}
</div>`}
${visible.length > 0 && html`
<div class="export-panel">
${isPro
@@ -1774,7 +1864,11 @@ export function UTCIForecast() {
>
<button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}</p>
${COL_DESCRIPTIONS[colPopup.key].link && html`
<a class="col-info-more" href=${COL_DESCRIPTIONS[colPopup.key].link} target="_blank" rel="noopener noreferrer">
More about this column →
</a>`}
</div>`}
${eventTagPopup && (() => {
+118 -9
View File
@@ -18,7 +18,7 @@ import {
calcFurSurfaceTempPass,
} from './physics.js';
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR } from './config.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open;
@@ -723,10 +723,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
grp: 'felt',
}] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
@@ -778,10 +778,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
grp: 'felt',
}] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
grp: 'airqual',
}] : []),
@@ -825,10 +825,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
alert: !!(peakUvRow && peakUvRow.uv >= 6),
grp: 'solar',
}] : []),
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
}] : []),
...(show('pollen') && maxPollen >= 10 ? [{
@@ -878,10 +878,10 @@ export function computeGlanceSummary(todayRows, profile, variant, skinType, cols
}] : []),
...(show('precipProb') ? [rainItem] : []),
...lightningItem,
...(show('aqi') ? [{
...(show('aqi') && peakAqi > 0 ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? aqiLabel(peakAqi) : '-',
value: aqiLabel(peakAqi),
alert: peakAqi >= 60,
grp: 'airqual',
}] : []),
@@ -958,6 +958,115 @@ function bestRun(arr, pred) {
return best;
}
// ------------------------------------------------------------------------
// computeBestDay(weekDays, profile, variant, nowLocalISO) - the day with the
// longest unbroken run of pleasant outdoor hours, for "The Week Ahead".
//
// The app already works out comfort windows for the selected day, but
// comparing days meant tapping through all fourteen tabs. This answers the
// question the day tabs make you do by hand.
//
// Deliberately NOT part of computeGlanceSummary: "At a glance" describes the
// one selected day, and a week-scoped line reads as a category error inside
// it. It renders in its own panel below the rail instead.
//
// Scored on utciAdj (SunSoak) rather than the profile's own main field.
// Every row has it whatever profile is active, and it is the number that
// actually answers "would I enjoy being outside" - unlike vehicleT or
// indoorT, where a "best day" framing would be meaningless.
//
// Daylight hours only, and hours already past today are skipped, so a warm
// morning that has been and gone can't win.
// ------------------------------------------------------------------------
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
const IDEAL_UTCI = 20; // centre of the "no thermal stress" band
function comfortableHour(r) {
return r.elev > 0 // daylight
&& r.utciAdj != null && r.utciAdj >= 9 && r.utciAdj <= 28 // no thermal stress
&& (r.precipProb ?? 0) < 50 // unlikely to rain on you
&& (r.gust ?? r.va ?? 0) * 2.237 < 32; // below near-gale
}
export function computeBestDay(weekDays, profile, variant, nowLocalISO) {
if (!weekDays || weekDays.length === 0) return [];
const candidates = [];
for (const day of weekDays.slice(0, 7)) {
if (!day?.rows?.length) continue;
// Drop hours that have already passed - only ever bites on day 0.
const rows = nowLocalISO
? day.rows.filter(r => r.iso.slice(0, 13) >= nowLocalISO)
: day.rows;
let start = -1, len = 0, dayBest = null;
for (let i = 0; i < rows.length; i++) {
if (comfortableHour(rows[i])) {
if (start < 0) start = i;
len++;
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
} else {
start = -1; len = 0;
}
}
if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue;
// How far the run sits from an ideal ~20 °C, averaged. Length alone can't
// separate days: in a temperate summer week half of them run comfortable
// from dawn to dusk, and picking arbitrarily among those is a coin toss.
const run = rows.slice(dayBest.start, dayBest.end + 1);
const miss = run.reduce((s, r) => s + Math.abs(r.utciAdj - IDEAL_UTCI), 0) / run.length;
candidates.push({
len: dayBest.len, miss, key: day.key,
from: rows[dayBest.start], to: rows[dayBest.end],
daylight: rows.filter(r => r.elev > 0).length,
});
}
if (candidates.length === 0) return [];
// Longest run wins, but anything within an hour of the longest counts as a
// tie and is settled on which day is actually the most pleasant.
const maxLen = Math.max(...candidates.map(c => c.len));
const best = candidates
.filter(c => c.len >= maxLen - 1)
.sort((a, b) => a.miss - b.miss)[0];
const hh = (iso) => {
const h = parseInt(iso.slice(11, 13), 10);
return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`;
};
// Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back
// as UTC to stop the browser's own zone shifting the weekday.
const dayName = new Date(`${best.key}T00:00:00Z`)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' });
// The panel title already says "The Week Ahead", so the row just names what
// is being judged rather than repeating the timeframe.
const label = profile === 'outdoors' && variant
? `Best for ${deriveProfileMain(profile, variant).mainLabel}`
: 'Best day out';
// The end row is the last comfortable hour, so the window runs to the end
// of it - 9am-12pm means 9:00 up to 12:59.
const endIso = `${best.to.iso.slice(0, 11)}${String((parseInt(best.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`;
// A run spanning nearly all the daylight is better said than shown: printing
// "6am - 9pm" makes the reader parse a time range to learn "all day".
const window = best.daylight > 0 && best.len >= best.daylight - 1
? 'comfortable all day'
: `${hh(best.from.iso)} ${hh(endIso)}`;
return [{
icon: '📅',
label,
value: `${dayName} · ${window}`,
alert: false,
grp: 'felt',
}];
}
export function computeCropAdvice(weekDays, lat) {
if (!weekDays || weekDays.length === 0) return [];
const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length);
+40 -35
View File
@@ -260,42 +260,47 @@ export const placeVariantKeys = ['urban', 'park', 'forest', 'beach', 'events', '
export const workVariantKeys = ['farming', 'construction', 'market', 'windowcleaning', 'driver', 'office'];
// Tooltip text shown when the user clicks/hovers a table column header.
//
// `short` is the one- or two-sentence summary the popup actually shows; `desc`
// is the full write-up, still used for the day-tab metric descriptions via
// deriveProfileMain. `link` deep-links to that column's row on the reference
// page (the row ids in columns.html) for the rest of the detail.
export const COL_DESCRIPTIONS = {
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:0011:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.' },
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.' },
shadeT: { title: 'Shade', desc: 'SunSoak with the sun taken away: the felt temperature standing in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. You are still outdoors, so wind, humidity and rain hit you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of. An estimate of a typical shaded spot, not a measurement.' },
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.' },
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.' },
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.' },
dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).' },
cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.' },
sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.' },
direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.' },
diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.' },
tmrt: { title: 'Mean Radiant Temp', desc: 'The combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Typically well above air temperature even on overcast days, and is one of the inputs used to calculate UTCI.' },
delta: { title: 'UTCI Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.' },
utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.' },
uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.' },
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.' },
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.' },
utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.' },
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.' },
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss.' },
lightning: { title: 'Lightning Potential', desc: 'Lightning Potential Index (LPI) from Open-Meteo in J/kg. A convective energy index — values above 5 suggest moderate lightning risk, above 25 suggest high risk.' },
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 710 °C.' },
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.' },
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.' },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.' },
vehicleT: { title: 'Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.' },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.' },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.' },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.' },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.' },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.' },
furSurfaceT: { title: 'Fur Temp', desc: 'Estimated fur surface temperature a cat or small dog experiences in direct sun, calibrated for a thin/short coat rather than a heavily-insulated breed. Runs hotter than air temperature on sunny, still days — set fur colour with the selector alongside this column.' },
pawT: { title: 'Paw Temp', desc: 'Estimated ground/pavement surface temperature for paw contact, colour-coded by burn risk: green below 40 °C (safe), amber 4052 °C (discomfort / possible burn on prolonged contact), red above 52 °C (can burn within 60 seconds). If you can\'t hold the back of your hand on the surface for 7 seconds, it\'s too hot to walk your pet.' },
petShadeT: { title: 'Pet Shade', desc: 'Shaded air temperature for a resting pet, using the same shade microclimate model as the Shade column but kept as an air temperature — a human felt-temperature index says little about an animal — and with a lower, pet-specific alert threshold — small pets have far less capacity to dump heat than humans.' },
petHomeT: { title: 'Pet Home', desc: 'Indoor temperature estimate for a pet left home alone, using the same model as the Indoors column but with a lower, pet-specific alert threshold (26 °C) rather than the human heatwave guidance threshold.' },
hour: { title: 'Rows', desc: 'Local wall-clock time for this forecast row. Each row covers the interval set by the stepper in this header, clock-aligned from midnight — at 3h, a row labelled 09:00 spans 09:0011:59. Totals like rainfall are summed across the block, risk figures show the block\'s peak, and smooth readings like air temperature are averaged.', short: "The local time each row covers. The stepper in this header sets the block size — totals are summed across the block, risks show its peak, and steady readings are averaged.", link: "./columns.html#col-hour" },
air: { title: 'Air Temperature', desc: 'Measured air temperature at 2 m above the ground. This is the standard thermometer reading — it does not account for sun, wind, or humidity.', short: "Thermometer reading at 2 m above the ground. Ignores sun, wind and humidity.", link: "./columns.html#col-air" },
shadeT: { title: 'Shade', desc: 'SunSoak with the sun taken away: the felt temperature standing in the typical shade of the selected Solar Model — building shadow, beach umbrella, tree canopy, boat panels, and so on. You are still outdoors, so wind, humidity and rain hit you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of. An estimate of a typical shaded spot, not a measurement.', short: "Felt temperature standing in typical shade for the selected Solar Model. Wind, humidity and rain still reach you — only the sun is blocked.", link: "./columns.html#col-shadeT" },
rh: { title: 'Relative Humidity', desc: 'How much moisture the air holds relative to its maximum capacity at that temperature. High RH makes warm days feel stickier and cold days feel rawer.', short: "Moisture in the air as a percentage of what it could hold at that temperature. High RH makes warm days stickier and cold days rawer.", link: "./columns.html#col-rh" },
dew: { title: 'Dew Point', desc: 'The temperature at which air becomes saturated and moisture begins to condense. A useful measure of absolute humidity — above 16 °C it starts to feel muggy; above 21 °C it feels oppressive.', short: "The temperature at which air saturates and moisture condenses — a good measure of absolute humidity. Above 16 °C feels muggy.", link: "./columns.html#col-dew" },
wind: { title: 'Wind Speed', desc: 'Mean wind speed at 10 m in mph, with peak gust in brackets where significantly higher. Gust colour indicates Beaufort scale severity: amber = Near Gale (32+ mph), orange = Gale (39+ mph), red = Severe Gale or above (55+ mph). Banners above the table classify conditions from Strong Breeze through to Violent Storm using standard wind categories. Wind dramatically increases heat loss from exposed skin — the basis of wind chill.', short: "Mean wind speed at 10 m, with the peak gust in brackets. Gust colour follows the Beaufort scale.", link: "./columns.html#col-wind" },
dir: { title: 'Wind Direction', desc: 'The compass direction the wind is blowing from, shown as an arrow and abbreviated label (e.g. SW = south-westerly).', short: "The compass direction the wind is blowing from, as an arrow and short label.", link: "./columns.html#col-dir" },
cloud: { title: 'Cloud Cover', desc: 'Total cloud cover as a percentage of the sky. High cloud blocks solar radiation and reduces both daytime heating and overnight cooling.', short: "Total cloud cover as a percentage of the sky. Cloud cuts both daytime heating and overnight cooling.", link: "./columns.html#col-cloud" },
sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.', short: "The angle of the sun above the horizon. Below 0° the sun has set; higher angles mean stronger radiation.", link: "./columns.html#col-sun" },
direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.', short: "Solar radiation arriving in a direct beam from the sun (W/m²) — the main driver of skin heating on sunny days.", link: "./columns.html#col-direct" },
diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.', short: "Solar radiation scattered across the sky (W/m²). Present even under cloud, and adds to the total solar load.", link: "./columns.html#col-diffuse" },
tmrt: { title: 'Mean Radiant Temp', desc: 'The combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Typically well above air temperature even on overcast days, and is one of the inputs used to calculate UTCI.', short: "Heat from direct sun, sky light and ground reflection expressed as a temperature. One of the inputs to UTCI.", link: "./columns.html#col-tmrt" },
delta: { title: 'UTCI Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.', short: "The gap between UTCI felt temperature and plain air temperature. A big positive value means the sun is adding real heat stress.", link: "./columns.html#col-delta" },
utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.', short: "The peer-reviewed standard felt temperature. Always taken from the raw open-ground reading, so it stays fixed as a benchmark to read SunSoak against.", link: "./columns.html#col-utci" },
uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.', short: "Estimated UV-A index. UV-A reaches deeper into the skin and drives long-term ageing, even through glass.", link: "./columns.html#col-uvA" },
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.', short: "Estimated UV-B index. UV-B causes sunburn and drives vitamin D production.", link: "./columns.html#col-uvB" },
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.', short: "Estimated time to sunburn (one MED) for your selected skin type. A guide, not a medical measurement.", link: "./columns.html#col-burn" },
utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.', short: "SunScope's felt-temperature index: UTCI plus adjustments for rain or snow and your chosen surroundings.", link: "./columns.html#col-utciP" },
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.', short: "Expected rainfall in mm per hour, or snow in cm. Even drizzle cuts felt temperature noticeably in wind.", link: "./columns.html#col-precip" },
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss.', short: "Chance of precipitation this hour, from ensemble model runs. Useful for spotting showers the main forecast misses.", link: "./columns.html#col-precipProb" },
lightning: { title: 'Lightning Potential', desc: 'Lightning Potential Index (LPI) from Open-Meteo in J/kg. A convective energy index — values above 5 suggest moderate lightning risk, above 25 suggest high risk.', short: "Lightning Potential Index in J/kg. Above 5 suggests moderate risk, above 25 high risk.", link: "./columns.html#col-lightning" },
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 710 °C.', short: "Soil temperature at the surface. Most seeds germinate above 710 °C.", link: "./columns.html#col-soilT" },
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.', short: "Soil temperature at 6 cm — the root zone for many crops. Lags the surface by several hours.", link: "./columns.html#col-soilT6" },
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.', short: "Water saturation of the top 1 cm of soil. Above 40% is saturated ground; below 20% is dry.", link: "./columns.html#col-soilM" },
concreteT: { title: 'Concrete Surface', desc: 'Estimated temperature of sun-exposed urban concrete or paving. Concrete absorbs more solar energy than grass and cannot cool itself through evaporation — surface temps can run 1525 °C above air temperature on sunny days.', short: "Estimated temperature of sun-exposed paving, which can run 1525 °C above air temperature.", link: "./columns.html#col-concreteT" },
vehicleT: { title: 'Interior', desc: 'Estimated ambient cabin temperature inside a parked vehicle. Cars heat rapidly through thin body panels and glass; motorhomes and caravans are modelled as insulated occupied living spaces that warm more slowly but hold heat longer, with gain through the windscreen and rooflights and a little retained living-space warmth. Accounts for ambient wind over the bodywork, road speed, and whether the windows are open. Dangerous for children and pets above 35 °C; potentially fatal above 45 °C.', short: "Estimated cabin temperature inside a parked vehicle. Dangerous for children and pets above 35 °C.", link: "./columns.html#col-vehicleT" },
indoorT: { title: 'Indoors', desc: 'Estimated ambient temperature inside a selected building type with windows closed and no air conditioning. Accounts for retained warmth, window solar gain, internal gains, and thermal lag without repeatedly accumulating solar heat hour after hour.', short: "Estimated temperature inside your selected building type with windows closed and no cooling.", link: "./columns.html#col-indoorT" },
managedT: { title: 'Managed Indoors', desc: 'Estimated indoor temperature with curtains closed and windows opened when outdoor air is cooler than inside — the standard UK heatwave advice. Curtains block most direct solar gain; smart ventilation pulls the temperature down during cooler periods.', short: "The same building with curtains closed and windows opened when outside air is cooler — the standard heatwave advice.", link: "./columns.html#col-managedT" },
vis: { title: 'Visibility', desc: 'Horizontal visibility in kilometres, sourced from the CAMS air quality model. Values below 1 km indicate fog or very thick haze; below 10 km suggests mist, smoke, or significant pollution. Relevant for driving, flying, and photography.', short: "Horizontal visibility in km. Below 1 km means fog; below 10 km means mist, smoke or pollution.", link: "./columns.html#col-vis" },
aqi: { title: 'Air Quality Index', desc: 'European Air Quality Index (0100+), combining PM2.5, PM10, ozone, nitrogen dioxide, and sulphur dioxide. 020 = Good; 2040 = Fair; 4060 = Moderate; 6080 = Poor; 80100 = Very Poor; 100+ = Extremely Poor. Sourced from Copernicus CAMS.', short: "European Air Quality Index combining PM2.5, PM10, ozone, NO₂ and SO₂. 020 is Good, 100+ Extremely Poor.", link: "./columns.html#col-aqi" },
pollen: { title: 'Pollen', desc: 'Pollen concentration in grains per cubic metre for the selected pollen type, sourced from the Copernicus CAMS pollen forecast. Low = <10; Moderate = 1050; High = 50200; Very High = 200+. Values vary by species and season.', short: "Pollen concentration in grains per m³ for the selected type. Low is under 10; Very High is 200+.", link: "./columns.html#col-pollen" },
furSurfaceT: { title: 'Fur Temp', desc: 'Estimated fur surface temperature a cat or small dog experiences in direct sun, calibrated for a thin/short coat rather than a heavily-insulated breed. Runs hotter than air temperature on sunny, still days — set fur colour with the selector alongside this column.', short: "Estimated fur surface temperature for a cat or small dog in direct sun. Set the coat colour beside this column.", link: "./columns.html#col-furSurfaceT" },
pawT: { title: 'Paw Temp', desc: 'Estimated ground/pavement surface temperature for paw contact, colour-coded by burn risk: green below 40 °C (safe), amber 4052 °C (discomfort / possible burn on prolonged contact), red above 52 °C (can burn within 60 seconds). If you can\'t hold the back of your hand on the surface for 7 seconds, it\'s too hot to walk your pet.', short: "Estimated ground temperature for paw contact. Above 52 °C can burn within 60 seconds.", link: "./columns.html#col-pawT" },
petShadeT: { title: 'Pet Shade', desc: 'Shaded air temperature for a resting pet, using the same shade microclimate model as the Shade column but kept as an air temperature — a human felt-temperature index says little about an animal — and with a lower, pet-specific alert threshold — small pets have far less capacity to dump heat than humans.', short: "Shaded air temperature for a resting pet, with a lower, pet-specific alert threshold.", link: "./columns.html#col-petShadeT" },
petHomeT: { title: 'Pet Home', desc: 'Indoor temperature estimate for a pet left home alone, using the same model as the Indoors column but with a lower, pet-specific alert threshold (26 °C) rather than the human heatwave guidance threshold.', short: "Indoor estimate for a pet left home alone, alerting at 26 °C rather than the human threshold.", link: "./columns.html#col-petHomeT" },
};
// Seasonal crop calendar for the Farming "At a glance" sow/harvest advice.
+160 -6
View File
@@ -46,10 +46,67 @@ function track(event, profile) {
} catch (e) { /* ignore */ }
}
// ── SHARE LINK PARAMS ───────────────────────────────────────────────────
// Read once at module load, before any state initialiser or effect runs. The
// Stripe and dev-unlock effects each strip the query string on mount, so
// anything read lazily later would already be gone.
//
// Everything is validated: a link is untrusted input, and these values feed
// straight into the fetch URL and the column set. Anything unrecognised is
// dropped silently and the normal localStorage / default path takes over.
//
// Note there is deliberately no `pro` param. Pro is granted only by a
// Stripe-verified session_id or the server-checked dev token; a shareable link
// must never become an unlock. Pro-only profiles and variants are therefore
// accepted only for a visitor who is already Pro on this device.
const SHARE_PARAMS = (() => {
const out = {};
try {
const p = new URLSearchParams(window.location.search);
if (!p.has('lat') && !p.has('profile') && !p.has('day')) return out;
const lat = parseFloat(p.get('lat'));
const lon = parseFloat(p.get('lon'));
if (Number.isFinite(lat) && Number.isFinite(lon) &&
lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
// Strip control characters only - place names legitimately contain
// spaces, hyphens, apostrophes and accents.
const raw = (p.get('name') || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
out.location = {
name: raw.slice(0, 60) || `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`,
lat, lon,
country: (p.get('country') || '').slice(0, 2).toUpperCase() || '',
};
}
const alreadyPro = (() => {
try { return localStorage.getItem('sunscope_pro') === '1'; } catch (e) { return false; }
})();
const profile = p.get('profile');
if (profile && Object.prototype.hasOwnProperty.call(FILTER_PROFILES, profile) &&
(alreadyPro || !FILTER_PROFILES[profile].proOnly)) {
out.profile = profile;
}
const variant = p.get('variant');
if (variant && Object.prototype.hasOwnProperty.call(OUTDOORS_VARIANTS, variant) &&
(alreadyPro || !OUTDOORS_VARIANTS[variant].proOnly)) {
out.variant = variant;
}
const day = parseInt(p.get('day'), 10);
if (Number.isInteger(day) && day >= 0 && day < FREE_DAYS) out.day = day;
} catch (e) { /* malformed query string - ignore entirely */ }
return out;
})();
export function useAppState() {
// ── 1. LOCATION ──────────────────────────────────────────────────────
// Precedence: share link → last saved location → London.
const [location, setLocation] = useState(() => {
if (SHARE_PARAMS.location) return SHARE_PARAMS.location;
try {
const saved = localStorage.getItem('sunscope_last_location');
if (saved) return JSON.parse(saved);
@@ -88,15 +145,58 @@ export function useAppState() {
setLocation(loc);
};
// ── GEOLOCATION ──────────────────────────────────────────────────────
// Without this the first-time default is London for everyone on earth.
// Never auto-prompted: a saved location always wins, and an unrequested
// permission dialog on load is hostile. The pin button in the header is
// the only entry point.
const [locating, setLocating] = useState(false);
const [locateError, setLocateError] = useState(null);
const useMyLocation = () => {
if (!navigator.geolocation) {
setLocateError("This browser can't share your location.");
return;
}
setLocating(true);
setLocateError(null);
navigator.geolocation.getCurrentPosition(
(pos) => {
// Named "My location" rather than a place name: Open-Meteo's geocoding
// API is forward-only (?name=), with no reverse endpoint, and pulling
// in a second provider just to label a pin isn't worth the extra host.
// The header already prints the coordinates underneath the name.
setLocationAndSave({
name: 'My location',
lat: pos.coords.latitude,
lon: pos.coords.longitude,
country: '',
});
setSelectedDay(0);
setLocating(false);
track('geolocate');
},
(err) => {
setLocating(false);
setLocateError(
err.code === 1 ? 'Location permission denied.'
: err.code === 3 ? 'Timed out finding your location.'
: "Couldn't get your location."
);
},
{ timeout: 8000, maximumAge: 10 * 60 * 1000 }
);
};
// Pro tier flag is read early so useForecast can pick its refresh cadence
// (Pro: 5 min, free: 15 min). Full setup notes in the PRO TIER section below.
// (Pro: 15 min, free: 30 min). Full setup notes in the PRO TIER section below.
// Initial state trusts only what's already in localStorage - a bare
// ?session_id=... in the URL is verified against Stripe (see the effect
// below) before it's ever allowed to flip this on, so pasting/guessing a
// URL param can't grant free access.
const [isPro, setIsPro] = useState(() => localStorage.getItem('sunscope_pro') === '1');
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
const { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry } = useForecast(location, isPro);
// Just returned from a Stripe Payment Link: verify the checkout session
// server-side (verify-session.php) before granting Pro. Also records
@@ -180,7 +280,7 @@ export function useAppState() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [selectedDay, setSelectedDay] = useState(0);
const [selectedDay, setSelectedDay] = useState(SHARE_PARAMS.day ?? 0);
const [proPromptDay, setProPromptDay] = useState(null);
const [proPromptSource, setProPromptSource] = useState('day');
@@ -307,16 +407,17 @@ export function useAppState() {
// ── 4. PROFILE + COLUMN VISIBILITY ───────────────────────────────────
const [activeProfile, setActiveProfile] = useState(() => {
if (SHARE_PARAMS.profile) return SHARE_PARAMS.profile;
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
});
const [visibleCols, setVisibleCols] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
const saved = SHARE_PARAMS.profile || localStorage.getItem('sunscope_profile') || 'basic';
// If saved profile is outdoors. read columns from the saved variant - Beach. Running. etc.
// so the column buttons match the autoloaded variant on startup.
if (saved === 'outdoors') {
const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || 'urban';
const savedVariant = SHARE_PARAMS.variant || localStorage.getItem('sunscope_outdoors_variant') || 'urban';
const variantCols = OUTDOORS_VARIANTS[savedVariant]?.cols
?? FILTER_PROFILES.outdoors.cols;
return { ...variantCols };
@@ -384,6 +485,7 @@ export function useAppState() {
};
const [outdoorsVariant, setOutdoorsVariant] = useState(() => {
if (SHARE_PARAMS.variant) return SHARE_PARAMS.variant;
try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; }
});
const setOutdoorsVariantAndSave = (v) => {
@@ -658,6 +760,55 @@ export function useAppState() {
}, 300);
}, [searchQuery]);
// ── 8b. SHARE LINK ───────────────────────────────────────────────────
// Keep the address bar in step with what's on screen so the page can be
// bookmarked, pinned to a home screen, or sent to someone else.
//
// replaceState, not pushState: the back button should leave the app, not
// walk backwards through every profile the user tried.
const buildShareUrl = () => {
const p = new URLSearchParams();
p.set('lat', location.lat.toFixed(4));
p.set('lon', location.lon.toFixed(4));
if (location.name) p.set('name', location.name);
if (location.country) p.set('country', location.country);
p.set('profile', activeProfile);
if (activeProfile === 'outdoors') p.set('variant', outdoorsVariant);
if (selectedDay > 0) p.set('day', String(selectedDay));
return `${window.location.origin}${window.location.pathname}?${p}`;
};
// Skip the first run. The Stripe and dev-unlock effects both strip the query
// string on mount; syncing before they do would put params back and undo it.
const urlSyncReady = useRef(false);
useEffect(() => {
if (!urlSyncReady.current) { urlSyncReady.current = true; return; }
try {
window.history.replaceState({}, '', buildShareUrl());
} catch (e) { /* ignore - some embedded webviews block this */ }
}, [location, activeProfile, outdoorsVariant, selectedDay]);
// Share button: native sheet where available, clipboard everywhere else.
// `shareState` drives the transient "Link copied" confirmation.
const [shareState, setShareState] = useState(null); // null | 'copied' | 'failed'
const shareForecast = async () => {
const url = buildShareUrl();
track('share');
try {
if (navigator.share) {
await navigator.share({ title: `SunScope - ${location.name}`, url });
return;
}
await navigator.clipboard.writeText(url);
setShareState('copied');
} catch (e) {
// AbortError just means the user dismissed the native share sheet.
if (e && e.name === 'AbortError') return;
setShareState('failed');
}
setTimeout(() => setShareState(null), 2200);
};
// ── 9. COMPUTATION ───────────────────────────────────────────────────
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
forecast, airQuality, location, vehicleType, vehicleVent,
@@ -694,8 +845,11 @@ export function useAppState() {
return {
// location
location, setLocationAndSave, recentLocations,
useMyLocation, locating, locateError, setLocateError,
// share
shareForecast, shareState, buildShareUrl,
// forecast
forecast, airQuality, loading, error, now, fetchedAt, normals,
forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
// search
searchQuery, setSearchQuery,
searchResults, setSearchResults,
+43 -14
View File
@@ -3,8 +3,8 @@
// given location and keeps them fresh.
//
// Responsibilities:
// 1. Fetch /v1/forecast on location change, with a 5-minute
// localStorage cache. fetchedAt reflects the cache timestamp.
// 1. Fetch /v1/forecast on location change, with a localStorage cache
// (15 min Pro / 30 min free). fetchedAt reflects the cache timestamp.
// 2. Soil data (soil_temperature, soil_moisture) waterfall:
// a. Main auto API — works if the regional model includes soil.
// b. ICON Global fallback — used when the auto model returns nulls
@@ -12,23 +12,25 @@
// c. Stale soil cache — used if ICON fetch fails.
// 3. Fetch /v1/air-quality on location change, with a 6-hour
// localStorage cache (AQI / pollen update slowly).
// 4. Auto-refresh forecast every 5 minutes for Pro, 15 minutes for
// 4. Auto-refresh forecast every 15 minutes for Pro, 30 minutes for
// free (Open-Meteo updates ~every 15 min). Air quality only
// refetches if cache is stale.
// 5. Tick `now` every 2 minutes to keep the current-row highlight
// 5. Tick `now` every minute to keep the current-row highlight
// accurate without any API cost.
//
// Inputs:
// location - { lat, lon, name, country }
// isPro - boolean; true tightens auto-refresh to 5 min
// isPro - boolean; true tightens auto-refresh to 15 min
//
// Outputs:
// forecast - raw /v1/forecast response, or null
// airQuality - raw /v1/air-quality response, or null
// aqHorizon - last date ('YYYY-MM-DD') air quality covers, or null
// loading - true while the forecast fetch is in flight
// error - fetch error message, or null
// now - Date that ticks every 2 minutes (drives the current row)
// now - Date that ticks every minute (drives the current row)
// fetchedAt - Date the forecast data was last fetched or read from cache
// retry - force a fresh fetch of both, ignoring cache age
// ------------------------------------------------------------------------
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
@@ -53,13 +55,17 @@ function buildForecastUrl(loc) {
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
}
// CAMS only publishes 7 days of air quality against the forecast's 14, so the
// AQI and pollen columns run out early. The UI reads the real horizon back off
// the returned time array (see aqHorizon below) rather than trusting this
// number, so raising it later needs no other change.
function buildAirQualityUrl(loc) {
return `https://air-quality-api.open-meteo.com/v1/air-quality` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=european_aqi,` +
`grass_pollen,birch_pollen,alder_pollen,` +
`mugwort_pollen,olive_pollen,ragweed_pollen` +
`&timezone=auto&forecast_days=5`;
`&timezone=auto&forecast_days=7`;
}
function forecastCacheKey(loc) {
@@ -237,7 +243,10 @@ export function useForecast(location, isPro = false) {
// 5. No cache but data shown → keep existing data (don't show error)
// 6. Nothing loaded at all → show error
//
async function loadForecast(loc) {
// `force` is the manual Retry path: it still paints the cache first (better
// than a blank screen) but never takes the "fresh enough, skip the fetch"
// exit, so pressing Retry always goes to the network.
async function loadForecast(loc, force = false) {
const key = forecastCacheKey(loc);
let staleCache = null;
@@ -254,12 +263,13 @@ export function useForecast(location, isPro = false) {
setForecast(data);
setFetchedAt(new Date(ts));
// If fresh enough, no need to re-fetch
if (Date.now() - ts < cacheMaxAge) return;
if (!force && Date.now() - ts < cacheMaxAge) return;
}
} catch (e) { /* ignore bad cache */ }
// Only show spinner if nothing could be served from cache
if (!staleCache) { setLoading(true); }
// Only show spinner if nothing could be served from cache. A manual retry
// always spins, so the button visibly does something.
if (!staleCache || force) { setLoading(true); }
setError(null);
try {
// Step 2 — fetch main forecast
@@ -296,14 +306,14 @@ export function useForecast(location, isPro = false) {
}
// ─── AIR QUALITY LOADER ───────────────────────────────────────────
async function loadAirQuality(loc) {
async function loadAirQuality(loc, force = false) {
const key = airQualityCacheKey(loc);
let stale = null;
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) {
if (!force && Date.now() - ts < SIX_HOURS_MS) {
setAirQuality(data);
return;
}
@@ -323,6 +333,15 @@ export function useForecast(location, isPro = false) {
}
}
// ─── MANUAL RETRY ─────────────────────────────────────────────────
// Wired to the Retry button in the error banner. Forces past the cache-age
// check on both loaders so a user who taps it always gets a real attempt.
function retry() {
if (!location) return;
loadForecast(location, true);
loadAirQuality(location, true);
}
// ─── INITIAL FETCH on location change ─────────────────────────────
useEffect(() => {
loadForecast(location);
@@ -373,5 +392,15 @@ export function useForecast(location, isPro = false) {
return () => clearInterval(id);
}, [location, isPro]);
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
// ─── AIR QUALITY HORIZON ──────────────────────────────────────────
// CAMS runs out well before the 14-day forecast does. Read the last date it
// actually returned so the table can say "not forecast this far ahead"
// instead of rendering blank AQI and pollen cells, which read as a bug.
const aqHorizon = (() => {
const times = airQuality?.hourly?.time;
if (!times || times.length === 0) return null;
return times[times.length - 1].slice(0, 10); // 'YYYY-MM-DD'
})();
return { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry };
}