From 073e6c6b23ed5323f058a938f90108df76e10ded Mon Sep 17 00:00:00 2001 From: fraxle Date: Thu, 21 May 2026 13:21:05 +0100 Subject: [PATCH] 1.7.1 Split code into smaller sections and fix things that broke along the way --- assets/js/app.js | 972 ++------------------------------ assets/js/components/DayTabs.js | 445 +++++++++++++++ assets/js/hooks/useAppState.js | 442 +++++++++++++++ 3 files changed, 941 insertions(+), 918 deletions(-) create mode 100644 assets/js/components/DayTabs.js create mode 100644 assets/js/hooks/useAppState.js 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`
Acquiring forecast data…
`} - ${forecast && days.length > 0 && html` - <${Fragment}> - -
- - -
- ${days.map((d, i) => { - const band = confidenceBand(i); - const locked = !isPro && i >= FREE_DAYS; - const isActive = i === selectedDay; - // d.key is "YYYY-MM-DD" in location-local time — parse as UTC so - // toLocaleDateString with timeZone:'UTC' reads the correct weekday/date. - const dDate = new Date(d.key + 'T00:00Z'); - const dayName = i === 0 ? 'Today' - : i === 1 ? 'Tomorrow' - : dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); - const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v)); - const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null; - const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null; - - // ── Day-tab weather icon ─────────────────────────────────── - // Use daytime rows (elev > 0) where available, else all rows. - // Pick the modal cloud category and sum precip/snow to decide - // whether to show a PrecipIcon or a CloudIcon. - const dayRows = d.rows.filter(r => r.elev > 0); - const repRows = dayRows.length > 0 ? dayRows : d.rows; - const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); - const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); - // Modal cloud category (most frequent among daytime hours) - const catCounts = {}; - repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; }); - const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear'; - // Representative solar elevation: midday row or median of daytime rows - const midRow = repRows[Math.floor(repRows.length / 2)]; - const repElev = midRow ? midRow.elev : 45; - const repDt = midRow ? midRow.dt : dDate; - // Show PrecipIcon when total daytime precip ≥ 0.3 mm or snow ≥ 0.1 cm - const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; - - return html` - `; - })} -
-
- - - ${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 → - - -
-
`; - })()} - - ${(() => { - 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` - `; - })} - - <${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; - } - try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ } - setActiveProfile('outdoors'); - setOutdoorsVariantAndSave(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; - } - try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ } - setActiveProfile('outdoors'); - setOutdoorsVariantAndSave(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` - `; - })} - -
+ ${forecast && days.length > 0 && html`<${DayTabs} + days=${days} + selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} + isPro=${isPro} + proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} + proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} + activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} + visibleCols=${visibleCols} + activityOptions=${activityOptions} placeOptions=${placeOptions} + activityValue=${activityValue} activityLabel=${activityLabel} + placeValue=${placeValue} placeLabel=${placeLabel} + outdoorsVariant=${outdoorsVariant} setOutdoorsVariantAndSave=${setOutdoorsVariantAndSave} + setActiveProfile=${setActiveProfile} setVisibleCols=${setVisibleCols} + setIndoorMode=${setIndoorMode} setIndoorManaged=${setIndoorManaged} + dayTabsRef=${dayTabsRef} canScrollLeft=${canScrollLeft} canScrollRight=${canScrollRight} + scrollDayTabs=${scrollDayTabs} + />`} ${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html` @@ -1670,7 +807,6 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html` + +
+ ${days.map((d, i) => { + const band = confidenceBand(i); + const locked = !isPro && i >= FREE_DAYS; + const isActive = i === selectedDay; + const dDate = new Date(d.key + 'T00:00Z'); + const dayName = i === 0 ? 'Today' + : i === 1 ? 'Tomorrow' + : dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); + const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v)); + const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null; + const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null; + + // Day-tab weather icon - use daytime rows where available + const dayRows = d.rows.filter(r => r.elev > 0); + const repRows = dayRows.length > 0 ? dayRows : d.rows; + const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); + const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); + const catCounts = {}; + repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; }); + const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear'; + const midRow = repRows[Math.floor(repRows.length / 2)]; + const repElev = midRow ? midRow.elev : 45; + const repDt = midRow ? midRow.dt : dDate; + const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; + + return html` + `; + })} +
+ + + ${proPromptDay !== null && days[proPromptDay] && (() => { + const promptDate = new Date(days[proPromptDay].key + 'T00:00Z'); + const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', 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 → + + +
+
`; + })()} + + ${(() => { + 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` + `; + })} + + <${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; + } + try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ } + setActiveProfile('outdoors'); + setOutdoorsVariantAndSave(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; + } + try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ } + setActiveProfile('outdoors'); + setOutdoorsVariantAndSave(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` + `; + })} +
+ + `; +} diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js new file mode 100644 index 0000000..2f3fa04 --- /dev/null +++ b/assets/js/hooks/useAppState.js @@ -0,0 +1,442 @@ +// ------------------------------------------------------------------------ +// hooks/useAppState.js - All state and side-effect logic for UTCIForecast. +// +// Extracted from app.js to keep the main component under the AI edit +// safe-zone. This hook owns every useState, useEffect, useCallback and +// useRef that app.js needs, returning them as a single flat object. +// +// Usage in app.js: +// const state = useAppState(); +// const { forecast, isPro, visibleCols, ... } = state; +// +// Reading order: +// 1. Location + search +// 2. Day-tab scroll refs +// 3. Pro tier +// 4. Profile + column visibility +// 5. Skin, vehicle, indoor, pollen +// 6. Table refs +// 7. Column popup + table scroll hooks +// 8. Geocoding search effect +// 9. Computation - rows, days, current row +// 10. Banner - events, snooze, slideshow +// ------------------------------------------------------------------------ + +import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js'; +import { + FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, + profileButtonOrder, variantIcons, + activityVariantKeys, placeVariantKeys, +} from '../config.js'; +import { utciCategory } from '../utils.js'; +import { buildHourlyRows } from '../compute.js'; +import { useForecast } from './useForecast.js'; +import { useColumnPopup } from './useColumnPopup.js'; +import { useTableScroll } from './useTableScroll.js'; +import { getActiveEvents, getLensEvent } from '../events.js'; + +export function useAppState() { + + // ── 1. LOCATION ────────────────────────────────────────────────────── + 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' }; + }); + + const setLocationAndSave = (loc) => { + try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ } + setLocation(loc); + }; + + const { forecast, airQuality, loading, error, now } = useForecast(location); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [selectedDay, setSelectedDay] = useState(0); + const [proPromptDay, setProPromptDay] = useState(null); + const [proPromptSource, setProPromptSource] = useState('day'); + + // ── 2. DAY-TAB SCROLL ──────────────────────────────────────────────── + const dayTabsRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + 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' }); + }; + + // ── 3. PRO TIER ────────────────────────────────────────────────────── + const [isPro, setIsPro] = useState(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('pro') === '1') { + localStorage.setItem('sunscope_pro', '1'); + window.history.replaceState({}, '', window.location.pathname); + return true; + } + return localStorage.getItem('sunscope_pro') === '1'; + }); + + // ── 4. PROFILE + COLUMN VISIBILITY ─────────────────────────────────── + const [activeProfile, setActiveProfile] = useState(() => { + try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; } + }); + + const [visibleCols, setVisibleCols] = useState(() => { + try { + const saved = 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 variantCols = OUTDOORS_VARIANTS[savedVariant]?.cols + ?? FILTER_PROFILES.outdoors.cols; + return { ...variantCols }; + } + 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] })); + + 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); + } + }; + + // ── 5. SKIN, VEHICLE, INDOOR, POLLEN ───────────────────────────────── + const [skinType, setSkinType] = useState('II'); + const [vehicleType, setVehicleType] = useState('car'); + const [vehicleVent, setVehicleVent] = useState(false); + + 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); + // Sync the column toggle buttons to the variants own cols so the UI matches + // the autoloaded selection - eg Beach selects Dew. Sun. UV-B and Running selects RH. Pollen. UTCI. + const variantCols = OUTDOORS_VARIANTS[v]?.cols; + if (variantCols) { + setVisibleCols({ ...variantCols }); + const hasIndoor = variantCols['indoorT'] || variantCols['managedT']; + setIndoorMode(hasIndoor ? 'on' : 'off'); + } + }; + + const activeCols = activeProfile === 'outdoors' + ? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols) + : (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols); + + 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'; } + }); + + 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); + }; + + const searchTimeout = useRef(null); + + // Derived selectors used by profile controls + 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 ? ' 🔒' : ''}`, + }; + }); + + const activityValue = activeProfile === 'farming' + ? 'farming' + : activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant) + ? outdoorsVariant + : 'off'; + const activityLabel = activityOptions.find((o) => o.value === activityValue)?.label; + const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant) + ? outdoorsVariant + : 'off'; + const placeLabel = placeOptions.find((o) => o.value === placeValue)?.label; + + // ── 6. TABLE REFS ──────────────────────────────────────────────────── + const headStickyRef = useRef(null); + const headTrackRef = useRef(null); + const headTableRef = useRef(null); + const bodyScrollRef = useRef(null); + const bodyTableRef = useRef(null); + const tableWrapRef = useRef(null); + + // ── 7. COLUMN POPUP + TABLE SCROLL ─────────────────────────────────── + const { + colPopup, colPopupRef, + handleThClick, handleThEnter, handleThLeave, + handlePopupEnter, handlePopupLeave, + eventTagPopup, eventTagPopupRef, + evSlideIndex, evTransition, evSlideTo, + handleEventTagClick, handleEventTagEnter, handleEventTagLeave, + handleEventTagPopupEnter, handleEventTagPopupLeave, + closePopup, closeEventTagPopup, + } = useColumnPopup(); + + const { + tableCanScrollLeft, + tableCanScrollRight, + handleBodyScroll, + } = useTableScroll({ + headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, + forecast, visibleCols, selectedDay, skinType, vehicleType, + indoorMode, indoorManaged, + }); + + // ── 8. 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]); + + // ── 9. COMPUTATION ─────────────────────────────────────────────────── + const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({ + forecast, airQuality, location, vehicleType, vehicleVent, buildingType, + }); + + const visible = days[selectedDay]?.rows || []; + + const nowLocalISO = new Date(now.getTime() + utcOffsetMs).toISOString().slice(0, 13); + 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' }; + + // ── 10. BANNER + EVENTS ─────────────────────────────────────────────── + const [dismissedEventIds, setDismissedEventIds] = useState([]); + + 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); + const [bannerPrevIndex, setBannerPrevIndex] = useState(null); + 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); + + // Only show banner when events first appear - not on every re-render + // Using a ref to track previous length avoids re-showing during dismiss animation + const prevActiveEventsLenRef = useRef(0); + useEffect(() => { + const prev = prevActiveEventsLenRef.current; + prevActiveEventsLenRef.current = activeEvents.length; + if (activeEvents.length > 0 && prev === 0) setBannerVisible(true); + }, [activeEvents.length]); + + const bannerSlideTo = useCallback((next) => { + const stage = bannerStageRef.current; + if (stage) { + 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'); + 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); + }, []); + + const dismissBanner = useCallback((evId) => { + // Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events + // synchronously on the very next render. so writing here would cause the banner to be + // unmounted before the fade animation can start - which is what made it snap closed. + // We snooze AFTER the animation finishes inside the setTimeout below. + requestAnimationFrame(() => { + setBannerVisible(false); + setTimeout(() => { + try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {} + setDismissedEventIds(ids => [...ids, evId]); + setBannerIndex(0); + }, 500); + }); + }, []); + + 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]); + + useEffect(() => { + if (bannerIndex >= activeEvents.length) setBannerIndex(0); + }, [activeEvents.length]); + + // ── RETURN ALL STATE + HANDLERS ─────────────────────────────────────── + return { + // location + location, setLocationAndSave, + // forecast + forecast, airQuality, loading, error, now, + // search + searchQuery, setSearchQuery, + searchResults, setSearchResults, + searching, + // day selection + selectedDay, setSelectedDay, + proPromptDay, setProPromptDay, + proPromptSource, setProPromptSource, + // day-tab scroll + dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, + // pro + isPro, setIsPro, + // profile + activeProfile, setActiveProfile, + activateProfile, activeCols, + visibleCols, setVisibleCols, toggleCol, + activityOptions, placeOptions, + activityValue, activityLabel, + placeValue, placeLabel, + // skin, vehicle, indoor, pollen + skinType, setSkinType, + vehicleType, setVehicleType, + vehicleVent, setVehicleVent, + outdoorsVariant, setOutdoorsVariantAndSave, + buildingType, setBuildingType, + indoorManaged, setIndoorManaged, + indoorMode, setIndoorMode, + pollenType, setPollenTypeAndSave, + // table refs + headStickyRef, headTrackRef, headTableRef, + bodyScrollRef, bodyTableRef, tableWrapRef, + // column popup + colPopup, colPopupRef, + handleThClick, handleThEnter, handleThLeave, + handlePopupEnter, handlePopupLeave, + eventTagPopup, eventTagPopupRef, + evSlideIndex, evTransition, evSlideTo, + handleEventTagClick, handleEventTagEnter, handleEventTagLeave, + handleEventTagPopupEnter, handleEventTagPopupLeave, + closePopup, closeEventTagPopup, + // table scroll + tableCanScrollLeft, tableCanScrollRight, handleBodyScroll, + // computation + hourlyRows, days, utcOffsetMs, + visible, nowLocalISO, currentRow, currentCat, + // banner + events + activeEvents, lensEvent, selectedDayEvents, + bannerIndex, bannerTransition, bannerPrevIndex, + bannerVisible, bannerStageRef, + bannerSlideTo, dismissBanner, + }; +}