Files
sunscope/assets/js/hooks/useAppState.js
T
Fraxle f356954f40 3.5.8.1
Add more labels for the day tabs
2026-07-29 18:35:45 +01:00

841 lines
35 KiB
JavaScript

// ------------------------------------------------------------------------
// 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, VEHICLE_SPEEDS } from '../utils.js';
import { buildHourlyRows, aggregateRows } from '../compute.js';
import { useForecast } from './useForecast.js';
import { useColumnPopup } from './useColumnPopup.js';
import { useTableScroll } from './useTableScroll.js';
import { getActiveEvents, getLensEvent } from '../events.js';
function track(event, profile) {
try {
fetch('track.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile ? { event, profile } : { event }),
}).catch(() => {});
} catch (e) { /* ignore */ }
}
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' };
});
// Recent locations: the most recently viewed spots, so the user can
// hop back with one click. The list is kept newest-first with the
// current location at the front; the UI shows the others as chips.
const [recentLocations, setRecentLocations] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_recent_locations');
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
return [];
});
const locKey = (l) => `${l.lat.toFixed(3)},${l.lon.toFixed(3)}`;
const setLocationAndSave = (loc) => {
try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
setRecentLocations((prev) => {
// Make sure the spot we're switching away from lands in the recent
// list too - otherwise the very first switch drops it on the floor
// since it was never added to recentLocations before now.
const withCurrent = locKey(location) !== locKey(loc)
? [location, ...prev.filter((l) => locKey(l) !== locKey(location))]
: prev;
// Keep the current spot at the front and the 3 previous distinct
// ones behind it (4 total, so 3 chips remain after excluding current).
const next = [loc, ...withCurrent.filter((l) => locKey(l) !== locKey(loc))].slice(0, 4);
try { localStorage.setItem('sunscope_recent_locations', JSON.stringify(next)); } catch (e) { /* ignore */ }
return next;
});
setLocation(loc);
};
// Pro tier flag is read early so useForecast can pick its refresh cadence
// (Pro: 5 min, free: 15 min). Full setup notes in the PRO TIER section below.
// Initial state trusts only what's already in localStorage - a bare
// ?session_id=... in the URL is verified against Stripe (see the effect
// below) before it's ever allowed to flip this on, so pasting/guessing a
// URL param can't grant free access.
const [isPro, setIsPro] = useState(() => localStorage.getItem('sunscope_pro') === '1');
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
// Just returned from a Stripe Payment Link: verify the checkout session
// server-side (verify-session.php) before granting Pro. Also records
// which kind of purchase it was (subscription vs one-off) and the Stripe
// customer id, so the re-check effect below knows whether/how to follow up.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (!sessionId) return;
window.history.replaceState({}, '', window.location.pathname);
fetch(`verify-session.php?session_id=${encodeURIComponent(sessionId)}`)
.then((r) => r.json())
.then((data) => {
if (!data.paid) return;
try {
localStorage.setItem('sunscope_pro', '1');
if (data.mode) localStorage.setItem('sunscope_pro_mode', data.mode);
if (data.customer) localStorage.setItem('sunscope_pro_customer', data.customer);
localStorage.setItem('sunscope_pro_checked_at', String(Date.now()));
} catch (e) { /* ignore */ }
setIsPro(true);
})
.catch(() => {});
}, []);
// Dev-only testing unlock: ?dev=<token>, verified server-side against
// DEV_UNLOCK_TOKEN in secrets.local.php (dev-unlock.php). Replaces the old
// bare ?pro=1 trick - a guessed/copied URL with the wrong token does nothing.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const devToken = params.get('dev');
if (!devToken) return;
window.history.replaceState({}, '', window.location.pathname);
fetch(`dev-unlock.php?token=${encodeURIComponent(devToken)}`)
.then((r) => r.json())
.then((data) => {
if (!data.ok) return;
try {
localStorage.setItem('sunscope_pro', '1');
localStorage.setItem('sunscope_pro_mode', 'dev');
localStorage.removeItem('sunscope_pro_customer');
localStorage.setItem('sunscope_pro_checked_at', String(Date.now()));
} catch (e) { /* ignore */ }
setIsPro(true);
})
.catch(() => {});
}, []);
// Subscribers (not one-off payers) can cancel in Stripe at any time, so
// Pro access shouldn't stay granted forever once localStorage is set.
// Re-check roughly once a day per visitor - one-off payments are skipped
// entirely since that access is permanent by design.
useEffect(() => {
if (!isPro) return;
const mode = (() => { try { return localStorage.getItem('sunscope_pro_mode'); } catch (e) { return null; } })();
if (mode !== 'subscription') return;
const customer = (() => { try { return localStorage.getItem('sunscope_pro_customer'); } catch (e) { return null; } })();
if (!customer) return;
const lastChecked = (() => { try { return Number(localStorage.getItem('sunscope_pro_checked_at')) || 0; } catch (e) { return 0; } })();
const RECHECK_MS = 24 * 60 * 60 * 1000;
if (Date.now() - lastChecked < RECHECK_MS) return;
fetch(`check-subscription.php?customer=${encodeURIComponent(customer)}`)
.then((r) => r.json())
.then((data) => {
try { localStorage.setItem('sunscope_pro_checked_at', String(Date.now())); } catch (e) { /* ignore */ }
if (!data.active) {
try {
localStorage.removeItem('sunscope_pro');
localStorage.removeItem('sunscope_pro_mode');
localStorage.removeItem('sunscope_pro_customer');
} catch (e) { /* ignore */ }
setIsPro(false);
}
})
.catch(() => {});
}, [isPro]);
useEffect(() => { track('visit'); }, []);
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' });
};
// Drag-to-scroll for the day-tab strip, mirroring the hourly table's body
// scroller (see useTableScroll). Unlike the table scroller, the draggable
// surface here IS the clickable element (each day is a <button>), so a
// plain mousedown/mouseup pair must still open that day - only a real drag
// (movement past a small threshold) should scroll instead of select. We
// track that with `dragged` and swallow the resulting click in capture
// phase so a drag never also fires the tab's onClick.
useEffect(() => {
const el = dayTabsRef.current;
if (!el) return;
let isDown = false;
let dragged = false;
let startX = 0;
let startScroll = 0;
const suppressClick = (e) => {
e.preventDefault();
e.stopPropagation();
el.removeEventListener('click', suppressClick, true);
};
const onMouseDown = (e) => {
if (!el.contains(e.target)) return;
if (e.button !== 0) return;
isDown = true;
dragged = false;
startX = e.clientX;
startScroll = el.scrollLeft;
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (!dragged && Math.abs(dx) > 4) {
dragged = true;
// .utci-day-tabs has scroll-behavior:smooth for the chevron/snap
// scrolls elsewhere - suspend it during drag so scrollLeft tracks
// the pointer 1:1 instead of easing behind it.
el.style.scrollBehavior = 'auto';
el.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
el.addEventListener('click', suppressClick, true);
}
if (dragged) el.scrollLeft = startScroll - dx;
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.scrollBehavior = '';
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
// Safety net: normally the browser's own click (fired right after
// mouseup, synchronously ahead of this timeout) hits suppressClick
// first and removes it. But if mouseup lands outside every tab or a
// click never follows for any other reason, this stops the listener
// leaking and silently swallowing the *next* real click.
if (dragged) setTimeout(() => el.removeEventListener('click', suppressClick, true), 0);
};
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', suppressClick, true);
};
}, [forecast]);
// ── 3. PRO TIER ──────────────────────────────────────────────────────
// (isPro is declared near the top so useForecast can read it; see above.)
// ── 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) => {
track('profile', 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);
};
// Road-speed class (key into VEHICLE_SPEEDS): 'static' | 'urban' | 'aroad' | 'motorway'.
const [vehicleSpeed, setVehicleSpeed] = useState(() => {
try { return localStorage.getItem('sunscope_vehicle_speed') || 'static'; } catch (e) { return 'static'; }
});
const setVehicleSpeedAndSave = (v) => {
try { localStorage.setItem('sunscope_vehicle_speed', v); } catch (e) { /* ignore */ }
setVehicleSpeed(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);
// The Driver profile is about cab comfort - default the vehicle model to a
// truck cab travelling at A-road speed.
if (v === 'driver') {
setVehicleTypeAndSave('truck');
setVehicleSpeedAndSave('aroad');
}
};
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 [furColor, setFurColor] = useState(() => {
try { return localStorage.getItem('sunscope_fur_color') || 'brown'; } catch (e) { return 'brown'; }
});
const setFurColorAndSave = (v) => {
try { localStorage.setItem('sunscope_fur_color', v); } catch (e) { /* ignore */ }
setFurColor(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;
});
// ── Welcome / "How it works" popup ───────────────────────────────────
// Auto-shows on first visit (no seen flag), can be reopened anytime via
// the footer link. Reopening does NOT clear the flag.
const [welcomeOpen, setWelcomeOpen] = useState(() => {
try { return localStorage.getItem('sunscope_welcome_seen') !== '1'; }
catch (e) { return false; }
});
useEffect(() => {
if (welcomeOpen && (() => { try { return localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { return false; } })()) {
track('welcome_shown');
}
}, []);
const closeWelcome = () => {
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
setWelcomeOpen(false);
};
const openWelcome = () => setWelcomeOpen(true);
// Restore-access modal ("Already subscribed?").
const [restoreOpen, setRestoreOpen] = useState(false);
const openRestore = () => setRestoreOpen(true);
const closeRestore = () => setRestoreOpen(false);
// Profile & config flyout panel (right-side drawer).
const [panelOpen, setPanelOpen] = useState(false);
const openPanel = () => setPanelOpen(true);
const closePanel = () => setPanelOpen(false);
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;
});
// Table row interval: 1 = every hour (default), 2/3/4 = clock-aligned buckets.
const [tableInterval, setTableIntervalState] = useState(() => {
try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 3; } catch (e) { return 3; }
});
const setTableInterval = (n) => setTableIntervalState(() => {
const next = [1, 2, 3, 4].includes(n) ? n : 1;
try { localStorage.setItem('sunscope_table_interval', String(next)); } catch (e) { /* ignore */ }
return next;
});
const [forecastView, setForecastViewState] = useState(() => {
try { const s = localStorage.getItem('sunscope_forecast_view'); return s === 'table' ? 'table' : 'simple'; } catch (e) { return 'simple'; }
});
const setForecastView = (v) => {
try { localStorage.setItem('sunscope_forecast_view', v); } catch (e) { /* ignore */ }
setForecastViewState(v);
};
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,
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,
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,
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,
vehicleSpeed: (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph,
buildingType, furColor, utciEnv,
});
const visible = days[selectedDay]?.rows || [];
// Rows actually rendered in the table - bucketed by the interval selector.
// `visible` stays full-resolution so the dial, glance and event detection
// keep seeing every hour.
const tableRows = aggregateRows(visible, tableInterval);
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]);
// The banner is now a position:fixed flyout pinned to the very top of the
// screen (see .event-banner-wrap in ui.css). It floats over all content and
// slides in/out via a transform, so no JS nav-pinning or shell padding is
// needed.
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
return {
// location
location, setLocationAndSave, recentLocations,
// forecast
forecast, airQuality, loading, error, now, fetchedAt, normals,
// 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,
vehicleSpeed, setVehicleSpeed: setVehicleSpeedAndSave,
outdoorsVariant, setOutdoorsVariantAndSave,
buildingType, setBuildingType: setBuildingTypeAndSave,
furColor, setFurColor: setFurColorAndSave,
indoorManaged, setIndoorManaged: setIndoorManagedAndSave,
indoorMode, setIndoorMode: setIndoorModeAndSave,
pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv: setUtciEnvAndSave,
showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore,
panelOpen, openPanel, closePanel,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
forecastView, setForecastView,
// 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, tableRows, nowLocalISO, currentRow, currentCat,
liveElev,
// banner + events
activeEvents, lensEvent, selectedDayEvents,
bannerIndex, bannerTransition, bannerPrevIndex,
bannerVisible, bannerStageRef,
bannerSlideTo, dismissBanner,
};
}