app.js

Banner snooze: dismissing a banner suppresses it for 6 hours via localStorage (configurable via BANNER_SNOOZE_HOURS)
Vis column header: "Visibility" → "Vis" with utci-tight-head class
UTCI+P font: 16px → 13px
Vehicle/Indoor col-toggle-group: dynamic col-toggle-group--expanded class when VentPill is visible
Wind units appear reverted to m/s in the diff (that was a pre-existing change from before the commit, not something we did today)
Footer links also show as removed — same story, pre-existing

table.css

Sticky header offset on mobile: top: 72px so it clears the fixed nav
Full @media (max-width: 480px) density block for squeezing 7 columns onto small screens
@media (max-width: 640px) profile/column toggle grid: calc((100% - 12px) / 3) for correct 3-column layout accounting for gaps
col-toggle-group 1-slot default, col-toggle-group--expanded for 2-slot when pill active

ui.css — CustomSelect button clipping on mobile (label ellipsis, arrow absolutely positioned)
components.js — cs-btn-label span wrapper inside CustomSelect button for ellipsis support
This commit is contained in:
fraxle
2026-05-20 13:24:24 +01:00
parent 3e4ef00f5c
commit 476b4dfc75
4 changed files with 193 additions and 32 deletions
+29 -24
View File
@@ -469,6 +469,15 @@ export function UTCIForecast() {
// activeEvents — today's events → drives banner & lens overlay.
// selectedDayEvents — viewed day's events → drives cell tags.
const [dismissedEventIds, setDismissedEventIds] = useState([]);
// ------------------------------------------------------------------
// BANNER_SNOOZE_HOURS - how long to suppress a banner after dismissal.
// Change this value to adjust the snooze duration. Future builds can
// expose this as a user-facing setting and pass the value in here.
// ------------------------------------------------------------------
const BANNER_SNOOZE_HOURS = 6;
const BANNER_SNOOZE_MS = BANNER_SNOOZE_HOURS * 60 * 60 * 1000;
const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`;
const [bannerIndex, setBannerIndex] = useState(0);
const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null
// bannerVisible drives the .visible class — kept true until the collapse
@@ -477,9 +486,16 @@ export function UTCIForecast() {
const bannerIndexRef = useRef(0);
useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]);
const isSnoozed = id => {
try {
const ts = localStorage.getItem(BANNER_SNOOZE_KEY(id));
return ts && (Date.now() - Number(ts)) < BANNER_SNOOZE_MS;
} catch (e) { return false; }
};
const todayRows = days[0]?.rows || [];
const activeEvents = getActiveEvents(todayRows, location)
.filter(ev => !dismissedEventIds.includes(ev.id));
.filter(ev => !dismissedEventIds.includes(ev.id) && !isSnoozed(ev.id));
const lensEvent = getLensEvent(activeEvents);
const selectedDayEvents = getActiveEvents(visible, location);
@@ -500,9 +516,11 @@ export function UTCIForecast() {
// Dismiss with animation: fade stage → collapse wrap → remove event
const dismissBanner = useCallback((evId) => {
// 1. fade the stage out (CSS handles this via :not(.visible))
// 1. Record the dismissal time so we can suppress for BANNER_SNOOZE_HOURS
try { localStorage.setItem(BANNER_SNOOZE_KEY(evId), String(Date.now())); } catch (e) {}
// 2. Fade the stage out (CSS handles this via .visible class removal)
setBannerVisible(false);
// 2. after wrap has collapsed (grid transition ~450ms), remove the event
// 3. After wrap has collapsed (grid transition ~450ms), remove the event
setTimeout(() => {
setDismissedEventIds(ids => [...ids, evId]);
setBannerIndex(0);
@@ -545,7 +563,6 @@ export function UTCIForecast() {
<div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a>
<a href="./about.html">About</a>
<a href="./faq.html">FAQ</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
@@ -1162,7 +1179,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
/>`}
${(isPro ? (activeProfile === 'custom' || activeCols['vehicleT']) : activeCols['vehicleT']) && html`
<span class="col-toggle-group">
<span class=${'col-toggle-group' + (visibleCols.vehicleT ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${vehicleType}
isOn=${visibleCols.vehicleT}
@@ -1194,7 +1211,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</span>`}
${(isPro ? (activeProfile === 'custom' || activeCols['indoorT'] || activeCols['managedT']) : (activeCols['indoorT'] || activeCols['managedT'])) && html`
<span class="col-toggle-group">
<span class=${'col-toggle-group' + (indoorMode === 'on' ? ' col-toggle-group--expanded' : '')}>
<${CustomSelect}
value=${buildingType}
isOn=${indoorMode === 'on'}
@@ -1248,10 +1265,10 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
${visibleCols.soilT6 && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilT6', e)} onMouseEnter=${(e) => handleThEnter('soilT6', e)} onMouseLeave=${handleThLeave}>Soil 6cm <span class="col-unit">°C root</span></th>`}
${visibleCols.soilM && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('soilM', e)} onMouseEnter=${(e) => handleThEnter('soilM', e)} onMouseLeave=${handleThLeave}>Soil moist <span class="col-unit">m³/m³</span></th>`}
${visibleCols.concreteT && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('concreteT', e)} onMouseEnter=${(e) => handleThEnter('concreteT', e)} onMouseLeave=${handleThLeave}>Concrete <span class="col-unit">°C surface</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.wind && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">m/s (gust)</span></th>`}
${visibleCols.dir && html`<th class="utci-dir-cell col-info-th" scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.cloud && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('cloud', e)} onMouseEnter=${(e) => handleThEnter('cloud', e)} onMouseLeave=${handleThLeave}>Cloud <span class="col-unit">%</span></th>`}
${visibleCols.vis && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Visibility <span class="col-unit">km</span></th>`}
${visibleCols.vis && html`<th class="utci-tight-head col-info-th" scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
${visibleCols.aqi && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class="col-unit">EU idx</span></th>`}
${visibleCols.pollen && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('pollen', e)} onMouseEnter=${(e) => handleThEnter('pollen', e)} onMouseLeave=${handleThLeave}>${pollenType === 'all_pollen' ? 'Pollen' : POLLEN_TYPES[pollenType]?.name.replace(' pollen','') ?? 'Pollen'} <span class="col-unit">grains/m³</span></th>`}
${visibleCols.sun && html`<th class="col-info-th" scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
@@ -1373,9 +1390,9 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
<td style=${{ color: r.concreteT != null && r.concreteT > 40 ? '#c0392b' : r.concreteT != null && r.concreteT > 30 ? '#e67e22' : '#7f8c8d', fontWeight: 'bold', background: tempBg(r.concreteT) }}>
${r.concreteT != null ? r.concreteT.toFixed(1) : '—'}
</td>`}
${visibleCols.wind && html`<td style=${{ background: scaleBg((r.gust ?? r.va) * 2.237, 0, 40, '85,130,180') }}>
${Math.round(r.va * 2.237)}${r.gust != null && r.gust > r.va + 0.5
? (gm => html`<span style=${{ marginLeft: '4px', fontWeight: gm >= 25 ? 700 : 'normal', opacity: gm >= 25 ? 1 : 0.65, color: gm >= 55 ? '#b81010' : gm >= 40 ? '#d44010' : gm >= 25 ? '#c47a00' : 'inherit' }}>(${Math.round(gm)})</span>`)(r.gust * 2.237)
${visibleCols.wind && html`<td style=${{ background: scaleBg(r.gust ?? r.va, 0, 18, '85,130,180') }}>
${r.va.toFixed(1)}${r.gust != null && r.gust > r.va + 0.5
? html`<span style=${{ opacity: 0.65, marginLeft: '4px' }}>(${r.gust.toFixed(1)})</span>`
: ''}
</td>`}
${visibleCols.dir && html`<td class="utci-dir-cell">
@@ -1497,7 +1514,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</span>
</td>`}
${visibleCols.utciP && html`
<td style=${{ background: hexToRgba(adjCat.bg, 0.55), color: adjCat.fg === '#fff' ? adjCat.fg : '#2a1a08', fontWeight: 700, fontSize: '16px' }}>
<td style=${{ background: hexToRgba(adjCat.bg, 0.55), color: adjCat.fg === '#fff' ? adjCat.fg : '#2a1a08', fontWeight: 700, fontSize: '13px' }}>
${r.utciAdj.toFixed(1)}°
</td>`}
</tr>`;
@@ -1667,17 +1684,5 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`<button cla
</div>
</main>
<footer class="utci-site-footer">
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a>
· <a href="./index.html">Forecast</a>
· <a href="./about.html">About</a>
· <a href="./faq.html">FAQ</a>
· <a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
· Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a>
· UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer>
</div>`;
}