1.7.0
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:
+29
-24
@@ -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">
|
||||
© 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>`;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
|
||||
onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}
|
||||
type="button"
|
||||
>
|
||||
${btnLabel}
|
||||
<span class="cs-btn-label">${btnLabel}</span>
|
||||
<span class="cs-arrow"></span>
|
||||
</button>
|
||||
${open && html`
|
||||
|
||||
Reference in New Issue
Block a user