// ------------------------------------------------------------------------ // 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 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); 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, }) { return html` <${Fragment}>
${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` `; })}
${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`
๐Ÿ”’ ${promptCopy.title}
${promptCopy.detail}
ยฃ2 / month ยท cancel any time
Subscribe โ€” ยฃ2/month Already subscribed? Restore access โ†’ Manage or cancel subscription โ†’
`; })()} ${(() => { const band = confidenceBand(selectedDay); const isOutlook = selectedDay >= 7; return html`
Day ${selectedDay + 1} of 14 ยท Forecast clarity: ${band.label} ${isOutlook && html` forecast skill is reduced โ€” treat hourly detail as trend, not precision `}
`; })()}
Profile: ${profileButtonOrder.slice(0, 3).map((key) => { const profile = FILTER_PROFILES[key]; const locked = profile.proOnly && !isPro; return html` `; })} <${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 === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)} noHide=${true} hideLabel="๐ŸŽฏ Activities" buttonLabel=${activityValue === 'off' ? '๐ŸŽฏ Activities' : ` ${activityLabel}`} 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}`} 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); } }} /> ${profileButtonOrder.slice(3).map((key) => { const profile = FILTER_PROFILES[key]; const locked = profile.proOnly && !isPro; return html` `; })}
`; }