// ════════════════════════════════════════════════════════════════════════
// app.js — Main UTCIForecast component.
//
// This is the top-level Preact component that owns all state, fetches
// the forecast, runs the per-hour computations, and renders the page.
//
// Reading order inside UTCIForecast():
// 1. STATE (useState calls) — bits that change on interaction
// 2. EFFECTS (useEffect calls) — runs on search / location change
// 3. COMPUTATION (hourlyRows, days, …) — API data → display rows
// 4. JSX RETURN (the big html`...`) — actual page markup
//
// QUICK MAP
// ──────────────────────────────────────────────────────────────────────
// Forecast length .............. fetch URL contains &forecast_days=14
// Free tier day limit .......... const FREE_DAYS = 3
// Preview the Pro view ......... useState(false) on isPro → flip to true
// Starting location ............ useState({...}) on `location` near top
// Default columns shown ........ useState({...}) on visibleCols
// Page tagline / about copy .... search "utci-tagline" or "utci-about-text"
// ════════════════════════════════════════════════════════════════════════
import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useEffect, useRef, useCallback } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
utciCategory,
SKIN_TYPES, sunburnMinutes, burnLabel,
VEHICLE_TYPES, BUILDING_TYPES,
confidenceBand, moonGlyph,
} from './utils.js';
import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill } from './components.js';
import { getActiveEvents, getLensEvent, getCellTagEvents, getUpcomingEvents } from './events.js';
import {
FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, POLLEN_TYPES,
profileButtonOrder, variantIcons,
activityVariantKeys, placeVariantKeys,
COL_DESCRIPTIONS,
} from './config.js';
import { buildHourlyRows } from './compute.js';
import { useForecast } from './hooks/useForecast.js';
import { useColumnPopup } from './hooks/useColumnPopup.js';
import { useTableScroll } from './hooks/useTableScroll.js';
const html = htm.bind(h);
export function UTCIForecast() {
// ── 1. STATE ──────────────────────────────────────────────────────────
// Each useState() pairs a value with a setter. Calling the setter
// re-renders the page with the new value.
// The location we're forecasting for. Restored from localStorage if the
// user has visited before, otherwise defaults to Pangbourne, Berkshire.
const [location, setLocation] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_last_location');
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' };
});
// Wrapper that persists the location before updating state.
const setLocationAndSave = (loc) => {
try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
setLocation(loc);
};
// Forecast data is owned by useForecast — it fetches on location change,
// auto-refreshes every 5 minutes, and exposes the air-quality side data too.
const { forecast, airQuality, loading, error, now } = useForecast(location);
const [searchQuery, setSearchQuery] = useState(''); // text in the search box
const [searchResults, setSearchResults] = useState([]); // geocoding dropdown
const [searching, setSearching] = useState(false); // search-in-flight flag
const [selectedDay, setSelectedDay] = useState(0); // which day tab is active
const [proPromptDay, setProPromptDay] = useState(null); // locked day clicked → show upsell card
const [proPromptSource, setProPromptSource] = useState('day'); // 'day' | 'custom'
// Day-tabs horizontal scrolling — chevrons show only when there's more
// content to reveal in that direction. Auto-scrolls active tab into view.
const dayTabsRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Re-runs whenever the number of day tabs changes (e.g. when the
// forecast finishes loading and the tabs first appear). Also re-measures
// on scroll, on window resize, and via ResizeObserver if the element's
// own width changes (e.g. layout shifts when sidebar opens).
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const update = () => {
setCanScrollLeft(el.scrollLeft > 1);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
let ro = null;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(update);
ro.observe(el);
}
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
if (ro) ro.disconnect();
};
}, [forecast]);
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
const activeTab = el.querySelector('.utci-day-tab.active');
if (!activeTab) return;
const elRect = el.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
if (tabRect.left < elRect.left + 8) {
el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' });
} else if (tabRect.right > elRect.right - 8) {
el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' });
}
}, [selectedDay]);
const scrollDayTabs = (dir) => {
const el = dayTabsRef.current;
if (!el) return;
el.scrollBy({ left: dir * 200, behavior: 'smooth' });
};
// ─── PRO TIER ─────────────────────────────────────────────────────────
// Initialise from URL param (?pro=1, set by Stripe after checkout) or
// from localStorage (returning subscriber). Cleans the URL param
// immediately so it doesn't stay visible in the address bar.
const [isPro, setIsPro] = useState(() => {
// Check URL param first (just returned from Stripe checkout)
const params = new URLSearchParams(window.location.search);
if (params.get('pro') === '1') {
localStorage.setItem('sunscope_pro', '1');
// Clean the URL so the param doesn't stay visible
window.history.replaceState({}, '', window.location.pathname);
return true;
}
// Check localStorage (returning Pro subscriber)
return localStorage.getItem('sunscope_pro') === '1';
});
// (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.)
// Current active filter profile
const [activeProfile, setActiveProfile] = useState('basic');
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols });
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
const [skinType, setSkinType] = useState('II');
// Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car');
// Places profile sub-variant (urban / beach / events / festival / wintersports / naturist)
const [outdoorsVariant, setOutdoorsVariant] = useState('urban');
// Vehicle ventilation — true = windows open (high convective loss).
const [vehicleVent, setVehicleVent] = useState(false);
// Indoor: buildingType drives the physics preset; indoorManaged toggles the
// curtains+ventilation model on top. indoorMode gates column visibility:
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off');
// Pollen type for the pollen column. Persisted in localStorage.
const [pollenType, setPollenType] = useState(() => {
try { return localStorage.getItem('sunscope_pollen_type') || 'all_pollen'; } catch (e) { return 'all_pollen'; }
});
const setPollenTypeAndSave = (v) => {
try { localStorage.setItem('sunscope_pollen_type', v); } catch (e) { /* ignore */ }
setPollenType(v);
};
// (POLLEN_TYPES now lives in ./config.js.)
const searchTimeout = useRef(null);
const activateProfile = (key) => {
const profile = FILTER_PROFILES[key];
setActiveProfile(key);
if (key !== 'custom') {
setVisibleCols({ ...profile.cols });
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
setIndoorMode(hasIndoor ? 'on' : 'off');
setIndoorManaged(false);
}
};
// (profileButtonOrder, variantIcons, activityVariantKeys,
// placeVariantKeys now live in ./config.js.)
const activityOptions = [
{
value: 'farming',
label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`,
},
...activityVariantKeys.map((k) => {
const v = OUTDOORS_VARIANTS[k];
return {
value: k,
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
};
}),
];
const placeOptions = placeVariantKeys.map((k) => {
const v = OUTDOORS_VARIANTS[k];
return {
value: k,
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
};
});
// activeCols: the effective column set for the current profile/variant.
// Use this everywhere instead of FILTER_PROFILES[activeProfile].cols so that
// outdoors variants each get their own cols rather than the generic outdoors cols.
const activeCols = activeProfile === 'outdoors'
? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols)
: (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols);
const activityValue = activeProfile === 'farming'
? 'farming'
: activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
? outdoorsVariant
: 'off';
const activityLabel = activityOptions.find((option) => option.value === activityValue)?.label;
const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
? outdoorsVariant
: 'off';
const placeLabel = placeOptions.find((option) => option.value === placeValue)?.label;
// Refs for the two-scroller table layout (sticky-to-viewport header +
// horizontally-scrolling body). The header is clipped (overflow:hidden)
// and its inner "track" gets translateX'd via JS to follow the body's
// scrollLeft. See the useLayoutEffect just below where the JS sync
// happens, and the .utci-thead-sticky / .utci-tbody-scroll CSS rules.
const headStickyRef = useRef(null);
const headTrackRef = useRef(null);
const headTableRef = useRef(null);
const bodyScrollRef = useRef(null);
const bodyTableRef = useRef(null);
const tableWrapRef = useRef(null);
// ─── COLUMN HEADER & EVENT TAG POPUPS ────────────────────────────────
// All popup state + handlers live in the useColumnPopup hook. The JSX
// wires up the th cells and event-tag spans with the handlers returned
// here, and reads colPopup / eventTagPopup to know when to render the
// floating panel.
const {
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
eventTagPopup, eventTagPopupRef,
evSlideIndex, evTransition, evSlideTo,
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
} = useColumnPopup();
// (COL_DESCRIPTIONS, calcPopupPos and all the popup handlers now live in
// ./config.js and ./hooks/useColumnPopup.js respectively.)
// ─── TABLE SCROLL ────────────────────────────────────────────────────
// useTableScroll owns everything horizontal-scroll-related for the
// hourly table:
// • scroll indicators (drives fade + chevron CSS)
// • drag-to-scroll for desktop users
// • column-width sync between sticky header and body
// It needs the four table refs and the deps that should trigger a
// re-sync (anything that changes the rendered cell content / count).
const {
tableCanScrollLeft,
tableCanScrollRight,
handleBodyScroll,
} = useTableScroll({
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef,
forecast, visibleCols, selectedDay, skinType, vehicleType,
});
// Geocoding search
useEffect(() => {
if (searchQuery.length < 2) { setSearchResults([]); return; }
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
setSearching(true);
try {
const r = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(searchQuery)}&count=6&language=en&format=json`
);
const j = await r.json();
setSearchResults(j.results || []);
} catch { setSearchResults([]); }
finally { setSearching(false); }
}, 300);
}, [searchQuery]);
// (FORECAST FETCH, AIR QUALITY FETCH and 5-min AUTO-REFRESH now live in
// ./hooks/useForecast.js — called at the top of this component.)
// ─── COMPUTATION ─────────────────────────────────────────────────────
// Stitch the raw API arrays into one object per hour (UTCI + soak factor,
// indoor temps, AQI, pollen, etc) and group into days for the day tabs.
// See ./compute.js for the full algorithm.
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
forecast, airQuality, location, vehicleType, vehicleVent, buildingType,
});
const visible = days[selectedDay]?.rows || [];
// nowLocalISO: current moment in location-local time as "YYYY-MM-DDTHH"
// Used to match against r.iso (which is already a local wall-clock string).
const nowLocalISO = new Date(now.getTime() + utcOffsetMs)
.toISOString().slice(0, 13); // "YYYY-MM-DDTHH"
const currentRow = hourlyRows.length > 0
? (hourlyRows.find(row => row.iso.slice(0, 13) === nowLocalISO)
?? hourlyRows.reduce((best, row) =>
Math.abs(row.dt - now) < Math.abs(best.dt - now) ? row : best))
: null;
const currentCat = currentRow
? utciCategory(currentRow.utciAdj)
: { bg: '#4a4228', fg: '#ede4cc', label: 'No data' };
// ─── COSMIC/WEATHER EVENTS ────────────────────────────────────────────
// activeEvents — today's events → drives banner & lens overlay.
// selectedDayEvents — viewed day's events → drives cell tags.
const [dismissedEventIds, setDismissedEventIds] = useState([]);
const [bannerIndex, setBannerIndex] = useState(0);
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
// bannerVisible drives the .visible class — kept true until the collapse
// animation finishes so content doesn't vanish before the wrap closes.
const [bannerVisible, setBannerVisible] = useState(false);
const bannerIndexRef = useRef(0);
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
const todayRows = days[0]?.rows || [];
const activeEvents = getActiveEvents(todayRows, location)
.filter(ev => !dismissedEventIds.includes(ev.id));
const lensEvent = getLensEvent(activeEvents);
const selectedDayEvents = getActiveEvents(visible, location);
// Sync visibility: show as soon as events exist, hide after dismiss animation
useEffect(() => {
if (activeEvents.length > 0) setBannerVisible(true);
}, [activeEvents.length]);
// Crossfade to a new banner index: fade out → swap → fade in
const bannerSlideTo = useCallback((next) => {
setBannerTransition('exiting');
setTimeout(() => {
setBannerIndex(next);
setBannerTransition('entering');
setTimeout(() => setBannerTransition(null), 420);
}, 400);
}, []);
// Dismiss with animation: fade stage → collapse wrap → remove event
const dismissBanner = useCallback((evId) => {
// 1. fade the stage out (CSS handles this via :not(.visible))
setBannerVisible(false);
// 2. after wrap has collapsed (grid transition ~450ms), remove the event
setTimeout(() => {
setDismissedEventIds(ids => [...ids, evId]);
setBannerIndex(0);
}, 500);
}, []);
// Auto-advance banner slideshow every 10 seconds when multiple events
useEffect(() => {
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
const id = setInterval(() => {
const next = (bannerIndexRef.current + 1) % activeEvents.length;
bannerSlideTo(next);
}, 10000);
return () => clearInterval(id);
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
// Keep index in bounds if events change
useEffect(() => {
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
}, [activeEvents.length]);
// ─── 4. JSX RETURN ───────────────────────────────────────────────────
// Everything below is the actual page markup, written as one big HTM
// template. Search tips:
// • "utci-header" — the top section (title + dial + search)
// • "utci-day-tabs" — the 14 day buttons with band colours
// • "col-toggles" — the column-customisation row (Pro only)
// • "utci-table" — the hourly table itself
// • "utci-legend" — the thermal-stress band legend
// • "utci-about" — the explainer paragraphs at the bottom
// • "utci-footer" — the "reading the table" note
return html`
{ const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}>
SUN Scope
See the sun the way your body does.
{
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
${activeEvents.length > 0 && (() => {
const ev = activeEvents[bannerIndex] || activeEvents[0];
const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null;
const startFmt = fmtDate(ev.start);
const peakFmt = fmtDate(ev.peak);
const endFmt = fmtDate(ev.end);
// Only show date line if the event has start/end (cosmic events do; weather events don't)
const showDates = startFmt && endFmt;
const dateLine = showDates
? (startFmt === endFmt
? peakFmt ? `Peak: ${peakFmt}` : `Date: ${startFmt}`
: peakFmt && peakFmt !== startFmt && peakFmt !== endFmt
? `Active ${startFmt} – ${endFmt} · Peak: ${peakFmt}`
: `Active ${startFmt} – ${endFmt}`)
: null;
return html`
${ev.emoji}
${ev.title}
${ev.message}
${dateLine && html`
${dateLine}
`}
${activeEvents.length > 1 && html`
${activeEvents.map((_, i) => html`
bannerSlideTo(i)}
style=${{ background: ev.textColor }}
/>
`)}
`}
dismissBanner(ev.id)}
aria-label="Dismiss"
title="Dismiss this event"
>✕
`;
})()}
${error && html`
⚠ ${error}
`}
${loading && !error && html`
Acquiring forecast data…
`}
${forecast && days.length > 0 && html`
<${Fragment}>
${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
const extraPromptCopy = {
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
},
'profile:alltemps': {
title: 'Temps is part of SunScope Extra',
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
},
'profile:custom': {
title: 'Custom columns are part of SunScope Extra',
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
},
'variant:festival': {
title: 'Festival planning is part of SunScope Extra',
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
},
'variant:wintersports': {
title: 'Winter Sports is part of SunScope Extra',
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
},
'variant:naturist': {
title: 'Naturist is part of SunScope Extra',
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
},
};
const promptCopy = extraPromptCopy[proPromptSource] || {
title: `${dayName}'s forecast is part of SunScope Extra`,
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
};
return html`
🔒 ${promptCopy.title}
${promptCopy.detail}
£2 / month · cancel any time
Subscribe — £2/month
Already subscribed? Restore access →
Manage or cancel subscription →
setProPromptDay(null)}
style=${{
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#b09870',
padding: '2px 4px',
}}
>
dismiss
`;
})()}
${(() => {
const band = confidenceBand(selectedDay);
const isOutlook = selectedDay >= 7;
return html`
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
${isOutlook && html`
forecast skill is reduced — treat hourly detail as trend, not precision
`}
`;
})()}
Profile:
${profileButtonOrder.slice(0, 3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
{
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''} `;
})}
<${CustomSelect}
key="places"
value=${placeValue}
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
noHide=${true}
hideLabel="🌤️ Places"
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`}
options=${placeOptions}
onChange=${(v) => {
if (v === 'off') {
activateProfile('basic');
return;
}
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
<${CustomSelect}
key="activities"
value=${activityValue}
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
noHide=${true}
hideLabel="🎯 Activities"
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`}
options=${activityOptions}
onChange=${(v) => {
if (v === 'off') {
activateProfile('basic');
return;
}
if (v === 'farming') {
activateProfile('farming');
return;
}
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
setProPromptSource(`variant:${v}`);
setProPromptDay(0);
return;
}
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
}}
/>
${profileButtonOrder.slice(3).map((key) => {
const profile = FILTER_PROFILES[key];
const locked = profile.proOnly && !isPro;
return html`
{
if (locked) {
setProPromptSource(`profile:${key}`);
setProPromptDay(0);
return;
}
activateProfile(key);
}}
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''} `;
})}
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
Columns:
${isPro && (activeProfile === 'custom' || activeCols['air']) && html` toggleCol('air')}>Air `}
${isPro && (activeProfile === 'custom' || activeCols['rh']) && html` toggleCol('rh')}>RH `}
${isPro && (activeProfile === 'custom' || activeCols['dew']) && html` toggleCol('dew')}>Dew `}
${isPro && (activeProfile === 'custom' || activeCols['soilT']) && html` toggleCol('soilT')}>Soil °C `}
${isPro && (activeProfile === 'custom' || activeCols['soilT6']) && html` toggleCol('soilT6')}>Soil 6cm `}
${isPro && (activeProfile === 'custom' || activeCols['soilM']) && html` toggleCol('soilM')}>Soil moist `}
${isPro && (activeProfile === 'custom' || activeCols['concreteT']) && html` toggleCol('concreteT')}>Concrete `}
${isPro && (activeProfile === 'custom' || activeCols['wind']) && html` toggleCol('wind')}>Wind `}
${isPro && (activeProfile === 'custom' || activeCols['dir']) && html` toggleCol('dir')}>Dir `}
${isPro && (activeProfile === 'custom' || activeCols['cloud']) && html` toggleCol('cloud')}>Cloud `}
${isPro && (activeProfile === 'custom' || activeCols['vis']) && html` toggleCol('vis')}>Visibility `}
${isPro && (activeProfile === 'custom' || activeCols['aqi']) && html` toggleCol('aqi')}>Air Quality `}
${(isPro ? (activeProfile === 'custom' || activeCols['pollen']) : activeCols['pollen']) && html`<${CustomSelect}
value=${pollenType}
isOn=${visibleCols.pollen}
hideLabel="Pollen"
hidingLabel="Hide Pollen"
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
{ value: k, label: v.name },
...(i === 0 ? [{ value: '_div', divider: true }] : []),
])}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, pollen: false }));
} else {
setPollenTypeAndSave(v);
setVisibleCols(prev => ({ ...prev, pollen: true }));
}
}}
/>`}
${isPro && (activeProfile === 'custom' || activeCols['sun']) && html` toggleCol('sun')}>Sun `}
${isPro && (activeProfile === 'custom' || activeCols['direct']) && html` toggleCol('direct')}>Direct `}
${isPro && (activeProfile === 'custom' || activeCols['diffuse']) && html` toggleCol('diffuse')}>Diffuse `}
${isPro && (activeProfile === 'custom' || activeCols['tmrt']) && html` toggleCol('tmrt')}>Tmrt `}
${isPro && (activeProfile === 'custom' || activeCols['delta']) && html` toggleCol('delta')}>Δ `}
${isPro && (activeProfile === 'custom' || activeCols['utci']) && html` toggleCol('utci')}>UTCI `}
${isPro && (activeProfile === 'custom' || activeCols['uvA']) && html` toggleCol('uvA')}>UV-A `}
${isPro && (activeProfile === 'custom' || activeCols['uvB']) && html` toggleCol('uvB')}>UV-B `}
${(isPro ? (activeProfile === 'custom' || activeCols['burn']) : activeCols['burn']) && html`
<${CustomSelect}
value=${skinType}
isOn=${visibleCols.burn}
hideLabel="Burn"
hidingLabel="Hide Burn"
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({
value: k,
label: v.name.split(' · ')[1] + ' skin',
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, burn: false }));
} else {
setSkinType(v);
setVisibleCols(prev => ({ ...prev, burn: true }));
}
}}
/>`}
${(isPro ? (activeProfile === 'custom' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<${CustomSelect}
value=${vehicleType}
isOn=${visibleCols.vehicleT}
hideLabel="Vehicle"
hidingLabel="Hide Vehicle"
groupedLeft=${true}
isLastChild=${!visibleCols.vehicleT}
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setVisibleCols(prev => ({ ...prev, vehicleT: false }));
setVehicleVent(false);
} else {
setVehicleType(v);
setVisibleCols(prev => ({ ...prev, vehicleT: true }));
}
}}
/>
${visibleCols.vehicleT && html`
<${VentPill}
checked=${vehicleVent}
onChange=${() => setVehicleVent(v => !v)}
label="Ventilation"
title="Ventilation — open windows significantly reduce cabin heat build-up"
/>`}
`}
${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
<${CustomSelect}
value=${buildingType}
isOn=${indoorMode === 'on'}
hideLabel="Indoors"
hidingLabel="Hide Indoors"
groupedLeft=${true}
isLastChild=${indoorMode !== 'on'}
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({
value: k,
label: v.name,
}))}
onChange=${(v) => {
if (v === 'off') {
setIndoorMode('off');
setIndoorManaged(false);
} else {
setBuildingType(v);
setIndoorMode('on');
}
}}
/>
${indoorMode === 'on' && html`
<${VentPill}
checked=${indoorManaged}
onChange=${() => setIndoorManaged(v => !v)}
label="Managed"
title="Managed: curtains closed by day, windows open when cooler outside"
/>`}
`}
${isPro && (activeProfile === 'custom' || activeCols['precip']) && html` toggleCol('precip')}>Precip `}
`}
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
`}
${eventTagPopup && (() => {
const evs = eventTagPopup.events;
const ev = evs[evSlideIndex] || evs[0];
return html`
`;
})()}
Thermal stress bands
${[
{ l: '-27 to -13 Arctic', bg: '#3f73c4', fg: '#fff' },
{ l: '-13 to 0 Freezing', bg: '#7eb0e0', fg: '#111' },
{ l: '0 to 9 Cold', bg: '#bcd9ec', fg: '#111' },
{ l: '9 to 18 Chilled', bg: '#c8dcc0', fg: '#111' },
{ l: '18 to 26 Comfortable', bg: '#4a8a3a', fg: '#fff' },
{ l: '26 to 32 Mod heat', bg: '#e8c547', fg: '#111' },
{ l: '32 to 38 Strong heat', bg: '#dc8a3a', fg: '#111' },
{ l: '38 to 46 V. strong', bg: '#c44a3a', fg: '#fff' },
{ l: '> 46 Extreme heat', bg: '#7a1a1a', fg: '#fff' },
].map((b, i) => html`
${b.l}
`)}
${(() => {
const upcoming = getUpcomingEvents(location, 90);
const formatPeak = (iso) => {
const d = new Date(iso + 'T00:00Z');
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
};
const countdownLabel = (days) => {
if (days === 0) return 'Tonight!';
if (days === 1) return 'Tomorrow';
if (days < 7) return `In ${days} days`;
if (days < 14) return 'Next week';
if (days < 60) return `In ${Math.round(days / 7)} weeks`;
return `In ${Math.round(days / 30)} months`;
};
return html`
${upcoming.length === 0
? html`
No major cosmic events in the next 90 days — clear skies ahead.
`
: upcoming.map(ev => html`
${ev.emoji}
${ev.title}
${formatPeak(ev.peak)}
${countdownLabel(ev.daysUntil)}
${ev.desc}
${ev.visibilityNote && html`
📍 ${ev.visibilityNote}
`}
`)
}
`;
})()}
>`}
What is SunScope?
SunScope is a free hourly weather forecast built around felt temperature ,
not just air temperature. It uses the Universal Thermal Climate Index (UTCI)
— the biometeorological standard used in heat-health warning systems worldwide — to combine
air temperature, humidity, wind, and solar radiation into a single honest number. The
UTCI+P column adds an original rain and snow penalty so wet, windy days
read as cold as they feel.
Beyond felt temperature, SunScope calculates vehicle cabin heat (choose
your vehicle type; toggle windows open), indoor temperature (seven
building types; managed heatwave mode), urban concrete surface temperature ,
UV index and sunburn time by skin type, and soil temperature
and moisture for farming and motorhome use. Switch profiles to see the data
that matters for your situation — or go Custom and build your own view.
Learn more →
`;
}