Files
sunscope/assets/js/components/DayTabs.js
T
fraxle ad889d938b 2.1
New profile icons
New profile higher position
Updated Column toggle row
Fixed text encoding
2026-06-01 12:59:19 +01:00

622 lines
28 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
// workOptions - dropdown options for Work selector
// activityValue - current activity dropdown value
// activityLabel - current activity dropdown label
// placeValue - current place dropdown value
// placeLabel - current place dropdown label
// workValue - current work dropdown value
// workLabel - current work 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 { useRef, useEffect } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js';
import { confidenceBand } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
const html = htm.bind(h);
// Converts a base RGB colour into the same 135° gradient used by the thermal
// bands legend: 50% white blend to pastelise, then a subtle light→dark sweep.
function weatherGradient(r, g, b) {
const blend = (c) => Math.round(c + (255 - c) * 0.82);
const [mr, mg, mb] = [blend(r), blend(g), blend(b)];
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
const darken = (c) => Math.round(c * 0.96);
const light = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`;
const mid = `rgb(${mr},${mg},${mb})`;
const dark = `rgb(${darken(mr)},${darken(mg)},${darken(mb)})`;
return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
}
export function DayTabs({
days,
selectedDay, setSelectedDay,
isPro,
proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource,
activeProfile, activateProfile, activeCols,
visibleCols,
activityOptions, placeOptions, workOptions,
activityValue, activityLabel,
placeValue, placeLabel,
workValue, workLabel,
outdoorsVariant, setOutdoorsVariantAndSave,
setActiveProfile, setVisibleCols,
setIndoorMode, setIndoorManaged, setBuildingType,
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
}) {
const profileScrollRef = useRef(null);
const profileWrapRef = useRef(null);
useEffect(() => {
const el = profileScrollRef.current;
const wrap = profileWrapRef.current;
if (!el) return;
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
const updateFades = () => {
if (!wrap) return;
wrap.classList.toggle('fade-left', el.scrollLeft > 1);
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
const onMouseDown = (e) => {
if (!el.contains(e.target) || e.button !== 0) return;
isDown = true; hasDragged = false;
startX = e.clientX; startScroll = el.scrollLeft;
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const onMouseMove = (e) => {
if (!isDown) return;
const dx = e.clientX - startX;
if (Math.abs(dx) > 5) {
hasDragged = true;
el.style.cursor = 'grabbing';
el.scrollLeft = startScroll - dx;
}
};
const onMouseUp = () => {
if (!isDown) return;
isDown = false;
el.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
};
const onClickCapture = (e) => {
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
};
el.addEventListener('scroll', updateFades);
updateFades(); // set initial fade state
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
el.addEventListener('click', onClickCapture, true);
return () => {
el.removeEventListener('scroll', updateFades);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
el.removeEventListener('click', onClickCapture, true);
};
}, []);
return html`
<${Fragment}>
<div class="filter-profiles">
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
<div class="profile-scroll" ref=${profileScrollRef}>
${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);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${profile.label}${locked ? ' 🔒' : ''}</span>
</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}`}
grpClass="profile-card"
sceneStyle=${{ background: placeValue !== 'off' && OUTDOORS_VARIANTS[placeValue]?.scene ? OUTDOORS_VARIANTS[placeValue].scene : "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)" }}
sceneContent=${null}
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 === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)}
noHide=${true}
hideLabel="Activities"
buttonLabel=${activityValue === 'off' ? 'Activities' : `${activityLabel}`}
grpClass="profile-card"
sceneStyle=${{ background: activityValue !== 'off' && OUTDOORS_VARIANTS[activityValue]?.scene ? OUTDOORS_VARIANTS[activityValue].scene : "url('assets/images/profiles/activities.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0d8c0)" }}
sceneContent=${null}
options=${activityOptions}
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="work"
value=${workValue}
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))}
noHide=${true}
hideLabel="Work"
buttonLabel=${workValue === 'off' ? 'Work' : `${workLabel}`}
grpClass="profile-card"
sceneStyle=${{ background: workValue === 'farming' ? FILTER_PROFILES.farming.scene : workValue !== 'off' && OUTDOORS_VARIANTS[workValue]?.scene ? OUTDOORS_VARIANTS[workValue].scene : "url('assets/images/profiles/work.png') center / cover no-repeat, linear-gradient(160deg,#b0b0d4,#d0d0e8)" }}
sceneContent=${null}
options=${workOptions}
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 });
if (v === 'office') {
setBuildingType('office');
setIndoorMode('on');
setIndoorManaged(false);
} else {
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);
}}
>
<span class="profile-btn-scene" style=${{ background: profile.scene }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
<span class="profile-btn-label">${profile.label}${locked ? ' 🔒' : ''}</span>
</button>`;
})}
</div>
</div>
</div>
<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;
// Base RGB for each weather condition — pastelised by weatherGradient()
const [wR, wG, wB] = (daySnow >= 0.1) ? [100, 160, 230]
: (dayHi > 38) ? [210, 60, 30]
: showPrecip ? [ 50, 120, 200]
: (dayHi > 32) ? [220, 110, 30]
: (dayHi > 26) ? [220, 180, 30]
: modalCloudCat === 'overcast' ? [100, 110, 130]
: (dayHi !== null && dayHi < 9) ? [ 60, 110, 180]
: modalCloudCat === 'scattered' ? [160, 150, 130]
: modalCloudCat === 'wispy' ? [200, 180, 130]
: [220, 190, 50];
const wBg = weatherGradient(wR, wG, wB);
const wText = '#2a1d10';
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: wBg,
color: wText,
border: '1px solid #c9b08a',
opacity: locked ? 0.5 : 1,
cursor: locked ? 'not-allowed' : 'pointer',
position: 'relative',
filter: isActive ? 'saturate(1.1) brightness(1.04)' : 'none',
}}
>
<span style=${{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: isActive ? '5px' : '4px',
background: band.bg,
borderTop: '1px solid #c9b08a',
borderRadius: '0 0 2px 2px',
transition: 'height 0.1s',
}} aria-hidden="true"></span>
${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 = {
// Profile buttons
'profile:alltemps': {
title: 'Temps is part of SunScope Extra',
detail: 'Extra unlocks the temperature comparison view — air, soil, concrete, vehicle interior, and indoor estimates side by side in one place.',
},
'profile:showall': {
title: 'Show All is part of SunScope Extra',
detail: 'Extra unlocks the full column set — every data point SunScope tracks, visible at once for deep-dive analysis.',
},
'profile:custom': {
title: 'Custom columns are part of SunScope Extra',
detail: 'Extra lets you hand-pick exactly which columns appear. Mix and match air, dew, soil, UV, UTCI and more to build your perfect view.',
},
// Places
'variant:construction': {
title: 'Construction is part of SunScope Extra',
detail: 'Extra adds the Construction profile with felt temperature, wind, rain probability, visibility, and air quality — the key factors for safe site working.',
},
// Activities
'variant:hiking': {
title: 'Hiking is part of SunScope Extra',
detail: 'Extra adds the Hiking profile with felt temperature, UV index, burn time, wind direction, and visibility — essential for longer days on exposed terrain.',
},
'variant:photography': {
title: 'Photography is part of SunScope Extra',
detail: 'Extra adds the Photography profile with direct and diffuse radiation, sun elevation, cloud cover, and visibility — the conditions that make or break a shoot.',
},
'variant:sailing': {
title: 'Sailing is part of SunScope Extra',
detail: 'Extra adds the Sailing profile with wind speed and direction, dew point, UV, burn time, sun elevation, and visibility for on-water planning.',
},
'variant:wintersports': {
title: 'Winter Sports is part of SunScope Extra',
detail: 'Extra adds the Winter Sports profile with UV-A and UV-B, burn time, sun elevation, wind direction, and visibility for snow and slope conditions.',
},
'variant:naturist': {
title: 'Naturist is part of SunScope Extra',
detail: 'Extra adds the Naturist profile with full skin-exposure detail — UV, burn time, felt temperature, dew point, humidity, and air quality.',
},
// Work
'variant:market': {
title: 'Market Trading is part of SunScope Extra',
detail: 'Extra adds the Market Trading profile with felt temperature, wind direction, rain probability, and visibility — the key factors for planning stall days.',
},
'variant:windowcleaning': {
title: 'Window Cleaning is part of SunScope Extra',
detail: 'Extra adds the Window Cleaning profile focused on wind speed and direction, rain, and felt temperature — the conditions that determine whether work is safe and worthwhile.',
},
'variant:office': {
title: 'Office is part of SunScope Extra',
detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.',
},
};
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,
}}>
<span style=${{ fontStyle: 'normal' }}>🔒</span> ${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: '0 0 14px',
padding: '6px 10px',
background: band.tint,
borderLeft: `3px solid ${band.edge}`,
borderBottom: `1px 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>`;
})()}
</${Fragment}>`;
}