diff --git a/assets/js/app.js b/assets/js/app.js index 63433b7..b47d944 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,7 +21,6 @@ // ════════════════════════════════════════════════════════════════════════ 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, @@ -30,368 +29,41 @@ import { 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'; +import { getCellTagEvents, getUpcomingEvents } from './events.js'; +import { POLLEN_TYPES, COL_DESCRIPTIONS } from './config.js'; +import { useAppState } from './hooks/useAppState.js'; +import { DayTabs } from './components/DayTabs.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'; - }); - - // ── FUTURE FEATURE v2: Dark Mode + Theme System (JSON-driven) ─────────────── - // Add a `theme` state that drives a data-theme attribute on
or .utci-app. - // CSS custom properties do all the heavy lifting — no JS colour logic needed. - // - // THE THEME FILE: /assets/themes.json - // All themes live in a single JSON file. Easy to read, easy to edit — - // add a new theme by copying an existing block and changing the values. - // No build step, no JS changes required for new themes. - // - // Each theme has: - // id — unique key used in localStorage and data-theme attribute - // name — human-readable label (shown in theme picker UI) - // description — one-liner explaining the look/feel - // base — which base mode it extends: "light" | "dark" - // trigger — how/when it activates (see trigger types below) - // colors — the CSS custom properties it overrides (only what changes) - // - // Example themes.json: - // [ - // { - // "id": "light", - // "name": "Light (Default)", - // "description": "Classic parchment and brass in full daylight", - // "base": "light", - // "trigger": { "type": "manual" }, - // "colors": { - // "--bg": "#f5edd6", - // "--surface": "#ede0c4", - // "--brass": "#c8922a", - // "--text": "#2a1a08", - // "--text-muted": "#9a7d5a", - // "--accent": "#c8922a" - // } - // }, - // { - // "id": "dark", - // "name": "Dark", - // "description": "Deep walnut and glowing brass — made for night", - // "base": "dark", - // "trigger": { "type": "manual" }, - // "colors": { - // "--bg": "#1a1208", - // "--surface": "#261a0a", - // "--brass": "#c8922a", - // "--text": "#f5edd6", - // "--text-muted": "#9a7d5a", - // "--accent": "#d4a030" - // } - // }, - // { - // "id": "christmas", - // "name": "Christmas", - // "description": "Holly green with crimson brass — Dec 20 to Jan 2", - // "base": "dark", - // "trigger": { "type": "date", "from": "12-20", "to": "01-02" }, - // "colors": { - // "--bg": "#0f1f0f", - // "--surface": "#1a3a1a", - // "--brass": "#b8312f", - // "--text": "#f0ede0", - // "--text-muted": "#7a9a7a", - // "--accent": "#c8922a" - // } - // }, - // { - // "id": "halloween", - // "name": "Halloween", - // "description": "Near-black with amber and burnt orange — Oct 25–31", - // "base": "dark", - // "trigger": { "type": "date", "from": "10-25", "to": "10-31" }, - // "colors": { - // "--bg": "#100a00", - // "--surface": "#1e1000", - // "--brass": "#cc6600", - // "--text": "#f0d080", - // "--text-muted": "#8a6020", - // "--accent": "#e07820" - // } - // }, - // { - // "id": "heatwave", - // "name": "Heatwave Alert", - // "description": "Deep red pulse when a dangerous heat day is forecast", - // "base": "light", - // "trigger": { "type": "alert", "alertType": "heat" }, - // "colors": { - // "--brass": "#b82020", - // "--accent": "#c83030" - // } - // } - // ] - // - // TRIGGER TYPES: - // "manual" — user picks it from the theme switcher in the nav, persisted - // to localStorage ('sunscope_theme') - // "date" — auto-applies between MM-DD dates (wraps year-end correctly) - // user can still override manually; auto resets when out of range - // "alert" — activates when daySummary (compute.js) fires a matching alert; - // subtle, temporary — reverts when alert clears - // "system" — mirrors the OS dark/light preference (prefers-color-scheme) - // - // HOW IT WORKS IN JS: - // 1. Fetch /assets/themes.json once on load (or bundle it as an import) - // 2. Walk the trigger rules to find the highest-priority active theme - // Priority: alert > manual > date > system - // 3. Apply the winning theme's colors as inline CSS variables on - // Object.entries(theme.colors).forEach(([k,v]) => - // document.body.style.setProperty(k, v)) - // 4. Store active theme id in localStorage for manual overrides - // 5. A small theme-picker icon (🎨 or ☀️/🌙) in the top nav lets the - // user browse and manually select any theme - // - // const [theme, setTheme] = useState(() => - // localStorage.getItem('sunscope_theme') || 'system'); - // ───────────────────────────────────────────────────────────────────────────── - - // (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.) - - // Current active filter profile - persisted in localStorage - const [activeProfile, setActiveProfile] = useState(() => { - try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return '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(() => { - try { - const saved = localStorage.getItem('sunscope_profile') || 'basic'; - return { ...(FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols) }; - } catch (e) { return { ...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 - persisted in localStorage - const [outdoorsVariant, setOutdoorsVariant] = useState(() => { - try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; } - }); - const setOutdoorsVariantAndSave = (v) => { - try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ } - setOutdoorsVariant(v); - }; - - // 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(() => { - try { - const saved = localStorage.getItem('sunscope_profile') || 'basic'; - const cols = FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols; - return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off'; - } catch (e) { return '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]; - try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ } - 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. + // ── STATE + EFFECTS ─────────────────────────────────────────────────── + // All useState, useEffect, useCallback and useRef logic lives in + // useAppState. See hooks/useAppState.js for the full reading order. const { + location, setLocationAndSave, + forecast, airQuality, loading, error, now, + searchQuery, setSearchQuery, searchResults, searching, + selectedDay, setSelectedDay, + proPromptDay, setProPromptDay, + proPromptSource, setProPromptSource, + dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, + isPro, setIsPro, + activeProfile, setActiveProfile, activateProfile, activeCols, + visibleCols, setVisibleCols, toggleCol, + activityOptions, placeOptions, + activityValue, activityLabel, placeValue, placeLabel, + skinType, setSkinType, + vehicleType, setVehicleType, + vehicleVent, setVehicleVent, + outdoorsVariant, setOutdoorsVariantAndSave, + buildingType, setBuildingType, + indoorManaged, setIndoorManaged, + indoorMode, setIndoorMode, + pollenType, setPollenTypeAndSave, + headStickyRef, headTrackRef, headTableRef, + bodyScrollRef, bodyTableRef, tableWrapRef, colPopup, colPopupRef, handleThClick, handleThEnter, handleThLeave, handlePopupEnter, handlePopupLeave, @@ -400,166 +72,14 @@ export function UTCIForecast() { 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, - indoorMode, indoorManaged, - }); - - // 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([]); - - // ------------------------------------------------------------------ - // BANNER_SNOOZE_HOURS - how long to suppress a banner after dismissal. - // Change this value to adjust the snooze duration. Future builds can - // expose this as a user-facing setting and pass the value in here. - // ------------------------------------------------------------------ - const BANNER_SNOOZE_HOURS = 6; - const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000; - const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`; - const [bannerIndex, setBannerIndex] = useState(0); - const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null - const [bannerPrevIndex, setBannerPrevIndex] = useState(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); - const bannerStageRef = useRef(null); - useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]); - - const isSnoozed = id => { - try { - const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id)); - return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS; - } catch (e) { return false; } - }; - - const todayRows = days[0]?.rows || []; - const activeEvents = getActiveEvents(todayRows, location) - .filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(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]); - - // True crossfade: render prev + next simultaneously, prev fades out on top - // Height is locked to current value then released after transition so it - // animates smoothly between slides of different heights. - const bannerSlideTo = useCallback((next) => { - const stage = bannerStageRef.current; - if (stage) { - // Lock current height so CSS transition has a start point - stage.style.height = stage.offsetHeight + 'px'; - stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)'; - } - setBannerPrevIndex(bannerIndexRef.current); - setBannerIndex(next); - setBannerTransition('crossfading'); - // After Preact re-renders with new slide, measure new height and animate to it - setTimeout(() => { - if (stage) { - const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)'); - if (incoming) stage.style.height = incoming.offsetHeight + 'px'; - } - }, 16); - setTimeout(() => { - setBannerTransition(null); - setBannerPrevIndex(null); - if (stage) { stage.style.height = ''; stage.style.transition = ''; } - }, 460); - }, []); - - // Dismiss with animation: fade stage → collapse wrap → remove event - const dismissBanner = useCallback((evId) => { - // 1. Record the dismissal time so we can suppress for BANNER_SNOOZE_HOURS - try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {} - // 2. Fade the stage out (CSS handles this via .visible class removal) - setBannerVisible(false); - // 3. 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]); + tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, + hourlyRows, days, utcOffsetMs, + visible, nowLocalISO, currentRow, currentCat, + activeEvents, lensEvent, selectedDayEvents, + bannerIndex, bannerTransition, bannerPrevIndex, + bannerVisible, bannerStageRef, + bannerSlideTo, dismissBanner, + } = useAppState(); // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM @@ -739,406 +259,23 @@ export function UTCIForecast() { ${loading && !error && html`