// ------------------------------------------------------------------------ // 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, workVariantKeys, UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV, } 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: 'London, England', lat: 51.509, lon: -0.126, 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, fetchedAt } = 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 }; } // For custom profile, load from config - blank slate with only Hour on. if (saved === 'custom') { return { ...FILTER_PROFILES.custom?.cols ?? FILTER_PROFILES.basic.cols }; } 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); if (hasIndoor) setBuildingTypeAndSave('brick'); const defaultEnv = VARIANT_DEFAULT_ENV[key]; if (defaultEnv) setUtciEnvAndSave(defaultEnv); } else { // Custom profile initializes from config - blank slate, user picks columns. const customCols = FILTER_PROFILES.custom?.cols; if (customCols) { setVisibleCols({ ...customCols }); const hasIndoor = customCols['indoorT'] || customCols['managedT']; setIndoorMode(hasIndoor ? 'on' : 'off'); setIndoorManaged(false); } } }; // ── 5. SKIN, VEHICLE, INDOOR, POLLEN ───────────────────────────────── const [skinType, setSkinType] = useState('II'); const [vehicleType, setVehicleType] = useState(() => { try { return localStorage.getItem('sunscope_vehicle_type') || 'car'; } catch (e) { return 'car'; } }); const setVehicleTypeAndSave = (v) => { try { localStorage.setItem('sunscope_vehicle_type', v); } catch (e) { /* ignore */ } setVehicleType(v); }; const [vehicleVent, setVehicleVent] = useState(() => { try { return localStorage.getItem('sunscope_vehicle_vent') === '1'; } catch (e) { return false; } }); const setVehicleVentAndSave = (v) => { try { localStorage.setItem('sunscope_vehicle_vent', v ? '1' : '0'); } catch (e) { /* ignore */ } setVehicleVent(v); }; 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'); } // Auto-set UTCI environment modifier to match the selected variant. const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open'; setUtciEnvAndSave(defaultEnv); }; const activeCols = activeProfile === 'outdoors' ? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols) : (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols); const [buildingType, setBuildingType] = useState(() => { try { const saved = localStorage.getItem('sunscope_building_type'); if (saved) return saved; // Fall back to office if the saved variant is office and no explicit building saved const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || ''; if (savedVariant === 'office') return 'office'; } catch (e) { /* ignore */ } return 'brick'; }); const setBuildingTypeAndSave = (v) => { try { localStorage.setItem('sunscope_building_type', v); } catch (e) { /* ignore */ } setBuildingType(v); }; const [indoorManaged, setIndoorManaged] = useState(() => { try { return localStorage.getItem('sunscope_indoor_managed') === '1'; } catch (e) { return false; } }); const setIndoorManagedAndSave = (v) => { try { localStorage.setItem('sunscope_indoor_managed', v ? '1' : '0'); } catch (e) { /* ignore */ } setIndoorManaged(v); }; const [indoorMode, setIndoorMode] = useState(() => { try { const saved = localStorage.getItem('sunscope_indoor_mode'); if (saved) return saved; const profile = localStorage.getItem('sunscope_profile') || 'basic'; const cols = FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols; return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off'; } catch (e) { return 'off'; } }); const setIndoorModeAndSave = (v) => { try { localStorage.setItem('sunscope_indoor_mode', v); } catch (e) { /* ignore */ } setIndoorMode(v); }; 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 [utciEnv, setUtciEnv] = useState(() => { try { const saved = localStorage.getItem('sunscope_utci_env'); if (saved && UTCI_ENVIRONMENTS[saved]) return saved; // No saved env - derive from the saved variant only when the outdoors // profile is active. Other profiles (Basic, Vehicle etc.) always open. const savedProfile = localStorage.getItem('sunscope_profile') || 'basic'; if (savedProfile === 'outdoors') { const savedVariant = localStorage.getItem('sunscope_outdoors_variant') || ''; return VARIANT_DEFAULT_ENV[savedVariant] ?? 'open'; } return 'open'; } catch (e) { return 'open'; } }); const setUtciEnvAndSave = (v) => { try { localStorage.setItem('sunscope_utci_env', v); } catch (e) { /* ignore */ } setUtciEnv(v); }; const [showDecimals, setShowDecimals] = useState(() => { try { const s = localStorage.getItem('sunscope_show_decimals'); return s === null ? false : s !== '0'; } catch (e) { return false; } }); const toggleShowDecimals = () => setShowDecimals(v => { const next = !v; try { localStorage.setItem('sunscope_show_decimals', next ? '1' : '0'); } catch (e) { /* ignore */ } return next; }); const [showUnits, setShowUnits] = useState(() => { try { const s = localStorage.getItem('sunscope_show_units'); if (s !== null) return s !== '0'; return typeof window !== 'undefined' ? window.innerWidth >= 640 : true; } catch (e) { return true; } }); const toggleShowUnits = () => setShowUnits(v => { const next = !v; try { localStorage.setItem('sunscope_show_units', next ? '1' : '0'); } catch (e) { /* ignore */ } return next; }); const searchTimeout = useRef(null); // Derived selectors used by profile controls const activityOptions = activityVariantKeys.map((k) => { const v = OUTDOORS_VARIANTS[k]; const locked = v.proOnly && !isPro; return { value: k, label: `${variantIcons[k]} ${v.name}${locked ? ' 🔒' : ''}`, name: `${v.name}${locked ? ' 🔒' : ''}`, scene: v.scene, icon: v.icon, disabled: locked, }; }); const placeOptions = placeVariantKeys.map((k) => { const v = OUTDOORS_VARIANTS[k]; const locked = v.proOnly && !isPro; return { value: k, label: `${variantIcons[k]} ${v.name}${locked ? ' 🔒' : ''}`, name: `${v.name}${locked ? ' 🔒' : ''}`, scene: v.scene, icon: v.icon, disabled: locked, }; }); const workOptions = workVariantKeys.map((k) => { const v = k === 'farming' ? { name: FILTER_PROFILES.farming.label, proOnly: false, scene: FILTER_PROFILES.farming.scene, icon: FILTER_PROFILES.farming.icon } : OUTDOORS_VARIANTS[k]; const locked = v.proOnly && !isPro; const iconGlyph = k === 'farming' ? FILTER_PROFILES.farming.icon : variantIcons[k]; return { value: k, label: `${iconGlyph} ${v.name}${locked ? ' 🔒' : ''}`, name: `${v.name}${locked ? ' 🔒' : ''}`, scene: v.scene, icon: v.icon || iconGlyph, disabled: locked, }; }); const activityValue = activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant) ? outdoorsVariant : 'off'; const activityLabel = activityOptions.find((o) => o.value === activityValue)?.name; const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant) ? outdoorsVariant : 'off'; const placeLabel = placeOptions.find((o) => o.value === placeValue)?.name; const workValue = activeProfile === 'farming' ? 'farming' : activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant) ? outdoorsVariant : 'off'; const workLabel = workOptions.find((o) => o.value === workValue)?.name; // ── 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, showDecimals, showUnits, }); // ── 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, utciEnv, }); 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]); // ── BANNER POSITION + SHELL PADDING ─────────────────────────────────── // The banner is position:absolute so it floats over content. We (1) pin its // top to the bottom edge of the site-nav so it sits flush below the nav on // every screen size, and (2) add matching padding-top to the shell so the // page content sits just below the banner. // On close: keep the banner pinned to the nav and animate the padding back // to 0 so content slides up smoothly as the banner collapses. const bannerMaxHeightRef = useRef(0); useEffect(() => { const shell = document.querySelector('.utci-shell'); const wrap = document.querySelector('.event-banner-wrap'); if (!shell || !wrap) return; // The site-nav's outer height is the banner's resting top edge. The banner // lives inside the shell, so translate that into the shell's coordinate // space by subtracting the shell's own offset from the page top. const bannerTop = () => { const navEl = document.getElementById('site-nav'); const navOuterHeight = navEl ? navEl.offsetHeight : 0; return navOuterHeight - shell.offsetTop; }; if (!bannerVisible) { // Keep the collapsing banner pinned to the nav's bottom edge, and animate // padding back to zero matching the banner collapse duration. wrap.style.top = bannerTop() + 'px'; shell.style.transition = 'padding-top 0.45s cubic-bezier(0.4, 0, 0.2, 1)'; shell.style.paddingTop = '0px'; const tid = setTimeout(() => { shell.style.transition = ''; shell.style.paddingTop = ''; bannerMaxHeightRef.current = 0; }, 500); return () => clearTimeout(tid); } const applyPadding = (allowShrink = false) => { const top = bannerTop(); wrap.style.top = top + 'px'; const h = top + wrap.getBoundingClientRect().height; if (allowShrink || h > bannerMaxHeightRef.current) { bannerMaxHeightRef.current = h; shell.style.transition = ''; // no transition while open/resizing shell.style.paddingTop = h + 'px'; } }; // Pin the banner immediately so it appears flush under the nav, then size // the content padding once the open animation settles. wrap.style.top = bannerTop() + 'px'; const onResize = () => applyPadding(true); const tid = setTimeout(applyPadding, 460); window.addEventListener('resize', onResize); return () => { clearTimeout(tid); window.removeEventListener('resize', onResize); }; }, [bannerVisible, bannerIndex, activeEvents.length]); // ── RETURN ALL STATE + HANDLERS ─────────────────────────────────────── return { // location location, setLocationAndSave, // forecast forecast, airQuality, loading, error, now, fetchedAt, // 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, workOptions, activityValue, activityLabel, placeValue, placeLabel, workValue, workLabel, // skin, vehicle, indoor, pollen skinType, setSkinType, vehicleType, setVehicleType: setVehicleTypeAndSave, vehicleVent, setVehicleVent: setVehicleVentAndSave, outdoorsVariant, setOutdoorsVariantAndSave, buildingType, setBuildingType: setBuildingTypeAndSave, indoorManaged, setIndoorManaged: setIndoorManagedAndSave, indoorMode, setIndoorMode: setIndoorModeAndSave, pollenType, setPollenTypeAndSave, utciEnv, setUtciEnv: setUtciEnvAndSave, showDecimals, toggleShowDecimals, showUnits, toggleShowUnits, // 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, }; }