New big and small scope sunset and sunrise simulation New calculations for the day tabs Added missing icons to simple view New record popups Week ahead description popups Camping profile Tweak to some of the indoor tems
1000 lines
44 KiB
JavaScript
1000 lines
44 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. Events - active, lens, selected-day
|
|
// ------------------------------------------------------------------------
|
|
|
|
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 */ }
|
|
}
|
|
|
|
// ── SHARE LINK PARAMS ───────────────────────────────────────────────────
|
|
// Read once at module load, before any state initialiser or effect runs. The
|
|
// Stripe and dev-unlock effects each strip the query string on mount, so
|
|
// anything read lazily later would already be gone.
|
|
//
|
|
// Everything is validated: a link is untrusted input, and these values feed
|
|
// straight into the fetch URL and the column set. Anything unrecognised is
|
|
// dropped silently and the normal localStorage / default path takes over.
|
|
//
|
|
// Note there is deliberately no `pro` param. Pro is granted only by a
|
|
// Stripe-verified session_id or the server-checked dev token; a shareable link
|
|
// must never become an unlock. Pro-only profiles and variants are therefore
|
|
// accepted only for a visitor who is already Pro on this device.
|
|
const SHARE_PARAMS = (() => {
|
|
const out = {};
|
|
try {
|
|
const p = new URLSearchParams(window.location.search);
|
|
if (!p.has('lat') && !p.has('profile') && !p.has('day')) return out;
|
|
|
|
const lat = parseFloat(p.get('lat'));
|
|
const lon = parseFloat(p.get('lon'));
|
|
if (Number.isFinite(lat) && Number.isFinite(lon) &&
|
|
lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
|
// Strip control characters only - place names legitimately contain
|
|
// spaces, hyphens, apostrophes and accents.
|
|
const raw = (p.get('name') || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
out.location = {
|
|
name: raw.slice(0, 60) || `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`,
|
|
lat, lon,
|
|
country: (p.get('country') || '').slice(0, 2).toUpperCase() || '',
|
|
};
|
|
}
|
|
|
|
const alreadyPro = (() => {
|
|
try { return localStorage.getItem('sunscope_pro') === '1'; } catch (e) { return false; }
|
|
})();
|
|
|
|
const profile = p.get('profile');
|
|
if (profile && Object.prototype.hasOwnProperty.call(FILTER_PROFILES, profile) &&
|
|
(alreadyPro || !FILTER_PROFILES[profile].proOnly)) {
|
|
out.profile = profile;
|
|
}
|
|
|
|
const variant = p.get('variant');
|
|
if (variant && Object.prototype.hasOwnProperty.call(OUTDOORS_VARIANTS, variant) &&
|
|
(alreadyPro || !OUTDOORS_VARIANTS[variant].proOnly)) {
|
|
out.variant = variant;
|
|
}
|
|
|
|
const day = parseInt(p.get('day'), 10);
|
|
if (Number.isInteger(day) && day >= 0 && day < FREE_DAYS) out.day = day;
|
|
} catch (e) { /* malformed query string - ignore entirely */ }
|
|
return out;
|
|
})();
|
|
|
|
export function useAppState() {
|
|
|
|
// ── 1. LOCATION ──────────────────────────────────────────────────────
|
|
// Precedence: share link → last saved location → London.
|
|
const [location, setLocation] = useState(() => {
|
|
if (SHARE_PARAMS.location) return SHARE_PARAMS.location;
|
|
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);
|
|
};
|
|
|
|
// ── GEOLOCATION ──────────────────────────────────────────────────────
|
|
// Without this the first-time default is London for everyone on earth.
|
|
// Never auto-prompted: a saved location always wins, and an unrequested
|
|
// permission dialog on load is hostile. The pin button in the header is
|
|
// the only entry point.
|
|
const [locating, setLocating] = useState(false);
|
|
const [locateError, setLocateError] = useState(null);
|
|
|
|
const useMyLocation = () => {
|
|
if (!navigator.geolocation) {
|
|
setLocateError("This browser can't share your location.");
|
|
return;
|
|
}
|
|
setLocating(true);
|
|
setLocateError(null);
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
// Named "My location" rather than a place name: Open-Meteo's geocoding
|
|
// API is forward-only (?name=), with no reverse endpoint, and pulling
|
|
// in a second provider just to label a pin isn't worth the extra host.
|
|
// The header already prints the coordinates underneath the name.
|
|
setLocationAndSave({
|
|
name: 'My location',
|
|
lat: pos.coords.latitude,
|
|
lon: pos.coords.longitude,
|
|
country: '',
|
|
});
|
|
setSelectedDay(0);
|
|
setLocating(false);
|
|
track('geolocate');
|
|
},
|
|
(err) => {
|
|
setLocating(false);
|
|
setLocateError(
|
|
err.code === 1 ? 'Location permission denied.'
|
|
: err.code === 3 ? 'Timed out finding your location.'
|
|
: "Couldn't get your location."
|
|
);
|
|
},
|
|
{ timeout: 8000, maximumAge: 10 * 60 * 1000 }
|
|
);
|
|
};
|
|
|
|
// Pro tier flag is read early so useForecast can pick its refresh cadence
|
|
// (Pro: 15 min, free: 30 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, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry } = 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(SHARE_PARAMS.day ?? 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(() => {
|
|
if (SHARE_PARAMS.profile) return SHARE_PARAMS.profile;
|
|
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
|
|
});
|
|
|
|
const [visibleCols, setVisibleCols] = useState(() => {
|
|
try {
|
|
const saved = SHARE_PARAMS.profile || 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 = SHARE_PARAMS.variant || 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);
|
|
// 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'];
|
|
// Must go through the persisting setters: the Indoors pill and column
|
|
// read indoorMode, and its initialiser reads it back from localStorage
|
|
// on the next load. Setting only the React state left the stored value
|
|
// behind, so a reload restored the previous profile's Indoors state.
|
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
|
setIndoorManagedAndSave(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'];
|
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
|
setIndoorManagedAndSave(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(() => {
|
|
if (SHARE_PARAMS.variant) return SHARE_PARAMS.variant;
|
|
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);
|
|
// 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;
|
|
if (variantCols) {
|
|
setVisibleCols({ ...variantCols });
|
|
// Indoors state is owned here (and persisted) so Quick, Detailed and
|
|
// Table all agree - callers must not override it afterwards.
|
|
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
|
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
|
setIndoorManagedAndSave(false);
|
|
}
|
|
// 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');
|
|
}
|
|
// Camping shows the Indoor column as the tent's overnight temperature.
|
|
if (v === 'camping') {
|
|
setBuildingTypeAndSave('tent');
|
|
}
|
|
// Office models the Indoor column on an office building.
|
|
if (v === 'office') {
|
|
setBuildingTypeAndSave('office');
|
|
}
|
|
};
|
|
|
|
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 profile = localStorage.getItem('sunscope_profile') || 'basic';
|
|
const cols = profile === 'outdoors'
|
|
? (OUTDOORS_VARIANTS[localStorage.getItem('sunscope_outdoors_variant') || 'urban']?.cols
|
|
?? FILTER_PROFILES.outdoors.cols)
|
|
: (FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols);
|
|
const offered = !!(cols['indoorT'] || cols['managedT']);
|
|
// A profile that has no Indoors columns has no Indoors pill either, so
|
|
// an 'on' left over from a profile that did would show the column with
|
|
// no way to turn it off. The profile decides whether it's available;
|
|
// the saved value only decides the state within a profile that offers it.
|
|
if (!offered) return 'off';
|
|
const saved = localStorage.getItem('sunscope_indoor_mode');
|
|
return saved || 'on';
|
|
} 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 = () => {
|
|
// First visit only: hand the new user straight on to the profile picker
|
|
// so they start from a profile that matches them rather than the default.
|
|
// Reopening the popup later from the footer link doesn't re-trigger it.
|
|
let firstVisit = false;
|
|
try { firstVisit = localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { /* ignore */ }
|
|
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
|
|
setWelcomeOpen(false);
|
|
if (firstVisit) setProfilePickPending(true);
|
|
};
|
|
const openWelcome = () => setWelcomeOpen(true);
|
|
|
|
// Set when the welcome popup is dismissed on a first visit; cleared by the
|
|
// effect below that actually opens the picker.
|
|
const [profilePickPending, setProfilePickPending] = useState(false);
|
|
|
|
// ── Weekly subscribe nudge (free users only) ─────────────────────────
|
|
// At most once every 7 days, and never on the first visit - the welcome
|
|
// popup owns that moment. The last-shown date lives in localStorage;
|
|
// subscribing (isPro) suppresses it entirely.
|
|
const NUDGE_KEY = 'sunscope_nudge_last';
|
|
const NUDGE_COOLDOWN = 7 * 24 * 60 * 60 * 1000;
|
|
const [weeklyNudgeOpen, setWeeklyNudgeOpen] = useState(false);
|
|
useEffect(() => {
|
|
if (isPro || welcomeOpen) return;
|
|
let last = 0;
|
|
try {
|
|
last = Number(localStorage.getItem(NUDGE_KEY)) || 0;
|
|
// First run for an existing user: start the clock, don't nudge yet.
|
|
if (!last) { localStorage.setItem(NUDGE_KEY, String(Date.now())); return; }
|
|
} catch (e) { return; }
|
|
if (Date.now() - last < NUDGE_COOLDOWN) return;
|
|
setWeeklyNudgeOpen(true);
|
|
track('weekly_nudge_shown');
|
|
}, [isPro, welcomeOpen]);
|
|
const closeWeeklyNudge = () => {
|
|
try { localStorage.setItem(NUDGE_KEY, String(Date.now())); } catch (e) { /* ignore */ }
|
|
setWeeklyNudgeOpen(false);
|
|
};
|
|
|
|
// 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);
|
|
|
|
// Completes the first-visit hand-off from the welcome popup, but only once
|
|
// the forecast is in: the panel anchors itself to the active-profile row,
|
|
// which doesn't exist until the forecast has rendered.
|
|
useEffect(() => {
|
|
if (!profilePickPending || !forecast) return;
|
|
setProfilePickPending(false);
|
|
setPanelOpen(true);
|
|
track('profile_picker_first_visit');
|
|
}, [profilePickPending, forecast]);
|
|
|
|
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;
|
|
});
|
|
|
|
// Rotates the hourly table: off = hours down the side, on = hours along
|
|
// the top with the metrics down the side. Off by default — the tall
|
|
// layout is the one most people expect from a forecast table.
|
|
const [tableRotated, setTableRotated] = useState(() => {
|
|
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
|
|
});
|
|
const setTableRotatedAndSave = (next) => {
|
|
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
|
|
setTableRotated(!!next);
|
|
};
|
|
|
|
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
|
|
const [tableInterval, setTableIntervalState] = useState(() => {
|
|
try { const n = parseInt(localStorage.getItem('sunscope_table_interval'), 10); return [1, 2, 3, 4].includes(n) ? n : 2; } catch (e) { return 2; }
|
|
});
|
|
const setTableInterval = (n) => setTableIntervalState(() => {
|
|
const next = [1, 2, 3, 4].includes(n) ? n : 2;
|
|
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,
|
|
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
|
|
} = useColumnPopup();
|
|
|
|
const {
|
|
tableCanScrollLeft,
|
|
tableCanScrollRight,
|
|
handleBodyScroll,
|
|
} = useTableScroll({
|
|
headTableRef, bodyTableRef, bodyScrollRef, headTrackRef, tableWrapRef,
|
|
forecast, visibleCols, selectedDay, skinType, vehicleType,
|
|
indoorMode, indoorManaged, showDecimals, showUnits,
|
|
// The rotated table is a single table with CSS-only sticky edges, so
|
|
// none of this hook's dual-table syncing applies to it.
|
|
rotated: tableRotated,
|
|
tableInterval,
|
|
});
|
|
|
|
// ── 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]);
|
|
|
|
// ── 8b. SHARE LINK ───────────────────────────────────────────────────
|
|
// Keep the address bar in step with what's on screen so the page can be
|
|
// bookmarked, pinned to a home screen, or sent to someone else.
|
|
//
|
|
// replaceState, not pushState: the back button should leave the app, not
|
|
// walk backwards through every profile the user tried.
|
|
const buildShareUrl = () => {
|
|
const p = new URLSearchParams();
|
|
p.set('lat', location.lat.toFixed(4));
|
|
p.set('lon', location.lon.toFixed(4));
|
|
if (location.name) p.set('name', location.name);
|
|
if (location.country) p.set('country', location.country);
|
|
p.set('profile', activeProfile);
|
|
if (activeProfile === 'outdoors') p.set('variant', outdoorsVariant);
|
|
if (selectedDay > 0) p.set('day', String(selectedDay));
|
|
return `${window.location.origin}${window.location.pathname}?${p}`;
|
|
};
|
|
|
|
// Skip the first run. The Stripe and dev-unlock effects both strip the query
|
|
// string on mount; syncing before they do would put params back and undo it.
|
|
const urlSyncReady = useRef(false);
|
|
useEffect(() => {
|
|
if (!urlSyncReady.current) { urlSyncReady.current = true; return; }
|
|
try {
|
|
window.history.replaceState({}, '', buildShareUrl());
|
|
} catch (e) { /* ignore - some embedded webviews block this */ }
|
|
}, [location, activeProfile, outdoorsVariant, selectedDay]);
|
|
|
|
// Share button: native sheet where available, clipboard everywhere else.
|
|
// `shareState` drives the transient "Link copied" confirmation.
|
|
const [shareState, setShareState] = useState(null); // null | 'copied' | 'failed'
|
|
const shareForecast = async () => {
|
|
const url = buildShareUrl();
|
|
track('share');
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({ title: `SunScope - ${location.name}`, url });
|
|
return;
|
|
}
|
|
await navigator.clipboard.writeText(url);
|
|
setShareState('copied');
|
|
} catch (e) {
|
|
// AbortError just means the user dismissed the native share sheet.
|
|
if (e && e.name === 'AbortError') return;
|
|
setShareState('failed');
|
|
}
|
|
setTimeout(() => setShareState(null), 2200);
|
|
};
|
|
|
|
// ── 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. 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);
|
|
const lensEvent = getLensEvent(activeEvents);
|
|
const selectedDayEvents = getActiveEvents(visible, location);
|
|
|
|
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
|
|
return {
|
|
// location
|
|
location, setLocationAndSave, recentLocations,
|
|
useMyLocation, locating, locateError, setLocateError,
|
|
// share
|
|
shareForecast, shareState, buildShareUrl,
|
|
// forecast
|
|
forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
|
|
// 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,
|
|
weeklyNudgeOpen, closeWeeklyNudge,
|
|
restoreOpen, openRestore, closeRestore,
|
|
panelOpen, openPanel, closePanel,
|
|
showUnits, toggleShowUnits,
|
|
tableInterval, setTableInterval,
|
|
tableRotated, setTableRotated: setTableRotatedAndSave,
|
|
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,
|
|
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
|
|
// table scroll
|
|
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
|
// computation
|
|
hourlyRows, days, utcOffsetMs,
|
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
|
liveElev,
|
|
// events
|
|
activeEvents, lensEvent, selectedDayEvents,
|
|
};
|
|
}
|