Updated pet calcs to be more accurate Fixed some layout issues Updated profile popup
539 lines
31 KiB
JavaScript
539 lines
31 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// 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, petCategory, 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, 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}>
|
||
|
||
<div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
|
||
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
|
||
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
|
||
title="Change profile & settings">
|
||
<span class="active-profile-row-item">
|
||
<span class="active-profile-row-label">Profile</span>
|
||
<span class="active-profile-row-value">
|
||
${activeProfile === 'outdoors'
|
||
? (variantIcons[outdoorsVariant] || '🌤️')
|
||
: (FILTER_PROFILES[activeProfile]?.icon || '🎯')}
|
||
${mainLabel}
|
||
</span>
|
||
</span>
|
||
${mainConfigValue && html`
|
||
<span class="active-profile-row-sep" aria-hidden="true">·</span>
|
||
<span class="active-profile-row-item">
|
||
<span class="active-profile-row-label">Viewing</span>
|
||
<span class="active-profile-row-value">${mainConfigValue}</span>
|
||
</span>`}
|
||
<span class="active-profile-row-edit" aria-hidden="true">Change ✎</span>
|
||
</div>
|
||
|
||
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
|
||
<button
|
||
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(-1)}
|
||
aria-label="Scroll days left"
|
||
type="button">‹</button>
|
||
<button
|
||
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(1)}
|
||
aria-label="Scroll days right"
|
||
type="button">›</button>
|
||
${/* 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). */''}
|
||
<div class="utci-day-legend-col">
|
||
<span class="utci-day-legend-day">Day</span>
|
||
<span class="utci-day-legend-date">Date</span>
|
||
<span class="utci-day-legend-icon">${isHomeOrOffice ? 'Home' : isVehicleProfile ? 'Alert' : 'Sky'}</span>
|
||
<div class="utci-day-legend-values">
|
||
<span class="utci-day-legend-main" title=${mainMetricDesc}>${mainMetricLabel}</span>
|
||
${(isVehicleProfile || secondaryField) && html`
|
||
<span class="utci-day-legend-sep" aria-hidden="true"></span>
|
||
<span class="utci-day-legend-sec" title=${secondaryMetricDesc}>${secondaryMetricLabel}</span>`}
|
||
</div>
|
||
</div>
|
||
<div class="utci-day-tabs" ref=${dayTabsRef}>
|
||
${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 catFn = isPetsProfile ? petCategory : utciCategory;
|
||
const tBand = dayHi !== null ? catFn(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;
|
||
|
||
// 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 : 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 = 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 = 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 < (isPetsProfile ? 2 : 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`
|
||
<button
|
||
key=${d.key}
|
||
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}${isDanger ? ' danger' : ''}`}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource('day');
|
||
setProPromptDay(i);
|
||
} else {
|
||
setSelectedDay(i);
|
||
setProPromptDay(null);
|
||
}
|
||
}}
|
||
title=${locked
|
||
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||
: `${band.label} · day ${i + 1} of 14`}
|
||
style=${{
|
||
background: isActive ? wBgActive : wBgNeutral,
|
||
color: wText,
|
||
borderTop: '1px solid #c9b08a',
|
||
borderRight: 'none',
|
||
borderBottom: '1px solid #c9b08a',
|
||
borderLeft: i === 0 ? '1px solid #c9b08a' : 'none',
|
||
opacity: locked ? 0.5 : 1,
|
||
cursor: locked ? 'not-allowed' : 'pointer',
|
||
position: 'relative',
|
||
filter: isActive ? 'saturate(1.1) brightness(1.04)' : 'none',
|
||
outline: isActive ? `2px solid ${wOutline}` : 'none',
|
||
outlineOffset: isActive ? '-2px' : '0',
|
||
}}
|
||
>
|
||
${locked && html`
|
||
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
|
||
<span class="utci-day-name">${dayName}</span>
|
||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||
</span>
|
||
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
|
||
${tempOnly
|
||
? (isVehicleProfile
|
||
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
|
||
: vehicleHazard === 'rain' ? html`<${PrecipIcon} precip=${hazPrecip} snow=${0} size=${30} />`
|
||
: vehicleHazard === 'fog' ? html`<${FogIcon} size=${30} />`
|
||
: vehicleHazard === 'ice' ? html`<${IceIcon} size=${30} />`
|
||
: html`<${CarIcon} size=${30} />`)
|
||
: html`<${HouseIcon} size=${30} />`)
|
||
: showPrecip
|
||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
|
||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
|
||
</span>
|
||
${dayHi !== null && html`
|
||
<span style=${{
|
||
display: 'flex',
|
||
alignItems: 'stretch',
|
||
justifyContent: 'center',
|
||
gap: '6px',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontStyle: 'normal',
|
||
letterSpacing: 0,
|
||
textTransform: 'none',
|
||
textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)',
|
||
marginTop: '2px',
|
||
}}>
|
||
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
|
||
<span style=${{ color: '#c0622a', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayHi}°</span>
|
||
${shadeHi !== null && html`
|
||
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
|
||
<span style=${{ color: '#c0622a', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeHi}°</span>`}
|
||
</span>
|
||
<span style=${{ width: '1px', background: 'rgba(0,0,0,0.15)', margin: '2px 0', alignSelf: 'stretch' }}></span>
|
||
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
|
||
<span style=${{ color: '#3a6080', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayLo}°</span>
|
||
${shadeLo !== null && html`
|
||
<span style=${{ width: '10px', height: '1px', background: 'rgba(0,0,0,0.15)', margin: '1px 0' }}></span>
|
||
<span style=${{ color: '#3a6080', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${shadeLo}°</span>`}
|
||
</span>
|
||
</span>`}
|
||
</button>`;
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
${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}>`;
|
||
}
|