// ------------------------------------------------------------------------
// 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, FILTER_PROFILES, variantIcons } from '../config.js';
import { calcVehicleInteriorTempPass } 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}>
${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;
// Run the lagged pass over the whole day, not per-hour equilibrium,
// so this hi/lo is directly comparable with the vehicleT column.
const ventVals = calcVehicleInteriorTempPass(
d.rows.map(r => r.Ta), d.rows.map(r => r.glob), d.rows.map(r => r.elev),
d.rows.map(r => r.va), vehicleType, true, speedMph
).filter(v => v != null && 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}
/>`;
})()}
${Fragment}>`;
}