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:
+130
-7
@@ -358,6 +358,13 @@
|
||||
box-shadow: 0 2px 5px rgba(74, 52, 32, 0.10);
|
||||
}
|
||||
|
||||
/* On mobile the nav bar is fixed at 72px - sticky header must clear it */
|
||||
@media (max-width: 640px) {
|
||||
.utci-thead-sticky {
|
||||
top: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shifted track inside the sticky header — JS sets transform:translate3d
|
||||
to keep its columns aligned with the body scroller's scrollLeft. */
|
||||
.utci-thead-track {
|
||||
@@ -721,7 +728,9 @@
|
||||
|
||||
/* Plain profile buttons: fixed ~31% so 3 fit per row */
|
||||
.profile-btn {
|
||||
flex: 0 0 calc(33.33% - 4px);
|
||||
flex: 0 0 calc((100% - 12px) / 3);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
padding: 7px 2px;
|
||||
font-size: 11px;
|
||||
@@ -733,7 +742,8 @@
|
||||
|
||||
/* CustomSelect (Activities / Places dropdowns) — same width as buttons */
|
||||
.filter-profiles .cs-wrap {
|
||||
flex: 0 0 calc(33.33% - 4px);
|
||||
flex: 0 0 calc((100% - 12px) / 3);
|
||||
min-width: 0;
|
||||
}
|
||||
.filter-profiles .cs-btn {
|
||||
width: 100%;
|
||||
@@ -759,38 +769,151 @@
|
||||
}
|
||||
|
||||
.col-toggle {
|
||||
flex: 0 0 calc(33.33% - 4px);
|
||||
flex: 0 0 calc((100% - 12px) / 3);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
padding: 7px 2px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pollen CustomSelect in col-toggles — match toggle width */
|
||||
.col-toggles .cs-wrap {
|
||||
flex: 0 0 calc(33.33% - 4px);
|
||||
flex: 0 0 calc((100% - 12px) / 3);
|
||||
min-width: 0;
|
||||
}
|
||||
.col-toggles .cs-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 7px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fused vehicle group (select + vent pill): spans 2 slots = ~65% */
|
||||
/* Fused group: 1 slot by default, 2 slots when VentPill is active */
|
||||
.col-toggle-group {
|
||||
flex: 0 0 calc(66.66% - 1px);
|
||||
flex: 0 0 calc((100% - 12px) / 3);
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.col-toggle-group--expanded {
|
||||
flex: 0 0 calc((100% - 12px) / 3 * 2 + 6px);
|
||||
}
|
||||
.col-toggle-group .cs-wrap {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.col-toggle-group .cs-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
padding: 7px 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ── MOBILE TABLE DENSITY — squeeze all basic profile columns in ────────
|
||||
Target: 7 cols (hour air wind cloud utciP precip vis) on a 390px screen.
|
||||
Worst-case minimum width at these values: 366px - fits iPhone SE (375px).
|
||||
Levers: tighter cell padding, smaller min-width, reduced font sizes.
|
||||
The existing 800px and 640px blocks handle tablets and phablets above.
|
||||
DENSITY-BREAKPOINT - change 480px here to adjust where dense layout kicks in.
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/* -- MOBILE TABLE DENSITY - squeeze all basic profile columns in --------
|
||||
Target: 7 cols (hour air wind cloud utciP precip vis) on a 390px screen.
|
||||
Worst-case minimum width: 366px - fits iPhone SE (375px) with room to spare.
|
||||
DENSITY-BREAKPOINT - change 480px to adjust where dense layout kicks in.
|
||||
-------------------------------------------------------------------- */
|
||||
@media (max-width: 480px) {
|
||||
|
||||
/* -- Table header cells -----------------------------------------------
|
||||
Reduce padding so headers do not drive columns wider than data needs. */
|
||||
.utci-table th {
|
||||
font-size: 9px;
|
||||
padding: 10px 4px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.utci-table th .col-unit {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
/* -- Table body cells -------------------------------------------------
|
||||
Drop side padding 12px to 4px, shrink min-width 60px to 44px.
|
||||
JetBrains Mono at 11px reads cleanly on retina screens. */
|
||||
.utci-table td {
|
||||
padding: 8px 4px;
|
||||
font-size: 11px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* -- Time (Hour) column -----------------------------------------------
|
||||
Drop Fraunces from 16px to 13px and tighten the hour cell min-width. */
|
||||
.utci-time {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.utci-table td:first-child {
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
/* -- SkyScope moon-sun icon in the hour cell ---------------------------
|
||||
JS renders at size=30. Clamp to 20px via CSS to stop it ballooning
|
||||
the hour column width and driving up row height. */
|
||||
.utci-time svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- Cloud, wind-vane and precip icons in body cells -------------------
|
||||
Clamp the 28px SVGs down to 20px on mobile to recover column width
|
||||
without touching the JS size prop. */
|
||||
.utci-table td svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- Hero UTCI+P pill -------------------------------------------------
|
||||
Shrink min-width and font - still the visual anchor but no longer
|
||||
forcing the column to be the widest. */
|
||||
.utci-cell-hero {
|
||||
font-size: 14px;
|
||||
min-width: 50px;
|
||||
padding: 5px 2px;
|
||||
}
|
||||
|
||||
/* -- Vis column -----------------------------------------------------------
|
||||
Short values (8-99km) need less room than the default 44px min-width. */
|
||||
.utci-vis-cell {
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
/* -- Standard UTCI pill (non-hero) ------------------------------------*/
|
||||
.utci-cell {
|
||||
font-size: 12px;
|
||||
min-width: 44px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
/* -- Band label inside pills ------------------------------------------
|
||||
Tighten letter-spacing to save a little width. */
|
||||
.utci-band-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,3 +715,36 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* -- CUSTOM SELECT BUTTON - mobile label clipping ----------------------
|
||||
On narrow screens buttons are fixed ~33% width. The .cs-btn-label span
|
||||
wraps the text node so ellipsis can work (text-overflow needs a block
|
||||
context - bare text nodes inside flex containers cannot clip directly).
|
||||
The cs-arrow is flex-shrink-0 so the chevron always stays visible.
|
||||
DENSITY-BREAKPOINT - matches the table.css 480px breakpoint.
|
||||
-------------------------------------------------------------------- */
|
||||
@media (max-width: 480px) {
|
||||
.cs-btn {
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
overflow: hidden;
|
||||
padding: 7px 6px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-btn-label {
|
||||
display: block;
|
||||
width: calc(100% - 15px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-btn .cs-arrow {
|
||||
position: absolute !important;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
margin: 0;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
+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