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:
fraxle
2026-08-01 16:18:15 +01:00
parent 2869694634
commit e55e0df86f
15 changed files with 406 additions and 380 deletions
+13 -94
View File
@@ -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,
};
}