UI Overhaul Profiles now in popup Current profile always on screen Hidden columns only when edit neeeded Add pulldowns in to profile config Customised the day tabs for vehicle, indoor and pet
697 lines
29 KiB
JavaScript
697 lines
29 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.
|
|
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, normals } = useForecast(location, 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' });
|
|
};
|
|
|
|
// ── 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,
|
|
};
|
|
}
|