966 lines
42 KiB
JavaScript
966 lines
42 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 });
|
|
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
|
|
setIndoorModeAndSave(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 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 = () => {
|
|
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
|
|
setWelcomeOpen(false);
|
|
};
|
|
const openWelcome = () => setWelcomeOpen(true);
|
|
|
|
// ── 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);
|
|
|
|
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,
|
|
} = 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,
|
|
});
|
|
|
|
// ── 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,
|
|
// table scroll
|
|
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
|
|
// computation
|
|
hourlyRows, days, utcOffsetMs,
|
|
visible, tableRows, nowLocalISO, currentRow, currentCat,
|
|
liveElev,
|
|
// events
|
|
activeEvents, lensEvent, selectedDayEvents,
|
|
};
|
|
}
|