// ------------------------------------------------------------------------ // 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 // openRestore - opens the "Already subscribed?" restore modal // 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, useState } 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'; import { SubscribeModal } from './SubscribeModal.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%)`; } // Active-tab variant: a more solid (less pastelised) version of the weather // colour, drawn as a radial gradient whose strong colour sits at the outer // edge and softens toward a lighter centre — so the hue reads as radiating // inward from the outside of the tab. // edgeBlend pastelises the outer edge (higher = lighter). centerBlend, when // supplied, blends the centre straight from the raw colour for a lighter core // (used by the Danger tier to keep a dark edge but a light centre); otherwise // the centre is just a lightened version of the edge (original behaviour). function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, centerBlend = null) { const blend = (c, amt) => Math.round(c + (255 - c) * amt); const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)]; const [cr, cg, cb] = centerBlend != null ? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)] : [lighten(er), lighten(eg), lighten(eb)]; const center = `rgb(${cr},${cg},${cb})`; const edge = `rgb(${er},${eg},${eb})`; return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; } function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) { const blend = (c, amt) => Math.round(c + (255 - c) * amt); const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)]; const [cr, cg, cb] = centerBlend != null ? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)] : [lighten(er), lighten(eg), lighten(eb)]; const center = `rgb(${cr},${cg},${cb})`; const edge = `rgb(${er},${eg},${eb})`; return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; } export function DayTabs({ days, selectedDay, setSelectedDay, isPro, openRestore, 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); // Active tab: common - places - activities - work. // Auto-derived from the current profile on mount and on change. const getTab = () => { if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places'; if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities'; if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work'; return 'common'; }; const [activeTab, setActiveTab] = useState(getTab); useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]); 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}>
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html` `)}
${activeTab === 'common' && profileButtonOrder.map((key) => { const profile = FILTER_PROFILES[key]; const locked = profile.proOnly && !isPro; return html` `; })} ${activeTab === 'places' && placeOptions.map((opt) => html` `)} ${activeTab === 'activities' && activityOptions.map((opt) => html` `)} ${activeTab === 'work' && workOptions.map((opt) => html` `)}
${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 core daylight rows (elev > 10°) where // available, falling back to any above-horizon rows, then all rows. const coreRows = d.rows.filter(r => r.elev > 10); const aboveRows = d.rows.filter(r => r.elev > 0); const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : 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'; // Use solar-noon row (highest elevation) as the representative row const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null); const repElev = noonRow ? noonRow.elev : 45; const repDt = noonRow ? noonRow.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 > 44) ? [136, 0, 0] : (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]; // Danger days keep a dark, saturated edge with a lighter centre. const isDanger = dayHi !== null && dayHi > 44; const wBg = weatherGradient(wR, wG, wB); const wBgNeutral = isDanger ? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72) : weatherGradientNeutral(wR, wG, wB); const wBgActive = isDanger ? weatherGradientActive(wR, wG, wB, 0.12, 0.72) : weatherGradientActive(wR, wG, wB); const wText = '#2a1d10'; // Active-tab outline: the day's weather colour, 20% darker. const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`; 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 = { // 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.', }, 'export': { title: 'Export day data is part of SunScope Extra', detail: 'Extra lets you download a full hourly spreadsheet for any day — all 41 columns including felt temperature, UV, soil, wind, and air quality, styled and ready to use in Excel or LibreOffice.', }, }; 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` <${SubscribeModal} title=${promptCopy.title} detail=${promptCopy.detail} onClose=${() => setProPromptDay(null)} openRestore=${openRestore} />`; })()} `; }