3.9
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:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user