// ------------------------------------------------------------------------
// 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 - current vehicle config, for the "Viewing" 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, petCategory, UTCI_BANDS, PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.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, 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, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel,
mainMetricLabel, mainMetricDesc, secondaryMetricLabel, secondaryMetricDesc,
} = 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}>
${/* Fixed label column outside the scroller — names every stacked row
in each tab (day name, date, icon, then the two numbers, since
which metrics those are changes with the active profile). Mirrors
the tabs' own top-down layout row for row (see .utci-day-legend-col
in table.css). */''}
${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;
// Secondary row under each hi/lo: Shade for SunSoak, Managed for
// indoor, Pet Shade for pets, and plain air temperature for
// Vehicle/Driver so the outside-vs-cabin gap is visible.
let shadeHi = null, shadeLo = null;
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.
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
// event banner can colour itself from exactly the same source.
const catFn = isPetsProfile ? petCategory : utciCategory;
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' };
// Band colour, but ramped: bandRampRgb() interpolates between the
// this band's colour and the next one's, so 30° isn't the same
// swatch as 27° while 32° still lands on the Extreme orange
// (see BAND COLOUR RAMP in utils.js). Danger stays flat — it's an
// alarm colour, not a point on the gradient.
const tBandRgb = tBand.solid
? hexToRgb(tBand.bg)
: bandRampRgb(dayHi, isPetsProfile ? PET_BANDS : UTCI_BANDS);
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;
// Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the
// Pets profile (furSurfaceT, judged against the shifted PET_BANDS)
// the same cutoffs are re-projected onto the pet scale so the
// "hot hue" / "plain cold" branches kick in at the same relative
// point in each band, not the same absolute degree.
const isHot = dayHi !== null && dayHi >= (isPetsProfile ? 35 : 27); // Hot band and above
const isDanger = tBand.solid === true;
// Extreme band gets a less pastelised gradient so it reads as a
// clear step between Caution and the pulsing Danger tier.
const isExtreme = tBand.label === 'Extreme';
// 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 = tBandRgb;
} 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 = isPetsProfile
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45)
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45);
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mixRgb(tBandRgb, [170, 162, 152], t);
} else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth.
rgb = snowTint(daySnow);
} else if (showPrecip) {
// Rain: light → deep blue with amount (pure blue, no temperature hue).
rgb = rainTint(dayPrecip);
} else if (avgCloud >= 30) {
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
rgb = cloudTint(avgCloud);
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) {
// Clear & cold: keep the cold temperature blue.
rgb = tBandRgb;
} else {
// Clear & mild/warm: a plain sunny yellow.
rgb = hexToRgb('#f8e554');
}
// Extreme tabs lean toward Danger's crimson as the daily high climbs
// through the band (32→41°C human, 40→52°C pet) — so a day peaking
// near 40° visibly edges into danger-red without becoming Danger.
if (isExtreme) {
const floor = isPetsProfile ? 40 : 32;
const ceil = isPetsProfile ? 52 : 41;
const t = Math.max(0, Math.min(1, (dayHi - floor) / (ceil - floor)));
rgb = mixRgb(rgb, [136, 0, 0], t * 0.55);
}
const [wR, wG, wB] = rgb;
// How pastel the tab is drawn also carries heat: the tint is blended
// toward white, and the hotter the day the less white goes in. This
// used to step at the band edges (0.50 for everything up to Caution,
// then 0.20 at Extreme), which made 30° look like a washed-out yellow
// while 32° snapped to a solid orange. Ramp the blend continuously
// instead — the endpoints still land on the old Extreme (heatT = 1)
// and Danger (both = 1) values, so only the in-between days change.
const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const heatT = dayHi === null ? 0 : clamp01((dayHi - heatFloor) / (heatMid - heatFloor));
const overT = dayHi === null ? 0 : clamp01((dayHi - heatMid) / (heatTop - heatMid));
const wBgNeutral = weatherGradientNeutral(
wR, wG, wB,
0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15
0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72
);
const wBgActive = weatherGradientActive(
wR, wG, wB,
0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12
0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72
);
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)})`;
// Extreme tabs get a strong red border so the tier pops even when
// the tab is not selected — a firmer warning than the tan default.
const wBorder = isExtreme ? '#c22f1a' : '#c9b08a';
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.',
},
// Edit columns button on the forecast toolbar
'columns': {
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}>`;
}