1.7.1
Split code into smaller sections and fix things that broke along the way
This commit is contained in:
+54
-918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,445 @@
|
|||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// components/DayTabs.js - Day-tab strip, pro-prompt card, confidence
|
||||||
|
// band bar, and filter-profile row for the UTCIForecast app.
|
||||||
|
//
|
||||||
|
// Extracted from app.js to keep the main component under the AI edit
|
||||||
|
// safe-zone. All state lives in useAppState - this component is purely
|
||||||
|
// presentational and receives everything it needs as props.
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// days - array of day objects from buildHourlyRows
|
||||||
|
// selectedDay - index of the active day tab
|
||||||
|
// setSelectedDay - setter for selectedDay
|
||||||
|
// isPro - boolean Pro status
|
||||||
|
// FREE_DAYS - number of free days
|
||||||
|
// proPromptDay - index of locked day that was clicked
|
||||||
|
// setProPromptDay - setter for proPromptDay
|
||||||
|
// proPromptSource - string key for upsell copy
|
||||||
|
// setProPromptSource - setter for proPromptSource
|
||||||
|
// activeProfile - current filter profile key
|
||||||
|
// activateProfile - function to switch profile
|
||||||
|
// activeCols - effective column set for current profile
|
||||||
|
// visibleCols - object of col-key -> boolean visibility
|
||||||
|
// activityOptions - dropdown options for Activities selector
|
||||||
|
// placeOptions - dropdown options for Places selector
|
||||||
|
// activityValue - current activity dropdown value
|
||||||
|
// activityLabel - current activity dropdown label
|
||||||
|
// placeValue - current place dropdown value
|
||||||
|
// placeLabel - current place dropdown label
|
||||||
|
// outdoorsVariant - current outdoors sub-variant key
|
||||||
|
// setOutdoorsVariantAndSave - setter that also persists to localStorage
|
||||||
|
// setActiveProfile - raw setter for activeProfile
|
||||||
|
// setVisibleCols - raw setter for visibleCols
|
||||||
|
// setIndoorMode - setter for indoorMode
|
||||||
|
// setIndoorManaged - setter for indoorManaged
|
||||||
|
// dayTabsRef - ref for the scrollable tab strip element
|
||||||
|
// canScrollLeft - boolean for left-fade chevron
|
||||||
|
// canScrollRight - boolean for right-fade chevron
|
||||||
|
// scrollDayTabs - function(dir) to scroll strip left/right
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { h, Fragment } from '../../vendor/preact.js';
|
||||||
|
import htm from '../../vendor/htm.js';
|
||||||
|
import { confidenceBand } from '../utils.js';
|
||||||
|
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys } from '../config.js';
|
||||||
|
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
|
||||||
|
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
export function DayTabs({
|
||||||
|
days,
|
||||||
|
selectedDay, setSelectedDay,
|
||||||
|
isPro,
|
||||||
|
proPromptDay, setProPromptDay,
|
||||||
|
proPromptSource, setProPromptSource,
|
||||||
|
activeProfile, activateProfile, activeCols,
|
||||||
|
visibleCols,
|
||||||
|
activityOptions, placeOptions,
|
||||||
|
activityValue, activityLabel,
|
||||||
|
placeValue, placeLabel,
|
||||||
|
outdoorsVariant, setOutdoorsVariantAndSave,
|
||||||
|
setActiveProfile, setVisibleCols,
|
||||||
|
setIndoorMode, setIndoorManaged,
|
||||||
|
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
|
||||||
|
}) {
|
||||||
|
return html`
|
||||||
|
<${Fragment}>
|
||||||
|
|
||||||
|
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
|
||||||
|
<button
|
||||||
|
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
|
||||||
|
onClick=${() => scrollDayTabs(-1)}
|
||||||
|
aria-label="Scroll days left"
|
||||||
|
type="button">‹</button>
|
||||||
|
<button
|
||||||
|
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
|
||||||
|
onClick=${() => scrollDayTabs(1)}
|
||||||
|
aria-label="Scroll days right"
|
||||||
|
type="button">›</button>
|
||||||
|
<div class="utci-day-tabs" ref=${dayTabsRef}>
|
||||||
|
${days.map((d, i) => {
|
||||||
|
const band = confidenceBand(i);
|
||||||
|
const locked = !isPro && i >= FREE_DAYS;
|
||||||
|
const isActive = i === selectedDay;
|
||||||
|
const dDate = new Date(d.key + 'T00:00Z');
|
||||||
|
const dayName = i === 0 ? 'Today'
|
||||||
|
: i === 1 ? 'Tomorrow'
|
||||||
|
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||||||
|
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
|
||||||
|
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
|
||||||
|
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
|
||||||
|
|
||||||
|
// Day-tab weather icon - use daytime rows where available
|
||||||
|
const dayRows = d.rows.filter(r => r.elev > 0);
|
||||||
|
const repRows = dayRows.length > 0 ? dayRows : d.rows;
|
||||||
|
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||||||
|
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||||||
|
const catCounts = {};
|
||||||
|
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
|
||||||
|
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
|
||||||
|
const midRow = repRows[Math.floor(repRows.length / 2)];
|
||||||
|
const repElev = midRow ? midRow.elev : 45;
|
||||||
|
const repDt = midRow ? midRow.dt : dDate;
|
||||||
|
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
key=${d.key}
|
||||||
|
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
|
||||||
|
onClick=${() => {
|
||||||
|
if (locked) {
|
||||||
|
setProPromptSource('day');
|
||||||
|
setProPromptDay(i);
|
||||||
|
} else {
|
||||||
|
setSelectedDay(i);
|
||||||
|
setProPromptDay(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title=${locked
|
||||||
|
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||||||
|
: `${band.label} · day ${i + 1} of 14`}
|
||||||
|
style=${{
|
||||||
|
background: band.bg,
|
||||||
|
color: '#2a1d10',
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: '0 0 3px 0',
|
||||||
|
borderBottomColor: isActive ? '#1e1208' : band.edge,
|
||||||
|
opacity: locked ? 0.5 : 1,
|
||||||
|
cursor: locked ? 'not-allowed' : 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${locked && html`
|
||||||
|
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
|
||||||
|
${dayName}
|
||||||
|
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||||||
|
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||||
|
</span>
|
||||||
|
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
|
||||||
|
${showPrecip
|
||||||
|
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
|
||||||
|
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
|
||||||
|
</span>
|
||||||
|
${dayHi !== null && html`
|
||||||
|
<span style=${{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'Fraunces, serif',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: '11px',
|
||||||
|
marginTop: '2px',
|
||||||
|
letterSpacing: 0,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}>
|
||||||
|
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
|
||||||
|
<span style=${{ opacity: 0.5 }}> / </span>
|
||||||
|
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
|
||||||
|
</span>`}
|
||||||
|
</button>`;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${proPromptDay !== null && days[proPromptDay] && (() => {
|
||||||
|
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
|
||||||
|
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
|
||||||
|
const extraPromptCopy = {
|
||||||
|
'variant:sailing': {
|
||||||
|
title: 'Sailing is part of SunScope Extra',
|
||||||
|
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
|
||||||
|
},
|
||||||
|
'profile:alltemps': {
|
||||||
|
title: 'Temps is part of SunScope Extra',
|
||||||
|
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
|
||||||
|
},
|
||||||
|
'profile:custom': {
|
||||||
|
title: 'Custom columns are part of SunScope Extra',
|
||||||
|
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
|
||||||
|
},
|
||||||
|
'variant:festival': {
|
||||||
|
title: 'Festival planning is part of SunScope Extra',
|
||||||
|
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
|
||||||
|
},
|
||||||
|
'variant:wintersports': {
|
||||||
|
title: 'Winter Sports is part of SunScope Extra',
|
||||||
|
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
|
||||||
|
},
|
||||||
|
'variant:naturist': {
|
||||||
|
title: 'Naturist is part of SunScope Extra',
|
||||||
|
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const promptCopy = extraPromptCopy[proPromptSource] || {
|
||||||
|
title: `${dayName}'s forecast is part of SunScope Extra`,
|
||||||
|
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
|
||||||
|
};
|
||||||
|
return html`
|
||||||
|
<div style=${{
|
||||||
|
margin: '12px 0',
|
||||||
|
padding: '18px 22px',
|
||||||
|
background: '#fdf8ee',
|
||||||
|
border: '1.5px solid #c9b08a',
|
||||||
|
borderLeft: '4px solid #c8922a',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '14px',
|
||||||
|
}}>
|
||||||
|
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
|
||||||
|
<div style=${{
|
||||||
|
fontFamily: 'Fraunces, serif',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: '19px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1e1208',
|
||||||
|
marginBottom: '6px',
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}>
|
||||||
|
🔒 ${promptCopy.title}
|
||||||
|
</div>
|
||||||
|
<div style=${{
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontSize: '13.5px',
|
||||||
|
color: '#4a3420',
|
||||||
|
lineHeight: 1.65,
|
||||||
|
}}>
|
||||||
|
${promptCopy.detail}
|
||||||
|
</div>
|
||||||
|
<div style=${{
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '11px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
color: '#c8922a',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}>
|
||||||
|
£2 / month · cancel any time
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style=${{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<a
|
||||||
|
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style=${{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '10px 18px',
|
||||||
|
background: '#c8922a',
|
||||||
|
color: '#fff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '11px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
borderRadius: '3px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Subscribe — £2/month
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/restore.php"
|
||||||
|
style=${{
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
color: '#c8922a',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||||||
|
paddingBottom: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Already subscribed? Restore access →
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style=${{
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
color: '#b09870',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: '1px solid rgba(176,152,112,0.4)',
|
||||||
|
paddingBottom: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage or cancel subscription →
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick=${() => setProPromptDay(null)}
|
||||||
|
style=${{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
color: '#b09870',
|
||||||
|
padding: '2px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
${(() => {
|
||||||
|
const band = confidenceBand(selectedDay);
|
||||||
|
const isOutlook = selectedDay >= 7;
|
||||||
|
return html`
|
||||||
|
<div style=${{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
margin: '8px 0 4px',
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: band.tint,
|
||||||
|
borderLeft: `3px solid ${band.edge}`,
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#3a2a18',
|
||||||
|
fontFamily: 'Manrope, sans-serif',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<span style=${{ letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||||
|
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
|
||||||
|
</span>
|
||||||
|
${isOutlook && html`
|
||||||
|
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
|
||||||
|
forecast skill is reduced — treat hourly detail as trend, not precision
|
||||||
|
</span>`}
|
||||||
|
</div>`;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
|
||||||
|
<span class="filter-profiles-label">Profile:</span>
|
||||||
|
${profileButtonOrder.slice(0, 3).map((key) => {
|
||||||
|
const profile = FILTER_PROFILES[key];
|
||||||
|
const locked = profile.proOnly && !isPro;
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
key=${key}
|
||||||
|
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||||||
|
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||||||
|
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||||||
|
onClick=${() => {
|
||||||
|
if (locked) {
|
||||||
|
setProPromptSource(`profile:${key}`);
|
||||||
|
setProPromptDay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateProfile(key);
|
||||||
|
}}
|
||||||
|
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||||
|
})}
|
||||||
|
<span class="profile-divider" aria-hidden="true"></span>
|
||||||
|
<${CustomSelect}
|
||||||
|
key="places"
|
||||||
|
value=${placeValue}
|
||||||
|
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
|
||||||
|
noHide=${true}
|
||||||
|
hideLabel="🌤️ Places"
|
||||||
|
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`}
|
||||||
|
options=${placeOptions}
|
||||||
|
onChange=${(v) => {
|
||||||
|
if (v === 'off') { activateProfile('basic'); return; }
|
||||||
|
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||||||
|
setProPromptSource(`variant:${v}`);
|
||||||
|
setProPromptDay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
|
||||||
|
setActiveProfile('outdoors');
|
||||||
|
setOutdoorsVariantAndSave(v);
|
||||||
|
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||||||
|
setIndoorMode('off');
|
||||||
|
setIndoorManaged(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<${CustomSelect}
|
||||||
|
key="activities"
|
||||||
|
value=${activityValue}
|
||||||
|
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
|
||||||
|
noHide=${true}
|
||||||
|
hideLabel="🎯 Activities"
|
||||||
|
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`}
|
||||||
|
options=${activityOptions}
|
||||||
|
onChange=${(v) => {
|
||||||
|
if (v === 'off') { activateProfile('basic'); return; }
|
||||||
|
if (v === 'farming') { activateProfile('farming'); return; }
|
||||||
|
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||||||
|
setProPromptSource(`variant:${v}`);
|
||||||
|
setProPromptDay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
|
||||||
|
setActiveProfile('outdoors');
|
||||||
|
setOutdoorsVariantAndSave(v);
|
||||||
|
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||||||
|
setIndoorMode('off');
|
||||||
|
setIndoorManaged(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="profile-divider" aria-hidden="true"></span>
|
||||||
|
${profileButtonOrder.slice(3).map((key) => {
|
||||||
|
const profile = FILTER_PROFILES[key];
|
||||||
|
const locked = profile.proOnly && !isPro;
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
key=${key}
|
||||||
|
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||||||
|
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||||||
|
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||||||
|
onClick=${() => {
|
||||||
|
if (locked) {
|
||||||
|
setProPromptSource(`profile:${key}`);
|
||||||
|
setProPromptDay(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateProfile(key);
|
||||||
|
}}
|
||||||
|
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</${Fragment}>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 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. Banner - events, snooze, slideshow
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||||
|
import {
|
||||||
|
FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS,
|
||||||
|
profileButtonOrder, variantIcons,
|
||||||
|
activityVariantKeys, placeVariantKeys,
|
||||||
|
} from '../config.js';
|
||||||
|
import { utciCategory } from '../utils.js';
|
||||||
|
import { buildHourlyRows } from '../compute.js';
|
||||||
|
import { useForecast } from './useForecast.js';
|
||||||
|
import { useColumnPopup } from './useColumnPopup.js';
|
||||||
|
import { useTableScroll } from './useTableScroll.js';
|
||||||
|
import { getActiveEvents, getLensEvent } from '../events.js';
|
||||||
|
|
||||||
|
export function useAppState() {
|
||||||
|
|
||||||
|
// ── 1. LOCATION ──────────────────────────────────────────────────────
|
||||||
|
const [location, setLocation] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('sunscope_last_location');
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLocationAndSave = (loc) => {
|
||||||
|
try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ }
|
||||||
|
setLocation(loc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { forecast, airQuality, loading, error, now } = useForecast(location);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedDay, setSelectedDay] = useState(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' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3. PRO TIER ──────────────────────────────────────────────────────
|
||||||
|
const [isPro, setIsPro] = useState(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('pro') === '1') {
|
||||||
|
localStorage.setItem('sunscope_pro', '1');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return localStorage.getItem('sunscope_pro') === '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. PROFILE + COLUMN VISIBILITY ───────────────────────────────────
|
||||||
|
const [activeProfile, setActiveProfile] = useState(() => {
|
||||||
|
try { return localStorage.getItem('sunscope_profile') || 'basic'; } catch (e) { return 'basic'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const [visibleCols, setVisibleCols] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = 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 = localStorage.getItem('sunscope_outdoors_variant') || 'urban';
|
||||||
|
const variantCols = OUTDOORS_VARIANTS[savedVariant]?.cols
|
||||||
|
?? FILTER_PROFILES.outdoors.cols;
|
||||||
|
return { ...variantCols };
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
|
const profile = FILTER_PROFILES[key];
|
||||||
|
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||||
|
setActiveProfile(key);
|
||||||
|
if (key !== 'custom') {
|
||||||
|
setVisibleCols({ ...profile.cols });
|
||||||
|
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||||
|
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||||||
|
setIndoorManaged(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 5. SKIN, VEHICLE, INDOOR, POLLEN ─────────────────────────────────
|
||||||
|
const [skinType, setSkinType] = useState('II');
|
||||||
|
const [vehicleType, setVehicleType] = useState('car');
|
||||||
|
const [vehicleVent, setVehicleVent] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
// 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'];
|
||||||
|
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeCols = activeProfile === 'outdoors'
|
||||||
|
? (OUTDOORS_VARIANTS[outdoorsVariant]?.cols ?? FILTER_PROFILES.outdoors.cols)
|
||||||
|
: (FILTER_PROFILES[activeProfile]?.cols ?? FILTER_PROFILES.basic.cols);
|
||||||
|
|
||||||
|
const [buildingType, setBuildingType] = useState('brick');
|
||||||
|
const [indoorManaged, setIndoorManaged] = useState(false);
|
||||||
|
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'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
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 searchTimeout = useRef(null);
|
||||||
|
|
||||||
|
// Derived selectors used by profile controls
|
||||||
|
const activityOptions = [
|
||||||
|
{
|
||||||
|
value: 'farming',
|
||||||
|
label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`,
|
||||||
|
},
|
||||||
|
...activityVariantKeys.map((k) => {
|
||||||
|
const v = OUTDOORS_VARIANTS[k];
|
||||||
|
return {
|
||||||
|
value: k,
|
||||||
|
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const placeOptions = placeVariantKeys.map((k) => {
|
||||||
|
const v = OUTDOORS_VARIANTS[k];
|
||||||
|
return {
|
||||||
|
value: k,
|
||||||
|
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityValue = activeProfile === 'farming'
|
||||||
|
? 'farming'
|
||||||
|
: activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
|
||||||
|
? outdoorsVariant
|
||||||
|
: 'off';
|
||||||
|
const activityLabel = activityOptions.find((o) => o.value === activityValue)?.label;
|
||||||
|
const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
|
||||||
|
? outdoorsVariant
|
||||||
|
: 'off';
|
||||||
|
const placeLabel = placeOptions.find((o) => o.value === placeValue)?.label;
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
forecast, visibleCols, selectedDay, skinType, vehicleType,
|
||||||
|
indoorMode, indoorManaged,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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]);
|
||||||
|
|
||||||
|
// ── 9. COMPUTATION ───────────────────────────────────────────────────
|
||||||
|
const { hourlyRows, days, utcOffsetMs } = buildHourlyRows({
|
||||||
|
forecast, airQuality, location, vehicleType, vehicleVent, buildingType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = days[selectedDay]?.rows || [];
|
||||||
|
|
||||||
|
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. BANNER + EVENTS ───────────────────────────────────────────────
|
||||||
|
const [dismissedEventIds, setDismissedEventIds] = useState([]);
|
||||||
|
|
||||||
|
const BANNER_SNOOZE_HOURS = 6;
|
||||||
|
const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
|
||||||
|
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
|
||||||
|
|
||||||
|
const [bannerIndex, setBannerIndex] = useState(0);
|
||||||
|
const [bannerTransition, setBannerTransition] = useState(null);
|
||||||
|
const [bannerPrevIndex, setBannerPrevIndex] = useState(null);
|
||||||
|
const [bannerVisible, setBannerVisible] = useState(false);
|
||||||
|
const bannerIndexRef = useRef(0);
|
||||||
|
const bannerStageRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
|
||||||
|
|
||||||
|
const isSnoozed = id => {
|
||||||
|
try {
|
||||||
|
const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
|
||||||
|
return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayRows = days[0]?.rows || [];
|
||||||
|
const activeEvents = getActiveEvents(todayRows, location)
|
||||||
|
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
|
||||||
|
const lensEvent = getLensEvent(activeEvents);
|
||||||
|
const selectedDayEvents = getActiveEvents(visible, location);
|
||||||
|
|
||||||
|
// Only show banner when events first appear - not on every re-render
|
||||||
|
// Using a ref to track previous length avoids re-showing during dismiss animation
|
||||||
|
const prevActiveEventsLenRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevActiveEventsLenRef.current;
|
||||||
|
prevActiveEventsLenRef.current = activeEvents.length;
|
||||||
|
if (activeEvents.length > 0 && prev === 0) setBannerVisible(true);
|
||||||
|
}, [activeEvents.length]);
|
||||||
|
|
||||||
|
const bannerSlideTo = useCallback((next) => {
|
||||||
|
const stage = bannerStageRef.current;
|
||||||
|
if (stage) {
|
||||||
|
stage.style.height = stage.offsetHeight + 'px';
|
||||||
|
stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)';
|
||||||
|
}
|
||||||
|
setBannerPrevIndex(bannerIndexRef.current);
|
||||||
|
setBannerIndex(next);
|
||||||
|
setBannerTransition('crossfading');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (stage) {
|
||||||
|
const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)');
|
||||||
|
if (incoming) stage.style.height = incoming.offsetHeight + 'px';
|
||||||
|
}
|
||||||
|
}, 16);
|
||||||
|
setTimeout(() => {
|
||||||
|
setBannerTransition(null);
|
||||||
|
setBannerPrevIndex(null);
|
||||||
|
if (stage) { stage.style.height = ''; stage.style.transition = ''; }
|
||||||
|
}, 460);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissBanner = useCallback((evId) => {
|
||||||
|
// Do NOT write the snooze to localStorage yet. activeEvents filters out snoozed events
|
||||||
|
// synchronously on the very next render. so writing here would cause the banner to be
|
||||||
|
// unmounted before the fade animation can start - which is what made it snap closed.
|
||||||
|
// We snooze AFTER the animation finishes inside the setTimeout below.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setBannerVisible(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
|
||||||
|
setDismissedEventIds(ids => [...ids, evId]);
|
||||||
|
setBannerIndex(0);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeEvents.length <= 1) { setBannerIndex(0); return; }
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const next = (bannerIndexRef.current + 1) % activeEvents.length;
|
||||||
|
bannerSlideTo(next);
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [activeEvents.length, activeEvents.map(e => e.id).join(','), bannerSlideTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bannerIndex >= activeEvents.length) setBannerIndex(0);
|
||||||
|
}, [activeEvents.length]);
|
||||||
|
|
||||||
|
// ── RETURN ALL STATE + HANDLERS ───────────────────────────────────────
|
||||||
|
return {
|
||||||
|
// location
|
||||||
|
location, setLocationAndSave,
|
||||||
|
// forecast
|
||||||
|
forecast, airQuality, loading, error, now,
|
||||||
|
// 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,
|
||||||
|
activityValue, activityLabel,
|
||||||
|
placeValue, placeLabel,
|
||||||
|
// skin, vehicle, indoor, pollen
|
||||||
|
skinType, setSkinType,
|
||||||
|
vehicleType, setVehicleType,
|
||||||
|
vehicleVent, setVehicleVent,
|
||||||
|
outdoorsVariant, setOutdoorsVariantAndSave,
|
||||||
|
buildingType, setBuildingType,
|
||||||
|
indoorManaged, setIndoorManaged,
|
||||||
|
indoorMode, setIndoorMode,
|
||||||
|
pollenType, setPollenTypeAndSave,
|
||||||
|
// 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, nowLocalISO, currentRow, currentCat,
|
||||||
|
// banner + events
|
||||||
|
activeEvents, lensEvent, selectedDayEvents,
|
||||||
|
bannerIndex, bannerTransition, bannerPrevIndex,
|
||||||
|
bannerVisible, bannerStageRef,
|
||||||
|
bannerSlideTo, dismissBanner,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user