// ------------------------------------------------------------------------ // components/DayTabs.js - Day-tab strip, pro-prompt card and confidence // band bar 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. // // The profile picker and config strip that used to live here now sit in the // ConfigPanel nav dropdown; DayTabs keeps the derived "main" metric needed // to compute the day-tab hi/lo numbers, and renders a read-only row above // the day tabs stating the active profile + thermal basis so it's always // clear what temperatures are being shown (click/Enter opens the dropdown). // // Props: // days - array of day objects from buildHourlyRows // selectedDay/setSelectedDay - active day tab index + setter // isPro - boolean Pro status // openRestore - opens the "Already subscribed?" restore modal // proPromptDay/setProPromptDay - locked-day upsell index + setter // proPromptSource/setProPromptSource - upsell copy key + setter // activeProfile - current filter profile key // outdoorsVariant - current outdoors sub-variant key // openPanel - opens the profile/config nav dropdown // vehicleType/vehicleSpeed - current vehicle config (vehicle-cabin day-tab // reference calc + row label) // buildingType/indoorManaged/utciEnv/furColor - current config values, // for the "Viewing" row label // dayTabsRef - ref for the scrollable tab strip element // canScrollLeft/canScrollRight - booleans for the fade chevrons // scrollDayTabs - function(dir) to scroll strip left/right // ------------------------------------------------------------------------ import { h, Fragment } from '../../vendor/preact.js'; import htm from '../../vendor/htm.js'; import { confidenceBand, utciCategory, VEHICLE_SPEEDS, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js'; import { calcVehicleInteriorTemp } from '../physics.js'; import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; import { SubscribeModal } from './SubscribeModal.js'; const html = htm.bind(h); // Per-selection gradients for the active-profile-row background — one entry // per possible value of each thermal-model category, so the row's shade // reflects not just "which model" (SunSoak/Vehicle/Indoor/Pets) but the // specific option currently picked within it (e.g. Beach vs Forest), the // same way the profile cards use a distinct "scene" per preset. const ROW_GRADIENTS = { solar: { open: 'linear-gradient(120deg, rgba(150,200,120,0.55), rgba(222,236,196,0.30))', urban: 'linear-gradient(120deg, rgba(160,168,178,0.55), rgba(218,222,228,0.30))', beach: 'linear-gradient(120deg, rgba(240,208,140,0.55), rgba(182,220,236,0.30))', river: 'linear-gradient(120deg, rgba(120,196,182,0.55), rgba(202,236,228,0.30))', forest: 'linear-gradient(120deg, rgba(66,124,70,0.55), rgba(170,202,156,0.30))', openwater: 'linear-gradient(120deg, rgba(96,150,204,0.55), rgba(180,214,238,0.30))', alpine: 'linear-gradient(120deg, rgba(180,210,230,0.55), rgba(238,246,250,0.30))', desert: 'linear-gradient(120deg, rgba(220,164,96,0.55), rgba(246,220,172,0.30))', }, vehicle: { car: 'linear-gradient(120deg, rgba(180,192,202,0.55), rgba(222,228,234,0.30))', mpv: 'linear-gradient(120deg, rgba(172,186,200,0.55), rgba(216,224,232,0.30))', suv: 'linear-gradient(120deg, rgba(154,172,190,0.55), rgba(206,216,228,0.30))', truck: 'linear-gradient(120deg, rgba(136,158,180,0.55), rgba(196,208,222,0.30))', motorhome: 'linear-gradient(120deg, rgba(200,180,148,0.55), rgba(232,220,196,0.30))', caravan: 'linear-gradient(120deg, rgba(208,188,158,0.55), rgba(236,224,204,0.30))', }, indoor: { brick: 'linear-gradient(120deg, rgba(190,120,92,0.50), rgba(226,178,158,0.28))', modern: 'linear-gradient(120deg, rgba(162,172,180,0.50), rgba(212,218,224,0.28))', victorian: 'linear-gradient(120deg, rgba(160,76,54,0.50), rgba(210,142,122,0.28))', stone: 'linear-gradient(120deg, rgba(146,138,124,0.50), rgba(202,196,184,0.28))', timber: 'linear-gradient(120deg, rgba(182,142,92,0.50), rgba(222,192,150,0.28))', flat: 'linear-gradient(120deg, rgba(162,162,168,0.50), rgba(212,212,218,0.28))', conservatory: 'linear-gradient(120deg, rgba(140,198,220,0.50), rgba(202,232,242,0.28))', office: 'linear-gradient(120deg, rgba(122,148,184,0.50), rgba(188,204,226,0.28))', }, fur: { black: 'linear-gradient(120deg, rgba(52,48,44,0.55), rgba(112,106,100,0.30))', brown: 'linear-gradient(120deg, rgba(126,84,48,0.55), rgba(178,140,98,0.30))', golden: 'linear-gradient(120deg, rgba(208,160,80,0.55),rgba(236,204,144,0.30))', white: 'linear-gradient(120deg, rgba(222,214,198,0.55),rgba(248,246,238,0.30))', }, }; // 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, outdoorsVariant, openPanel, vehicleType, vehicleSpeed, buildingType, indoorManaged, utciEnv, furColor, dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs, }) { // Which metric drives the day-tab's hi/lo readout depends on the active // profile — default is SunSoak (felt) + Shade, but profiles with their own // dedicated calc show their own numbers instead. The profile picker and the // full config strip now live in the nav dropdown (ConfigPanel); here we // only need the derived main metric to compute the day-tab numbers and to // label the "what temps am I looking at" row below. const { isHomeOrOffice, isVehicleProfile, mainConfigKey, mainField, secondaryField, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant); // Read-only label of the current main-config value, for the row above the // day tabs — makes it obvious at a glance which temperature basis (SunSoak // environment, vehicle type, building type, fur colour) the numbers below // are computed from. const mainConfigValue = mainConfigKey === 'vehicle' ? (VEHICLE_TYPES[vehicleType]?.name || '') : mainConfigKey === 'indoor' ? ((BUILDING_TYPES[buildingType]?.name || '') + (indoorManaged ? ' · Managed' : '')) : mainConfigKey === 'fur' ? (FUR_COLORS[furColor]?.name || '') : (UTCI_ENVIRONMENTS[utciEnv]?.label || ''); // Tints the row background to match the thermal model driving it, reusing // the same colour groups the CustomSelect pickers use (e.g. SunSoak's // "Solar model" dropdown is grp-felt) so the row reads as an extension of // those controls rather than a separate, unrelated style. const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind' : mainConfigKey === 'indoor' ? 'grp-ambient' : mainConfigKey === 'fur' ? 'grp-surface' : 'grp-felt'; // Within that group, shade further by the exact option chosen — same idea // as the "Solar model" pulldown, but keyed to whichever value is actually // driving the numbers right now (environment / vehicle / building / fur). const rowOptionKey = mainConfigKey === 'vehicle' ? vehicleType : mainConfigKey === 'indoor' ? buildingType : mainConfigKey === 'fur' ? furColor : utciEnv; const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey]; return html` <${Fragment}>
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }} style=${rowGradient ? { '--row-bg': rowGradient } : undefined} title="Change profile & settings"> Profile ${mainLabel} ${mainConfigValue && html` Viewing ${mainConfigValue} `}
${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 ? 'Tom' : dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }); const mainVals = d.rows.map(r => r[mainField]).filter(v => isFinite(v)); const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; // Vehicle/Driver: no table column exists for the ventilated cabin // temp (ventilation is just an input toggle on the main vehicleT // calc), so run the physics model again with vent forced on, to // show "if you opened the windows" as a reference hi/lo. let shadeHi = null, shadeLo = null; if (isVehicleProfile) { const speedMph = (VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).mph; const ventVals = d.rows .map(r => calcVehicleInteriorTemp(r.Ta, r.glob, r.elev, vehicleType, true, speedMph)) .filter(v => isFinite(v)); shadeHi = ventVals.length ? Math.round(Math.max(...ventVals)) : null; shadeLo = ventVals.length ? Math.round(Math.min(...ventVals)) : null; } else if (secondaryField) { const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v)); shadeHi = secondaryVals.length ? Math.round(Math.max(...secondaryVals)) : null; shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : 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; // Vehicle tab: swap the default car glyph for a hazard icon when // conditions could affect driving. Uses the full day (not just // daylight repRows) since fog and frost often hit overnight or on // an early commute, then picks the worst hazard by priority. let vehicleHazard = null; let hazPrecip = 0, hazSnow = 0; if (isVehicleProfile) { const allSnow = d.rows.reduce((s, r) => s + (r.snow || 0), 0); const allPrecip = d.rows.reduce((s, r) => s + (r.precip || 0), 0); const visVals = d.rows.map(r => r.visKm).filter(v => isFinite(v)); const minVis = visVals.length ? Math.min(...visVals) : null; const taVals = d.rows.map(r => r.Ta).filter(v => isFinite(v)); const minTa = taVals.length ? Math.min(...taVals) : null; if (allSnow >= 0.1) { vehicleHazard = 'snow'; hazSnow = allSnow; } else if (minTa !== null && minTa <= 0 && allPrecip < 0.3) { vehicleHazard = 'ice'; } else if (minVis !== null && minVis < 2) { vehicleHazard = 'fog'; } else if (allPrecip >= 0.3) { vehicleHazard = 'rain'; hazPrecip = allPrecip; } } // Day-tab colour — PRIORITY model, no hue blending. // Mixing a warm yellow with grey/blue passes through green, so rather // than blend temperature with sky we pick ONE dimension by priority and // only vary its shade: heat → snow → rain → cloud → sun (first wins). // • Hot/extreme days always keep their heat colour (a safety signal). // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t)))); const hex2rgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; const tBand = dayHi !== null ? utciCategory(dayHi) : { bg: '#f8e554' }; const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; const isHot = dayHi !== null && dayHi >= 29; // Hot band and above const isDanger = tBand.solid === true; // Home/indoor/vehicle tabs show a modelled temperature, not the // outdoor sky, so the tab shade should track that temperature only // — no rain/snow/cloud tinting, and no weather icon. const tempOnly = isHomeOrOffice || isVehicleProfile; let rgb; if (tempOnly) { rgb = hex2rgb(tBand.bg); } else if (isHot) { // Keep the heat hue; cloud only mutes it a touch (orange/red never // greens) and rain does not override heat. const heatFactor = dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45; const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor; rgb = mix(hex2rgb(tBand.bg), [170, 162, 152], t); } else if (daySnow >= 0.1) { // Snow: pale → icy blue with depth. rgb = mix([226, 234, 244], [176, 202, 232], Math.max(0, Math.min(1, daySnow / 1.5))); } else if (showPrecip) { // Rain: light → deep blue with amount (pure blue, no temperature hue). rgb = mix([150, 176, 212], [44, 84, 150], Math.max(0, Math.min(1, dayPrecip / 8))); } else if (avgCloud >= 30) { // Cloud: light → dark grey with cover (pure grey, no temperature hue). rgb = mix([205, 206, 208], [110, 115, 122], Math.max(0, Math.min(1, (avgCloud - 30) / 70))); } else if (dayHi !== null && dayHi < 10) { // Clear & cold: keep the cold temperature blue. rgb = hex2rgb(tBand.bg); } else { // Clear & mild/warm: a plain sunny yellow. rgb = hex2rgb('#f8e554'); } const [wR, wG, wB] = rgb; 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.', }, 'variant:driver': { title: 'Driver / Trucker is part of SunScope Extra', detail: 'Extra adds the Driver / Trucker profile with vehicle interior temperature, UV, wind speed and direction, rain probability, and visibility — the conditions that matter for long hours on the road.', }, '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.', }, }; // Day-tab locks get the day-specific heading; any other locked feature // without its own entry above falls back to a feature-agnostic message. const fallbackCopy = proPromptSource === 'day' ? { 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.', } : { title: 'This is part of SunScope Extra', detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.', }; const promptCopy = extraPromptCopy[proPromptSource] || fallbackCopy; return html` <${SubscribeModal} title=${promptCopy.title} detail=${promptCopy.detail} onClose=${() => setProPromptDay(null)} openRestore=${openRestore} />`; })()} `; }