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
+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,