New big and small scope sunset and sunrise simulation New calculations for the day tabs Added missing icons to simple view New record popups Week ahead description popups Camping profile Tweak to some of the indoor tems
392 lines
20 KiB
JavaScript
392 lines
20 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// components/ConfigPanel.js - Dropdown holding the profile picker and the
|
||
// context-sensitive config strip, opened from the profile trigger embedded
|
||
// in the middle of the (permanently floating) top nav.
|
||
//
|
||
// Lifted out of DayTabs.js so the profile/config UI no longer occupies a
|
||
// permanent full-width band above the forecast — it now expands downward
|
||
// from the nav, directly below it, keeping the original full-width
|
||
// horizontal layout (profile tabs, horizontally-scrolling card row, config
|
||
// strip) intact rather than squeezing it into a side drawer.
|
||
//
|
||
// Purely presentational - all state lives in useAppState. Dismisses on the
|
||
// × button, backdrop click, or Esc.
|
||
//
|
||
// Props:
|
||
// onClose - called on dismiss (×, backdrop, Esc)
|
||
// isPro - boolean Pro status
|
||
// activeProfile - current filter profile key
|
||
// activateProfile - function to switch profile
|
||
// activeCols - effective column set for current profile
|
||
// activityOptions/placeOptions/workOptions - dropdown options
|
||
// activityValue/placeValue/workValue - current variant values
|
||
// outdoorsVariant, setOutdoorsVariantAndSave
|
||
// setActiveProfile, setVisibleCols, setIndoorMode
|
||
// setProPromptSource, setProPromptDay - Pro upsell triggers
|
||
// indoorManaged/setIndoorManaged, buildingType/setBuildingType
|
||
// utciEnv/setUtciEnv, vehicleType/setVehicleType,
|
||
// vehicleSpeed/setVehicleSpeed, vehicleVent/setVehicleVent,
|
||
// furColor/setFurColor, skinType/setSkinType, pollenType/setPollenTypeAndSave
|
||
// ------------------------------------------------------------------------
|
||
|
||
import { h, Fragment } from '../../vendor/preact.js';
|
||
import { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
|
||
import htm from '../../vendor/htm.js';
|
||
import { SKIN_TYPES, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
||
import { FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys, POLLEN_TYPES, UTCI_ENVIRONMENTS, deriveProfileMain } from '../config.js';
|
||
import { CustomSelect, VentPill } from '../components.js';
|
||
|
||
const html = htm.bind(h);
|
||
|
||
export function ConfigPanel({
|
||
open,
|
||
onClose,
|
||
isPro,
|
||
activeProfile, activateProfile, activeCols,
|
||
activityOptions, placeOptions, workOptions,
|
||
activityValue, placeValue, workValue,
|
||
outdoorsVariant, setOutdoorsVariantAndSave,
|
||
setActiveProfile, setVisibleCols, setIndoorMode,
|
||
setProPromptSource, setProPromptDay,
|
||
indoorManaged, setIndoorManaged, buildingType, setBuildingType,
|
||
utciEnv, setUtciEnv,
|
||
vehicleType, setVehicleType, vehicleSpeed, setVehicleSpeed, vehicleVent, setVehicleVent,
|
||
furColor, setFurColor, skinType, setSkinType, pollenType, setPollenTypeAndSave,
|
||
}) {
|
||
const profileScrollRef = useRef(null);
|
||
const profileWrapRef = useRef(null);
|
||
const dropdownRef = useRef(null);
|
||
|
||
const { mainConfigKey, mainLabel } = deriveProfileMain(activeProfile, outdoorsVariant);
|
||
|
||
// Active tab: common - places - activities - work.
|
||
// Auto-derived from the current profile on mount and on change.
|
||
const getTab = () => {
|
||
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places';
|
||
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities';
|
||
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work';
|
||
return 'common';
|
||
};
|
||
const [activeTab, setActiveTab] = useState(getTab);
|
||
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]);
|
||
|
||
// Dismiss on Esc (component stays mounted while closed so the close
|
||
// transition can play, so this only acts while actually open).
|
||
useEffect(() => {
|
||
const onKey = (e) => { if (open && e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', onKey);
|
||
return () => document.removeEventListener('keydown', onKey);
|
||
}, [open, onClose]);
|
||
|
||
// From tablet up the panel drops *out of* the active-profile-row rather
|
||
// than sliding up from the floating button (which is mobile-only), so it
|
||
// has to be anchored to that row's live bottom edge — it's sticky on
|
||
// desktop and scrolls away on tablet, hence remeasuring on scroll/resize.
|
||
// Mobile (<=640px) ignores the var entirely and keeps the bottom sheet.
|
||
useEffect(() => {
|
||
const el = dropdownRef.current;
|
||
if (!el) return;
|
||
const measure = () => {
|
||
const row = document.querySelector('.active-profile-row');
|
||
const top = row ? Math.max(0, row.getBoundingClientRect().bottom) : 0;
|
||
el.style.setProperty('--profile-anchor-top', `${Math.round(top)}px`);
|
||
// Not enough room below the row for the panel's content? Fall back to
|
||
// the mobile bottom-sheet layout, which gets the full viewport height.
|
||
const body = el.querySelector('.profile-dropdown-body');
|
||
const content = body ? body.scrollHeight : 0;
|
||
el.classList.toggle('is-bottom-sheet', content > window.innerHeight - top - 16);
|
||
};
|
||
measure();
|
||
if (!open) return;
|
||
window.addEventListener('scroll', measure, true);
|
||
window.addEventListener('resize', measure);
|
||
return () => {
|
||
window.removeEventListener('scroll', measure, true);
|
||
window.removeEventListener('resize', measure);
|
||
};
|
||
}, [open, activeTab, activeProfile, outdoorsVariant]);
|
||
|
||
// Drag-to-scroll + fade edges for the horizontal profile card row.
|
||
useEffect(() => {
|
||
const el = profileScrollRef.current;
|
||
const wrap = profileWrapRef.current;
|
||
if (!el) return;
|
||
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
|
||
|
||
const updateFades = () => {
|
||
if (!wrap) return;
|
||
wrap.classList.toggle('fade-left', el.scrollLeft > 1);
|
||
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||
};
|
||
|
||
const onMouseDown = (e) => {
|
||
if (!el.contains(e.target) || e.button !== 0) return;
|
||
isDown = true; hasDragged = false;
|
||
startX = e.clientX; startScroll = el.scrollLeft;
|
||
document.body.style.userSelect = 'none';
|
||
document.body.style.webkitUserSelect = 'none';
|
||
};
|
||
const onMouseMove = (e) => {
|
||
if (!isDown) return;
|
||
const dx = e.clientX - startX;
|
||
if (Math.abs(dx) > 5) {
|
||
hasDragged = true;
|
||
el.style.cursor = 'grabbing';
|
||
el.scrollLeft = startScroll - dx;
|
||
}
|
||
};
|
||
const onMouseUp = () => {
|
||
if (!isDown) return;
|
||
isDown = false;
|
||
el.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
document.body.style.webkitUserSelect = '';
|
||
};
|
||
const onClickCapture = (e) => {
|
||
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
|
||
};
|
||
|
||
el.addEventListener('scroll', updateFades);
|
||
updateFades(); // set initial fade state
|
||
|
||
document.addEventListener('mousedown', onMouseDown);
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
el.addEventListener('click', onClickCapture, true);
|
||
return () => {
|
||
el.removeEventListener('scroll', updateFades);
|
||
document.removeEventListener('mousedown', onMouseDown);
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
el.removeEventListener('click', onClickCapture, true);
|
||
};
|
||
}, []);
|
||
|
||
const mainBadge = html`<span class="config-item-main-badge" title="Main selection — this is what drives the day tab's hi/lo readout">${mainLabel} Config:</span>`;
|
||
|
||
const showSolar = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP'];
|
||
const showVehicle = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT'];
|
||
const showIndoor = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT']);
|
||
const showFur = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['furSurfaceT']) : activeCols['furSurfaceT'];
|
||
const showBurn = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['burn']) : activeCols['burn'];
|
||
const showPollen = isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['pollen']) : activeCols['pollen'];
|
||
|
||
const configItems = [
|
||
{ key: 'solar', show: showSolar, node: html`
|
||
<span class="config-item-label">SunSoak</span>
|
||
<${CustomSelect}
|
||
value=${utciEnv} isOn=${true} noHide=${true} grpClass="grp-felt"
|
||
hideLabel="SunSoak"
|
||
options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ value: k, label: v.label }))}
|
||
onChange=${(v) => setUtciEnv(v)}
|
||
/>` },
|
||
{ key: 'vehicle', show: showVehicle, node: html`
|
||
<span class="config-item-label">Vehicle</span>
|
||
<span class="col-toggle-group col-toggle-group--expanded col-toggle-group--triple">
|
||
<${CustomSelect}
|
||
value=${vehicleType} isOn=${true} noHide=${true} grpClass="grp-felt"
|
||
hideLabel="Vehicle" groupedLeft=${true} isLastChild=${false}
|
||
options=${Object.entries(VEHICLE_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
|
||
onChange=${(v) => setVehicleType(v)}
|
||
/>
|
||
<${CustomSelect}
|
||
value=${vehicleSpeed} isOn=${true} noHide=${true} grpClass="grp-felt"
|
||
buttonLabel=${(VEHICLE_SPEEDS[vehicleSpeed] ?? VEHICLE_SPEEDS.static).name}
|
||
groupedLeft=${true} isLastChild=${false}
|
||
options=${Object.entries(VEHICLE_SPEEDS).map(([k, v]) => ({ value: k, label: v.name }))}
|
||
onChange=${(v) => setVehicleSpeed(v)}
|
||
/>
|
||
<${VentPill}
|
||
checked=${vehicleVent}
|
||
onChange=${() => setVehicleVent(v => !v)}
|
||
grpClass="grp-felt"
|
||
label="Ventilation"
|
||
title="Ventilation — open windows significantly reduce cabin heat build-up. Speed sets cabin airflow from forward motion."
|
||
/>
|
||
</span>` },
|
||
{ key: 'indoor', show: showIndoor, node: html`
|
||
<span class="config-item-label">Indoors</span>
|
||
<span class="col-toggle-group col-toggle-group--expanded">
|
||
<${CustomSelect}
|
||
value=${buildingType} isOn=${true} noHide=${true} grpClass="grp-felt"
|
||
hideLabel="Indoors" groupedLeft=${true} isLastChild=${false}
|
||
options=${Object.entries(BUILDING_TYPES).map(([k, v]) => ({ value: k, label: v.name }))}
|
||
onChange=${(v) => setBuildingType(v)}
|
||
/>
|
||
<${VentPill}
|
||
checked=${indoorManaged}
|
||
onChange=${() => setIndoorManaged(v => !v)}
|
||
grpClass="grp-felt"
|
||
label="Managed"
|
||
title="Managed: curtains closed by day, windows open when cooler outside"
|
||
/>
|
||
</span>` },
|
||
{ key: 'fur', show: showFur, node: html`
|
||
<span class="config-item-label">Fur Colour</span>
|
||
<${CustomSelect}
|
||
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-pets"
|
||
hideLabel="Fur Colour"
|
||
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
|
||
onChange=${(v) => setFurColor(v)}
|
||
/>` },
|
||
{ key: 'burn', show: showBurn, node: html`
|
||
<span class="config-item-label">Burn</span>
|
||
<${CustomSelect}
|
||
value=${skinType} isOn=${true} noHide=${true} grpClass="grp-felt"
|
||
hideLabel="Burn"
|
||
options=${Object.entries(SKIN_TYPES).map(([k, v]) => ({ value: k, label: v.name.split(' · ')[1] + ' skin' }))}
|
||
onChange=${(v) => setSkinType(v)}
|
||
/>` },
|
||
{ key: 'pollen', show: showPollen, node: html`
|
||
<span class="config-item-label">Pollen</span>
|
||
<${CustomSelect}
|
||
value=${pollenType} isOn=${true} noHide=${true} grpClass="grp-airqual"
|
||
hideLabel="Pollen"
|
||
options=${Object.entries(POLLEN_TYPES).flatMap(([k, v], i) => [
|
||
{ value: k, label: v.name },
|
||
...(i === 0 ? [{ value: '_div', divider: true }] : []),
|
||
])}
|
||
onChange=${(v) => setPollenTypeAndSave(v)}
|
||
/>` },
|
||
].filter((item) => item.show);
|
||
|
||
configItems.sort((a, b) => (a.key === mainConfigKey ? -1 : b.key === mainConfigKey ? 1 : 0));
|
||
|
||
return html`
|
||
<${Fragment}>
|
||
<div class=${`profile-dropdown-overlay${open ? ' is-open' : ''}`} onClick=${onClose}></div>
|
||
<div ref=${dropdownRef} class=${`profile-dropdown${open ? ' is-open' : ''}`} role="dialog" aria-modal="true" aria-label="Profile and configuration" aria-hidden=${!open}>
|
||
<button class="profile-dropdown-close" aria-label="Close" onClick=${onClose}>×</button>
|
||
<div class="profile-dropdown-body">
|
||
|
||
<div class="filter-profiles">
|
||
<div class="profile-tabs">
|
||
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
|
||
onClick=${() => setActiveTab(key)}
|
||
>${label}</button>
|
||
`)}
|
||
</div>
|
||
|
||
<!-- Invisible, always-rendered copy of the Activities cards. It has no
|
||
visual presence (height:0, hidden) but stays in normal flow so its
|
||
natural width feeds the dropdown's fit-content sizing — this keeps
|
||
the popup a constant width across all four tabs instead of
|
||
resizing as the user switches tabs, using Activities (the widest
|
||
tab) as that fixed reference size. -->
|
||
<div class="profile-scroll-sizer" aria-hidden="true">
|
||
<div class="profile-scroll">
|
||
${activityOptions.map((opt) => html`
|
||
<span key=${'sizer-' + opt.value} class="profile-btn" tabIndex="-1">
|
||
<span class="profile-btn-scene"></span>
|
||
<span class="profile-btn-label">${opt.name}</span>
|
||
</span>
|
||
`)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
|
||
<span class="profile-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="profile-scroll-chevron right" aria-hidden="true">›</span>
|
||
<div class="profile-scroll" ref=${profileScrollRef}>
|
||
|
||
${activeTab === 'common' && profileButtonOrder.map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
|
||
activateProfile(key);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
|
||
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
|
||
${locked && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>`;
|
||
})}
|
||
|
||
${activeTab === 'places' && placeOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'activities' && activityOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'work' && workOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
if (opt.value === 'farming') { activateProfile('farming'); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${configItems.length > 0 && html`
|
||
<div class="col-toggles-config">
|
||
${configItems.map((item) => html`
|
||
<${Fragment} key=${item.key}>
|
||
${item.key === mainConfigKey && mainBadge}
|
||
<span class=${`col-toggles-config-item${item.key === mainConfigKey ? ' col-toggles-config-item--main' : ''}`}>
|
||
${item.node}
|
||
</span>
|
||
</${Fragment}>`)}
|
||
</div>`}
|
||
|
||
</div>
|
||
</div>
|
||
</${Fragment}>`;
|
||
}
|