3.7
Updated event banner to fit top screen Updated table thermal colours to max hot and min cold for 2h, 3h & 4h Ground Lab preview
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
// 7. Column popup + table scroll hooks
|
||||
// 8. Geocoding search effect
|
||||
// 9. Computation - rows, days, current row
|
||||
// 10. Banner - events, snooze, slideshow
|
||||
// 10. Events - active, lens, selected-day
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||
@@ -336,6 +336,8 @@ export function useAppState() {
|
||||
const profile = FILTER_PROFILES[key];
|
||||
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||
setActiveProfile(key);
|
||||
// Profiles can declare a preferred forecast view (Basic opens in Quick).
|
||||
if (profile?.view) setForecastView(profile.view);
|
||||
if (key !== 'custom') {
|
||||
setVisibleCols({ ...profile.cols });
|
||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||
@@ -387,6 +389,10 @@ export function useAppState() {
|
||||
const setOutdoorsVariantAndSave = (v) => {
|
||||
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
|
||||
setOutdoorsVariant(v);
|
||||
// Every Places / Activities / Work variant carries a wide, specific column
|
||||
// set - Quick view can only show a handful of those, so open the detailed
|
||||
// table. (Basic goes the other way; see FILTER_PROFILES.basic.view.)
|
||||
setForecastView('table');
|
||||
// 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;
|
||||
@@ -675,99 +681,15 @@ export function useAppState() {
|
||||
? 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; }
|
||||
};
|
||||
|
||||
// ── 10. EVENTS ────────────────────────────────────────────────────────
|
||||
// Events surface as the .event-note strip above the nav (see app.js). It
|
||||
// is in flow rather than an overlay, so there is no dismiss or snooze
|
||||
// state to track.
|
||||
const todayRows = days[0]?.rows || [];
|
||||
const activeEvents = getActiveEvents(todayRows, location)
|
||||
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
|
||||
const activeEvents = getActiveEvents(todayRows, location);
|
||||
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
|
||||
@@ -831,10 +753,7 @@ export function useAppState() {
|
||||
hourlyRows, days, utcOffsetMs,
|
||||
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
||||
liveElev,
|
||||
// banner + events
|
||||
// events
|
||||
activeEvents, lensEvent, selectedDayEvents,
|
||||
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||
bannerVisible, bannerStageRef,
|
||||
bannerSlideTo, dismissBanner,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user