Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels.
Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab.
Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest.
Anchors on the reference page — 35 row ids in columns.html, one per column key.
New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added.
Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
This commit is contained in:
Fraxle
2026-08-03 18:06:09 +01:00
parent 2035edad23
commit 8f161dc8cb
18 changed files with 668 additions and 123 deletions
+103 -9
View File
@@ -39,7 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
import { ConfigPanel } from './components/ConfigPanel.js';
import { WelcomeModal } from './components/WelcomeModal.js';
import { RestoreModal } from './components/RestoreModal.js';
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';
@@ -88,6 +88,28 @@ function interpolateRowAt(rows, t) {
};
}
// Turn a raw fetch failure into something a person can act on. The banner only
// ever appears when nothing at all has loaded (see the loadForecast waterfall
// in hooks/useForecast.js), so "showing your last saved forecast" is never the
// right thing to say here - there isn't one.
function friendlyError(msg) {
const m = String(msg || '');
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return "You're offline. Reconnect and try again.";
}
if (/Failed to fetch|NetworkError|Load failed/i.test(m)) {
return "Couldn't reach the weather service. Check your connection and try again.";
}
const status = m.match(/HTTP (\d{3})/);
if (status) {
const code = Number(status[1]);
if (code === 429) return 'Too many requests just now. Give it a minute and try again.';
if (code >= 500) return "The weather service isn't responding. Try again in a moment.";
return `The weather service rejected the request (error ${code}).`;
}
return m;
}
// ── Draggable 24-hour timeline ──────────────────────────────────────────
function DayTimeline({ windowStart, windowEnd, simMs, setSimMs, hourlyRows, utcOffsetMs }) {
const trackRef = useRef(null);
@@ -179,7 +201,9 @@ export function UTCIForecast() {
// useAppState. See hooks/useAppState.js for the full reading order.
const {
location, setLocationAndSave, recentLocations,
forecast, airQuality, loading, error, now, fetchedAt, normals,
useMyLocation, locating, locateError, setLocateError,
shareForecast, shareState,
forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, normals, retry,
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
selectedDay, setSelectedDay,
proPromptDay, setProPromptDay,
@@ -539,6 +563,14 @@ export function UTCIForecast() {
: null;
const staleThreshMs = isPro ? 15 * 60 * 1000 : 30 * 60 * 1000;
const isStale = fetchedAt ? (now - fetchedAt) > staleThreshMs : false;
// CAMS air quality runs out well before the 14-day forecast does, so the AQI
// and Pollen columns hit a wall partway along the day tabs. Without saying so
// the empty cells read as a bug, which is worse than the missing data.
const aqBeyond = !!(aqHorizon && days[selectedDay]?.key && days[selectedDay].key > aqHorizon);
const aqBeyondNote = aqBeyond
? `Air quality and pollen are only forecast to ${new Date(`${aqHorizon}T00:00:00Z`)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}`
: null;
const glanceSummary = computeGlanceSummary(
days[selectedDay]?.rows ?? [],
activeProfile,
@@ -553,6 +585,9 @@ export function UTCIForecast() {
normals,
days[selectedDay]?.key,
);
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
// rendered in its own "The Week Ahead" box beneath it.
const weekAhead = computeBestDay(days, activeProfile, outdoorsVariant, nowLocalISO);
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
const glanceDate = (() => {
@@ -829,10 +864,51 @@ export function UTCIForecast() {
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
aria-label="Use my current location"
title="Use my current location"
disabled=${locating}
onClick=${useMyLocation}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="utci-loc-action"
aria-label="Share this forecast"
title="Copy a link to this forecast"
onClick=${shareForecast}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
${(locateError || shareState) && html`
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
${locateError
? locateError
: shareState === 'copied' ? 'Link copied' : "Couldn't copy the link"}
${locateError && html`
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
onClick=${() => setLocateError(null)}>×</button>`}
</div>`}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -1014,8 +1090,12 @@ export function UTCIForecast() {
</div>
${error && html`
<div class="utci-status" style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
${error}
<div class="utci-status" role="alert" aria-live="polite"
style=${{ borderColor: '#3a1010', color: '#c44a3a' }}>
<span class="utci-status-msg">⚠ ${friendlyError(error)}</span>
<button type="button" class="utci-status-retry" onClick=${retry} disabled=${loading}>
${loading ? 'Retrying…' : 'Retry'}
</button>
</div>`}
${loading && !error && html`
<div class="acquiring-overlay">
@@ -1377,8 +1457,8 @@ export function UTCIForecast() {
${visibleCols.vis && html`<th class=${`utci-tight-head col-info-th ${groupStart('vis')} ${groupColor('vis')}`} scope="col" onClick=${(e) => handleThClick('vis', e)} onMouseEnter=${(e) => handleThEnter('vis', e)} onMouseLeave=${handleThLeave}>Vis <span class="col-unit">km</span></th>`}
${visibleCols.wind && html`<th class=${`col-info-th ${groupStart('wind')} ${groupColor('wind')}`} scope="col" onClick=${(e) => handleThClick('wind', e)} onMouseEnter=${(e) => handleThEnter('wind', e)} onMouseLeave=${handleThLeave}>Wind <span class="col-unit">mph (gust)</span></th>`}
${visibleCols.dir && html`<th class=${`utci-dir-cell col-info-th ${groupStart('dir')} ${groupColor('dir')}`} scope="col" onClick=${(e) => handleThClick('dir', e)} onMouseEnter=${(e) => handleThEnter('dir', e)} onMouseLeave=${handleThLeave}>Dir <span class="col-unit">-</span></th>`}
${visibleCols.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} 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 ${groupStart('pollen')} ${groupColor('pollen')}`} 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.aqi && html`<th class=${`col-info-th ${groupStart('aqi')} ${groupColor('aqi')}`} scope="col" onClick=${(e) => handleThClick('aqi', e)} onMouseEnter=${(e) => handleThEnter('aqi', e)} onMouseLeave=${handleThLeave}>AQI <span class=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'EU idx'}</span></th>`}
${visibleCols.pollen && html`<th class=${`col-info-th ${groupStart('pollen')} ${groupColor('pollen')}`} 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=${aqBeyond ? 'col-unit col-unit--none' : 'col-unit'}>${aqBeyond ? 'no data' : 'grains/m³'}</span></th>`}
${visibleCols.uvA && html`<th class=${`col-info-th ${groupStart('uvA')} ${groupColor('uvA')}`} scope="col" onClick=${(e) => handleThClick('uvA', e)} onMouseEnter=${(e) => handleThEnter('uvA', e)} onMouseLeave=${handleThLeave}>UV-A <span class="col-unit">est. idx</span></th>`}
${visibleCols.uvB && html`<th class=${`col-info-th ${groupStart('uvB')} ${groupColor('uvB')}`} scope="col" onClick=${(e) => handleThClick('uvB', e)} onMouseEnter=${(e) => handleThEnter('uvB', e)} onMouseLeave=${handleThLeave}>UV-B <span class="col-unit">est. idx</span></th>`}
${visibleCols.sun && html`<th class=${`col-info-th ${groupStart('sun')} ${groupColor('sun')}`} scope="col" onClick=${(e) => handleThClick('sun', e)} onMouseEnter=${(e) => handleThEnter('sun', e)} onMouseLeave=${handleThLeave}>Sun <span class="col-unit">elev°</span></th>`}
@@ -1655,7 +1735,7 @@ export function UTCIForecast() {
: v < 80 ? `${v} Poor`
: v < 100 ? `${v} V.Poor`
: `${v} Hazard`;
return html`<td class=${groupStart('aqi')} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
return html`<td class=${groupStart('aqi')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 60 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.pollen && (() => {
const pollenMap = {
@@ -1679,7 +1759,7 @@ export function UTCIForecast() {
: v < 50 ? `${Math.round(v)} Mod`
: v < 200 ? `${Math.round(v)} High`
: `${Math.round(v)} V.High`;
return html`<td class=${groupStart('pollen')} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
return html`<td class=${groupStart('pollen')} title=${v == null && aqBeyondNote ? aqBeyondNote : null} style=${{ background: bg, color, fontWeight: v != null && v >= 50 ? 600 : 400 }}>${label}</td>`;
})()}
${visibleCols.uvA && html`
<td class=${groupStart('uvA')} style=${{ color: r.uvA > 0 ? '#c8922a' : '#4a3218', background: scaleBg(r.uvA, 0, 8, '220,155,40') }}>
@@ -1737,6 +1817,16 @@ export function UTCIForecast() {
];
})()}
</div>`}
${weekAhead.length > 0 && html`
<div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div>
${weekAhead.map(({ icon, label, value, grp }) => html`
<div class=${`insight-row${grp ? ` insight-row--${grp}` : ''}`} key=${label}>
<span class="insight-icon">${icon}</span>
<span class="insight-label">${label}</span>
<span class="insight-value">${value}</span>
</div>`)}
</div>`}
${visible.length > 0 && html`
<div class="export-panel">
${isPro
@@ -1774,7 +1864,11 @@ export function UTCIForecast() {
>
<button class="col-info-close" onClick=${closePopup} aria-label="Close">×</button>
<strong class="col-info-title">${COL_DESCRIPTIONS[colPopup.key].title}</strong>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].desc}</p>
<p class="col-info-desc">${COL_DESCRIPTIONS[colPopup.key].short || COL_DESCRIPTIONS[colPopup.key].desc}</p>
${COL_DESCRIPTIONS[colPopup.key].link && html`
<a class="col-info-more" href=${COL_DESCRIPTIONS[colPopup.key].link} target="_blank" rel="noopener noreferrer">
More about this column →
</a>`}
</div>`}
${eventTagPopup && (() => {