New Logo Updated cache system now 15/30mins but scope updates every minute Font update
648 lines
27 KiB
JavaScript
648 lines
27 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 } 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';
|
|
|
|
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);
|
|
};
|
|
|
|
// 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.
|
|
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';
|
|
});
|
|
|
|
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
|
|
|
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 ──────────────────────────────────────────────────────
|
|
// (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) => {
|
|
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;
|
|
});
|
|
|
|
// 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 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 || [];
|
|
// 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]);
|
|
|
|
// ── 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,
|
|
tableInterval, setTableInterval,
|
|
// 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,
|
|
};
|
|
}
|