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() {
${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