Profile memory
Comment cleanup
This commit is contained in:
fraxle
2026-05-18 23:02:41 +01:00
parent dded468bec
commit 85c415d1f5
16 changed files with 504 additions and 581 deletions
+42 -119
View File
@@ -261,13 +261,20 @@ export function UTCIForecast() {
// (FREE_DAYS, FILTER_PROFILES and OUTDOORS_VARIANTS now live in ./config.js.)
// Current active filter profile
const [activeProfile, setActiveProfile] = useState('basic');
// Current active filter profile - persisted in localStorage
const [activeProfile, setActiveProfile] = useState(() => {
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
});
// Which columns appear in the hourly table by default.
// true = visible on first load (and the only ones free users see)
// false = hidden by default (Pro users can toggle these on)
const [visibleCols, setVisibleCols] = useState({ ...FILTER_PROFILES.basic.cols });
const [visibleCols, setVisibleCols] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
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] }));
// Skin type for the sunburn-time column. Fitzpatrick II is typical UK fair.
@@ -276,8 +283,14 @@ export function UTCIForecast() {
// Vehicle type for the cabin heat column. 'car' is the default preset.
const [vehicleType, setVehicleType] = useState('car');
// Places profile sub-variant (urban / beach / events / festival / wintersports / naturist)
const [outdoorsVariant, setOutdoorsVariant] = useState('urban');
// Places profile sub-variant - persisted in localStorage
const [outdoorsVariant, setOutdoorsVariant] = useState(() => {
try { return localStorage.getItem('sunscope_outdoors_variant') || 'urban'; } catch (e) { return 'urban'; }
});
const setOutdoorsVariantAndSave = (v) => {
try { localStorage.setItem('sunscope_outdoors_variant', v); } catch (e) { /* ignore */ }
setOutdoorsVariant(v);
};
// Vehicle ventilation — true = windows open (high convective loss).
const [vehicleVent, setVehicleVent] = useState(false);
@@ -287,7 +300,13 @@ export function UTCIForecast() {
// 'off' = hidden, 'on' = indoorT shown (managed or not depending on indoorManaged).
const [buildingType, setBuildingType] = useState('brick');
const [indoorManaged, setIndoorManaged] = useState(false);
const [indoorMode, setIndoorMode] = useState('off');
const [indoorMode, setIndoorMode] = useState(() => {
try {
const saved = localStorage.getItem('sunscope_profile') || 'basic';
const cols = FILTER_PROFILES[saved]?.cols ?? FILTER_PROFILES.basic.cols;
return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off';
} catch (e) { return 'off'; }
});
// Pollen type for the pollen column. Persisted in localStorage.
const [pollenType, setPollenType] = useState(() => {
@@ -304,6 +323,7 @@ export function UTCIForecast() {
const activateProfile = (key) => {
const profile = FILTER_PROFILES[key];
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
setActiveProfile(key);
if (key !== 'custom') {
setVisibleCols({ ...profile.cols });
@@ -544,7 +564,7 @@ export function UTCIForecast() {
</nav>
<main class="utci-shell">
<!-- ── EVENT BANNER ── slideshow when multiple events active ── -->
<div class=${`event-banner-wrap${bannerVisible ? ' visible' : ''}`}>
${activeEvents.length > 0 && (() => {
const ev = activeEvents[bannerIndex] || activeEvents[0];
@@ -626,24 +646,7 @@ export function UTCIForecast() {
glob=${currentRow?.glob ?? 0}
activeEvent=${lensEvent}
/>
{/* ── FUTURE FEATURE v2: Historical Context Line ─────────────────
Show a subtle single line directly below the dial readout:
e.g. "3.2°C above the May average for this location"
"Near normal for late August"
Design notes:
• Must be visually elegant — same Fraunces/Manrope type pairing
as the rest of the dial area, small and muted
• Positive delta: warm amber tone; negative delta: cool blue tone
• Source: Open-Meteo has a free /climate endpoint that returns
monthly climate normals (ERA5 reanalysis) for any lat/lon.
Fetch once on location change, cache in a ref. Compare today's
peak air temp (or UTCI) to that month's normal.
• Could also show min/max historical context for the week:
"Warmest day forecast this week" / "Coolest night since March"
• The fetch is separate from the main forecast — handle its own
loading/error state independently so it doesn't block the UI.
─────────────────────────────────────────────────────────────── */}
</div>
<div class="header-right">
@@ -694,12 +697,7 @@ export function UTCIForecast() {
${forecast && days.length > 0 && html`
<${Fragment}>
<!--
DAY TABS — one button per day, coloured by confidence band.
Days 4+ get 🔒'd when isPro is false. To change the lock
behaviour (e.g. open a paywall modal instead of doing
nothing), edit the onClick handler below.
-->
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
<button
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
@@ -803,13 +801,7 @@ export function UTCIForecast() {
</div>
</div>
<!--
PRO UPSELL CARD shown when a locked day is clicked.
Visible only while proPromptDay !== null. To change the
copy or pricing, edit the strings below. The "Notify me"
button is a mailto: link replace with a real signup
form when you have one.
-->
${proPromptDay !== null && days[proPromptDay] && (() => {
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
@@ -1001,13 +993,7 @@ export function UTCIForecast() {
</div>`;
})()}
<!--
FILTER PROFILE SELECTOR presets shown to all users.
Extra profiles are shown in place with a gentle 🔒 and clicking
them triggers the same upsell prompt as locked days.
The bottom border is removed only when the col-toggles bar
follows (Pro users), so the two bars merge into one panel.
-->
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
<span class="filter-profiles-label">Profile:</span>
${profileButtonOrder.slice(0, 3).map((key) => {
@@ -1048,8 +1034,9 @@ export function UTCIForecast() {
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
@@ -1077,8 +1064,9 @@ export function UTCIForecast() {
setProPromptDay(0);
return;
}
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
setActiveProfile('outdoors');
setOutdoorsVariant(v);
setOutdoorsVariantAndSave(v);
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
setIndoorMode('off');
setIndoorManaged(false);
@@ -1108,12 +1096,7 @@ export function UTCIForecast() {
</div>
<!--
COLUMN TOGGLES in exact table column order.
Buttons visible to all users when profile includes that col.
Burn + Vehicle dropdowns always shown (free + pro).
Pro users see all toggles; free users see profile-filtered subset.
-->
${(isPro || activeCols['burn'] || activeCols['vehicleT'] || activeCols['indoorT'] || activeCols['managedT'] || activeCols['pollen']) && html`
<div class="col-toggles">
<span class="col-toggles-label">Columns:</span>
@@ -1244,68 +1227,13 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div>`}
<!--
HOURLY TABLE each row is one hour from the selected day.
Each column is wrapped in a visibleCols.X check, so it only
shows when its toggle is on. To force a column to always
show, remove the visibleCols check around it. To rename a
heading, edit the text inside the matching <th>.
-->
{/* ── FUTURE FEATURE v2: Sticky "Now" Row + Resizable Table ────────
TWO related ideas for the table UX:
1. AUTO-SCROLL TO NOW ON LOAD
On first render (or day change to today), scroll the tbody
so the current-hour row is visible — ideally centred or at
the top third of the viewport. Use a ref on the now-row <tr>
and call scrollIntoView({ block: 'center', behavior: 'smooth' })
inside a useEffect that fires when days[selectedDay] changes.
The now row already has `currentRow` identified — just need
to attach a ref and fire the scroll.
2. VERTICALLY RESIZABLE TABLE (extends existing drag-to-scroll)
The table already has horizontal drag-to-scroll (useTableScroll).
For v2, make the tbody height resizable — a drag handle on the
bottom edge of the table, similar to how devtools panels resize.
Store the preferred height in localStorage ('sunscope_table_height').
Combined with the now-row auto-scroll, the user sets the table to
exactly the number of rows they want to see at once and it always
opens at the current hour. Very clean UX.
────────────────────────────────────────────────────────────────── */}
{/* ── FUTURE FEATURE v2: Mobile Card Layout ─────────────────────────
On small screens the wide table is painful. Consider a responsive
breakpoint (e.g. < 640px) that switches from the table to a
vertical stack of hour cards:
┌──────────────────────────────────┐
│ 14:00 🌤️ SkyScope porthole │
│ Air 24°C Wind 12 km/h SW │
│ UTCI+P ████░░░░ 28.4°C Warm │
│ UV 6 · Burn 35 min · Precip
└──────────────────────────────────┘
Each card shows only the columns that are active in the current
profile — same visibility logic, different layout. The card design
should stay true to the brass/parchment aesthetic.
Implementation approach:
• CSS media query switches .utci-table-wrap to display:none
and shows a .utci-card-list instead (same data, different markup)
• OR: render the cards in JS from the same hourlyRows array,
conditionally based on a useWindowWidth() hook
• Needs careful thought on which columns to show in "summary"
view vs an expandable "detail" tap — don't want to overwhelm
the card but also don't want to hide too much.
• General spacing and layout passes needed for mobile regardless
of whether the card view is implemented in v2.
────────────────────────────────────────────────────────────────── */}
<div class=${`utci-table-wrap${tableCanScrollLeft ? ' scroll-fade-left' : ''}${tableCanScrollRight ? ' scroll-fade-right' : ''}`} ref=${tableWrapRef}>
<span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span>
<!-- Sticky header strip locks to viewport top. Clipped
horizontally; the inner .utci-thead-track is shifted
via translateX from JS to follow the body's scrollLeft.
See handleBodyScroll + useLayoutEffect above. -->
<div class="utci-thead-sticky" ref=${headStickyRef}>
<div class="utci-thead-track" ref=${headTrackRef}>
<table class="utci-table utci-table-head" ref=${headTableRef}>
@@ -1344,9 +1272,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</table>
</div>
</div>
<!-- Body scroller — owns the horizontal scrollbar. The
onScroll handler translates the header track to keep
columns aligned with the visible body columns. -->
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-body" ref=${bodyTableRef}>
<tbody>
@@ -1655,10 +1581,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div>
</div>
<!--
ALMANAC — upcoming cosmic events in the next 3 months.
Location-aware: visibility notes adjust by latitude.
-->
${(() => {
const upcoming = getUpcomingEvents(location, 90);
const formatPeak = (iso) => {