Files
sunscope/assets/js/components/DayTabs.js
T
fraxle 073e6c6b23 1.7.1
Split code into smaller sections and fix things that broke along the way
2026-05-21 13:21:05 +01:00

446 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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}>`;
}