// ------------------------------------------------------------------------ // 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. function weatherGradientNeutral(r, g, b) { const blend = (c) => Math.round(c + (255 - c) * 0.58); const [mr, mg, mb] = [blend(r), blend(g), blend(b)]; const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const center = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`; const edge = `rgb(${mr},${mg},${mb})`; return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`; } function weatherGradientActive(r, g, b) { const blend = (c) => Math.round(c + (255 - c) * 0.42); const [mr, mg, mb] = [blend(r), blend(g), blend(b)]; const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const center = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`; const edge = `rgb(${mr},${mg},${mb})`; 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}>