From 099f2924d368fd9325ef78d432d43722378e5f1a Mon Sep 17 00:00:00 2001 From: fraxle Date: Tue, 2 Jun 2026 00:17:18 +0100 Subject: [PATCH] 2.2 Add new quick summary panels and update layout to make space for them --- .claude/launch.json | 11 ++ assets/css/header.css | 51 ++++++- assets/css/table.css | 5 +- assets/css/ui.css | 161 +++++++++++++++++++++ assets/js/app.js | 166 ++++++++++++++-------- assets/js/compute.js | 317 +++++++++++++++++++++++++++++++++++++++++- 6 files changed, 648 insertions(+), 63 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..dca01c4 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "sunscope", + "runtimeExecutable": "npx", + "runtimeArgs": ["--yes", "serve", "-l", "8099", "."], + "port": 8099 + } + ] +} diff --git a/assets/css/header.css b/assets/css/header.css index c4bb086..7d96f1e 100644 --- a/assets/css/header.css +++ b/assets/css/header.css @@ -6,17 +6,41 @@ .utci-header { display: grid; - grid-template-columns: 1fr auto 1fr; /* title | dial | search */ - gap: 32px; + grid-template-columns: 1fr auto 1fr; /* left | dial | why */ + grid-template-areas: + "hleft dial hright" + ". env ."; + column-gap: 32px; + row-gap: 6px; align-items: center; padding-bottom: 6px; margin-bottom: 6px; } +/* Side columns occupy only the dial's row, so they vertically centre against + the dial itself; the environment selector drops to its own row beneath. */ +.header-left { grid-area: hleft; } +.scope-col { grid-area: dial; } +.scope-env-bar { grid-area: env; } +.header-right { grid-area: hright; } + +/* Left column — wordmark, tagline, search and location stacked together */ +.header-left { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.header-left .utci-search-wrap { + margin-top: 18px; +} + +/* Right column — now hosts the "Why it feels like this" panel, + sitting close to the dial it explains. */ .header-right { display: flex; flex-direction: column; - align-items: flex-end; + align-items: flex-start; justify-content: center; } @@ -61,7 +85,7 @@ margin-top: 8px; margin-bottom: 0; color: #1e1208; - text-align: right; + text-align: left; } /* The lat/lon below the location */ @@ -91,6 +115,16 @@ gap: 8px; } +/* The environment selector sits just below the dial, centred under it. + Kept out of .scope-col so the dial alone defines that column's height + and stays vertically centred against the side columns. */ +.scope-env-bar { + display: flex; + justify-content: center; + margin-top: 2px; + margin-bottom: 4px; +} + .scope-env-row { display: flex; flex-direction: column; @@ -261,11 +295,20 @@ @media (max-width: 640px) { .utci-header { grid-template-columns: 1fr; /* single column — stack title, dial, search */ + grid-template-areas: none; /* flow in DOM order: left, dial, selector, why */ gap: 20px; padding-bottom: 24px; margin-bottom: 24px; } + /* Clear the desktop area placement so children stack in DOM order */ + .header-left, + .scope-col, + .scope-env-bar, + .header-right { + grid-area: auto; + } + .header-right { align-items: flex-start; /* left-align location on mobile */ } diff --git a/assets/css/table.css b/assets/css/table.css index 2a97353..1bb8e5a 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -158,8 +158,8 @@ justify-content: safe center; /* fall back to flex-start if it overflows */ overflow-x: auto; overflow-y: auto; /* forced by browser when overflow-x is non-visible */ - padding-bottom: 260px; /* extends clip boundary so dropdown panels aren't cut off */ - margin-bottom: -260px; /* cancel that extra space in the layout */ + padding-bottom: 400px; /* extends clip boundary so the tallest dropdown (Activities) isn't cut off */ + margin-bottom: -400px; /* cancel that extra space in the layout */ scrollbar-width: none; -ms-overflow-style: none; padding-right: 4px; /* breathing room at right edge */ @@ -210,6 +210,7 @@ .profile-btn { width: 80px; height: 80px; + flex: none; /* never shrink below 80px — the row scrolls instead */ padding: 0; position: relative; display: inline-flex; diff --git a/assets/css/ui.css b/assets/css/ui.css index d5424ac..63a75fc 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -1127,3 +1127,164 @@ transform: translateY(-50%) rotate(45deg); } } + + +/* ════════════════════════════════════════════════════════════════════════ + INSIGHT PANELS - Why it feels like this + Today at a glance. + Sits between the header dial and the day tabs. + ════════════════════════════════════════════════════════════════════════ */ + +/* "Why it feels like this" — sits in the header's right column, beside the dial */ +.insight-panel--why { + width: 100%; + max-width: 300px; +} + +/* Hourly table paired with the "Today at a glance" rail on its right */ +.table-with-rail { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: start; +} + +/* Left column: the columns-toggle bar stacked above the table, both + sharing the table's width so the rail can top-align with the toggles. */ +.table-main { + min-width: 0; +} + +.glance-rail { + width: 240px; + position: sticky; + top: 16px; +} + +.insight-panel--glance { + width: 100%; +} + +.insight-panel { + background: #fffcf2; + border: 1px solid #c9b08a; + border-radius: 4px; + padding: 12px 16px; +} + +/* Section heading - "WHY IT FEELS LIKE THIS" */ +.insight-panel-title { + font-family: Manrope, sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #b09870; + margin-bottom: 10px; +} + +/* Each factor row */ +.insight-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 0; + border-bottom: 1px solid #ede4cc; + font-family: Manrope, sans-serif; + font-size: 13px; +} + +.insight-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.insight-icon { + width: 20px; + text-align: center; + flex-shrink: 0; + font-size: 14px; + line-height: 1; +} + +.insight-label { + flex: 1; + color: #4a3420; + font-size: 13px; +} + +/* Delta values for "Why it feels like this" */ +.insight-delta { + font-family: JetBrains Mono, monospace; + font-size: 13px; + font-weight: 600; + min-width: 44px; + text-align: right; + flex-shrink: 0; +} + +.delta-pos { + color: #c8522a; /* warm orange-red - heat contribution */ +} + +.delta-neg { + color: #3a78a8; /* cool blue - cooling contribution */ +} + +/* Values for "Today at a glance" */ +.insight-value { + font-family: JetBrains Mono, monospace; + font-size: 12px; + font-weight: 600; + color: #4a3420; + text-align: right; + flex-shrink: 0; +} + +/* Alert state - draws attention without being aggressive */ +.insight-row--alert .insight-value { + color: #b84020; +} + +.insight-row--alert .insight-label { + color: #1e1208; + font-weight: 700; +} + +/* ── Wide screens - the rail is narrow (240px), so stack each value + beneath its label instead of letting long values overflow sideways. + Mobile keeps the inline label/value layout (plenty of width there). */ +@media (min-width: 901px) { + .insight-panel--glance .insight-row { + display: grid; + grid-template-columns: 20px 1fr; + grid-template-areas: + "icon label" + "icon value"; + column-gap: 8px; + row-gap: 1px; + align-items: start; + } + .insight-panel--glance .insight-icon { + grid-area: icon; + padding-top: 1px; + } + .insight-panel--glance .insight-label { + grid-area: label; + } + .insight-panel--glance .insight-value { + grid-area: value; + text-align: left; /* value sits left-aligned under its label */ + } +} + +/* ── Narrow screens - drop the rail below the table ─────────────────── */ +@media (max-width: 900px) { + .table-with-rail { + grid-template-columns: 1fr; + } + .glance-rail { + width: 100%; + position: static; + order: -1; /* show the summary above the filters + table */ + } +} diff --git a/assets/js/app.js b/assets/js/app.js index e3f7051..c54ee54 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -33,6 +33,7 @@ import { getCellTagEvents, getUpcomingEvents } from './events.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; +import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; const html = htm.bind(h); @@ -166,6 +167,16 @@ export function UTCIForecast() { return raw.map(c => Math.min(255, Math.round(c + (255 - c) * 0.66))); }; + // ─── 3b. PANEL COMPUTATIONS ────────────────────────────────────────── + // Derived from currentRow and the active day - no side effects. + const whyFeelsLike = computeWhyFeelsLike(currentRow ?? null, UTCI_ENVIRONMENTS[utciEnv]); + const glanceSummary = computeGlanceSummary( + days[selectedDay]?.rows ?? [], + activeProfile, + outdoorsVariant, + skinType, + ); + // ─── 4. JSX RETURN ─────────────────────────────────────────────────── // Everything below is the actual page markup, written as one big HTM // template. Search tips: @@ -272,43 +283,13 @@ export function UTCIForecast() {
-
+

SUNScope

See the sun the way your skin does.
-
- -
- <${ScopeReticle} - value=${currentRow?.utciAdj ?? null} - cat=${currentCat} - loading=${loading} - elev=${currentRow?.elev ?? 0} - dt=${currentRow?.dt ?? new Date()} - glob=${currentRow?.glob ?? 0} - activeEvent=${lensEvent} - utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} - /> -
- Environment - <${CustomSelect} - value=${utciEnv} - isOn=${true} - noHide=${true} - grpClass="grp-felt" - options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ - value: k, - label: v.label, - }))} - onChange=${(v) => setUtciEnv(v)} - /> -
-
- -
-Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`}
-
+ +
+ <${ScopeReticle} + value=${currentRow?.utciAdj ?? null} + cat=${currentCat} + loading=${loading} + elev=${currentRow?.elev ?? 0} + dt=${currentRow?.dt ?? new Date()} + glob=${currentRow?.glob ?? 0} + activeEvent=${lensEvent} + utciEnvShort=${UTCI_ENVIRONMENTS[utciEnv]?.shortLabel ?? null} + /> +
+ +
+
+ Environment + <${CustomSelect} + value=${utciEnv} + isOn=${true} + noHide=${true} + grpClass="grp-felt" + options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ + value: k, + label: v.label, + }))} + onChange=${(v) => setUtciEnv(v)} + /> +
+
+ +
+ ${forecast && currentRow && whyFeelsLike && html` +
+
Why it feels like this
+ ${[ + { label: 'Sun and sky', icon: '☀', value: whyFeelsLike.sunAndSky }, + { label: 'Wind', icon: '🌬', value: whyFeelsLike.wind }, + { label: 'Humidity', icon: '💧', value: whyFeelsLike.humidity }, + ...(whyFeelsLike.environment !== 0 ? [ + { label: UTCI_ENVIRONMENTS[utciEnv]?.label ?? 'Environment', icon: '🌍', value: whyFeelsLike.environment } + ] : []), + ...(whyFeelsLike.precipitation !== 0 ? [ + { label: 'Precipitation', icon: '🌧', value: whyFeelsLike.precipitation } + ] : []), + ].map(({ label, icon, value }) => html` +
+ ${icon} + ${label} + = 0 ? 'delta-pos' : 'delta-neg'}`}> + ${value >= 0 ? '+' : ''}${value.toFixed(1)}° + +
+ `)} +
`} +
+
${error && html`
@@ -385,32 +422,34 @@ export function UTCIForecast() { />`} +
+
Columns: ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['utciP']) : activeCols['utciP']) && html` - - <${CustomSelect} - value=${utciEnv} - isOn=${visibleCols.utciP} - grpClass="grp-felt" - hideLabel="SunSoak" - hidingLabel="Hide SunSoak" - groupedLeft=${true} - isLastChild=${true} - options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ - value: k, - label: v.label, - }))} - onChange=${(v) => { - if (v === 'off') { - setVisibleCols(prev => ({ ...prev, utciP: false })); - } else { - setUtciEnv(v); - setVisibleCols(prev => ({ ...prev, utciP: true })); - } - }} - /> - `} + + <${CustomSelect} + value=${utciEnv} + isOn=${visibleCols.utciP} + grpClass="grp-felt" + hideLabel="SunSoak" + hidingLabel="Hide SunSoak" + groupedLeft=${true} + isLastChild=${true} + options=${Object.entries(UTCI_ENVIRONMENTS).map(([k, v]) => ({ + value: k, + label: v.label, + }))} + onChange=${(v) => { + if (v === 'off') { + setVisibleCols(prev => ({ ...prev, utciP: false })); + } else { + setUtciEnv(v); + setVisibleCols(prev => ({ ...prev, utciP: true })); + } + }} + /> + `} ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeCols['vehicleT']) : activeCols['vehicleT']) && html` <${CustomSelect} @@ -862,6 +901,21 @@ export function UTCIForecast() {
+
+ ${forecast && glanceSummary && glanceSummary.length > 0 && html` + `} +
${colPopup && COL_DESCRIPTIONS[colPopup.key] && html`
Math.round(v * 10) / 10; + + // Radiation - mean radiant temp above or below air temp. + const sunAndSky = r1(Tmrt - Ta); + + // Wind - UTCI with actual wind vs calm, holding Tmrt = Ta and RH neutral. + const ehNeutral = vaporPressureHpa(Ta, 50); + const wind = r1(utciApprox(Ta, Ta, va, ehNeutral) - utciApprox(Ta, Ta, 0, ehNeutral)); + + // Humidity - actual vapour pressure vs neutral RH 50%, no wind or sun. + const humidity = r1(utciApprox(Ta, Ta, 0, eh) - utciApprox(Ta, Ta, 0, ehNeutral)); + + // Environment modifier - explicit temperature offset from the env config. + const environment = env ? r1(env.taOffset) : 0; + + // Precipitation soak penalty - negative or zero. + const precipitation = precipPenalty(precip, snow, va); + + return { sunAndSky, wind, humidity, environment, precipitation }; +} + +// ------------------------------------------------------------------------ +// computeGlanceSummary(todayRows, profile, variant, skinType) - Build the +// environment-aware "Today at a Glance" items for the current day. +// +// Returns an array of { icon, label, value, alert } objects (4-5 items). +// Content varies by profile and sub-variant so it stays relevant to +// whatever the user is actually doing. +// +// Parameters: +// todayRows - hourlyRows for the selected day +// profile - active profile key e.g. "farming", "vehicle", "home" +// variant - active sub-variant key e.g. "running", "beach" (or null) +// skinType - Fitzpatrick skin type key for UV burn time +// ------------------------------------------------------------------------ +export function computeGlanceSummary(todayRows, profile, variant, skinType) { + if (!todayRows || todayRows.length === 0) return []; + + const hhmm = (iso) => iso ? iso.slice(11, 16) : null; + const dayRows = todayRows.filter(r => r.elev > 0); + + const peakRow = (field) => todayRows.reduce((best, r) => + (r[field] != null && (best == null || r[field] > best[field])) ? r : best, null); + + const maxRainProb = Math.max(0, ...todayRows.map(r => r.precipProb ?? 0)); + + // Longest run of rows that satisfy a predicate. + const longestWindow = (rows, cond) => { + let best = null, cur = null; + for (const r of rows) { + if (cond(r)) { + cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 }; + if (!best || cur.len > best.len) best = { ...cur }; + } else { + cur = null; + } + } + return best; + }; + + const pollenLabel = (v) => { + if (v == null || v < 0) return null; + if (v < 10) return 'Low'; + if (v < 50) return 'Moderate'; + if (v < 200) return 'High'; + return 'Very High'; + }; + + const aqiLabel = (v) => { + if (v == null) return null; + if (v < 20) return 'Good'; + if (v < 40) return 'Fair'; + if (v < 60) return 'Moderate'; + return 'Poor'; + }; + + const moistureLabel = (v) => { + if (v == null) return null; + if (v < 0.15) return 'Dry'; + if (v < 0.30) return 'Slightly dry'; + if (v < 0.45) return 'Moist'; + return 'Saturated'; + }; + + const rainItem = { + icon: '🌧', + label: 'Rain risk', + value: `${Math.round(maxRainProb)}%`, + alert: maxRainProb >= 60, + }; + + // ── Farming ──────────────────────────────────────────────────────────── + if (profile === 'farming') { + const fieldWindow = longestWindow(dayRows, r => + r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12 + ); + const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10); + const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null; + const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); + + return [ + { + icon: '⏱', + label: 'Best field work', + value: fieldWindow + ? `${hhmm(fieldWindow.start.iso)} – ${hhmm(fieldWindow.end.iso)}` + : 'No suitable window', + alert: !fieldWindow, + }, + { + icon: '🌱', + label: 'Soil warms to 10°C by', + value: soilWarmRow ? hhmm(soilWarmRow.iso) : 'Not today', + alert: !soilWarmRow, + }, + rainItem, + { + icon: '💧', + label: 'Soil moisture', + value: moistureLabel(soilM) ?? '-', + alert: soilM != null && (soilM < 0.10 || soilM > 0.50), + }, + ...(maxPollen >= 10 ? [{ + icon: '🌿', + label: 'Grass pollen', + value: pollenLabel(maxPollen), + alert: maxPollen >= 50, + }] : []), + ]; + } + + // ── Vehicle ──────────────────────────────────────────────────────────── + if (profile === 'vehicle') { + const peakCabin = peakRow('vehicleT'); + const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 35); + + return [ + { + icon: '🌡', + label: 'Peak cabin temp', + value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-', + alert: !!(peakCabin && peakCabin.vehicleT >= 35), + }, + { + icon: '🧒', + label: 'Children/pets in car', + value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day', + alert: !!dangerFrom, + }, + rainItem, + { + icon: '🌤', + label: 'Best travel comfort', + value: (() => { + const best = todayRows.reduce((b, r) => + (b == null || r.utciAdj < b.utciAdj) ? r : b, null); + return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-'; + })(), + alert: false, + }, + ]; + } + + // ── Home ─────────────────────────────────────────────────────────────── + if (profile === 'home') { + const peakIndoor = peakRow('indoorT'); + const peakManaged = peakRow('managedT'); + const ventWindow = longestWindow(todayRows, r => + r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20 + ); + const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); + const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); + + return [ + { + icon: '🌡', + label: 'Peak indoor (unmanaged)', + value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-', + alert: !!(peakIndoor && peakIndoor.indoorT >= 28), + }, + { + icon: '🌡', + label: 'Peak indoor (managed)', + value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-', + alert: !!(peakManaged && peakManaged.managedT >= 28), + }, + { + icon: '🪟', + label: 'Open windows', + value: ventWindow + ? `${hhmm(ventWindow.start.iso)} – ${hhmm(ventWindow.end.iso)}` + : 'Keep closed', + alert: false, + }, + { + icon: '💨', + label: 'Air quality', + value: peakAqi > 0 ? aqiLabel(peakAqi) : '-', + alert: peakAqi >= 60, + }, + ...(maxPollen >= 10 ? [{ + icon: '🌼', + label: 'Pollen', + value: pollenLabel(maxPollen), + alert: maxPollen >= 50, + }] : []), + ]; + } + + // ── Activities - running / cycling ───────────────────────────────────── + if (variant === 'running' || variant === 'cycling') { + const coolWindow = longestWindow(dayRows, r => + r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 + ); + const peakUvRow = peakRow('uv'); + const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); + const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); + const burnMins = peakUvRow && peakUvRow.uv > 0 + ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType)) + : null; + + return [ + { + icon: '⏱', + label: `Best ${variant} window`, + value: coolWindow + ? `${hhmm(coolWindow.start.iso)} – ${hhmm(coolWindow.end.iso)}` + : 'No cool window today', + alert: !coolWindow, + }, + rainItem, + ...(burnMins ? [{ + icon: '☀', + label: 'UV burn time (peak)', + value: burnMins, + alert: !!(peakUvRow && peakUvRow.uv >= 6), + }] : []), + { + icon: '💨', + label: 'Air quality', + value: peakAqi > 0 ? aqiLabel(peakAqi) : '-', + alert: peakAqi >= 60, + }, + ...(maxPollen >= 10 ? [{ + icon: '🌿', + label: 'Pollen', + value: pollenLabel(maxPollen), + alert: maxPollen >= 50, + }] : []), + ]; + } + + // ── Outdoors - beach, park, events etc. (and fallback) ───────────────── + const comfortWindow = longestWindow(dayRows, r => + r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30 + ); + const peakFelt = peakRow('utciAdj'); + const peakUvRow = peakRow('uv'); + const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); + const burnMins = peakUvRow && peakUvRow.uv > 0 + ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType)) + : null; + + return [ + { + icon: '🌤', + label: 'Comfortable window', + value: comfortWindow + ? `${hhmm(comfortWindow.start.iso)} – ${hhmm(comfortWindow.end.iso)}` + : 'No comfortable window', + alert: !comfortWindow, + }, + { + icon: '🌡', + label: 'Peak felt temp', + value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-', + alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)), + }, + ...(burnMins ? [{ + icon: '☀', + label: 'UV burn time (peak)', + value: burnMins, + alert: !!(peakUvRow && peakUvRow.uv >= 6), + }] : []), + rainItem, + { + icon: '💨', + label: 'Air quality', + value: peakAqi > 0 ? aqiLabel(peakAqi) : '-', + alert: peakAqi >= 60, + }, + ]; +} \ No newline at end of file