1.7.1
Split code into smaller sections and fix things that broke along the way
This commit is contained in:
@@ -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}>`;
|
||||
}
|
||||
Reference in New Issue
Block a user