Files
sunscope/assets/js/components/DayTabs.js
T
fraxle b10e0da25f 3.5.9
Updated pet calcs to be more accurate
Fixed some layout issues
Updated profile popup
2026-07-30 00:01:51 +01:00

539 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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}>`;
}