diff --git a/assets/css/table.css b/assets/css/table.css index cccb8eb..356002e 100644 --- a/assets/css/table.css +++ b/assets/css/table.css @@ -203,7 +203,11 @@ min-height: 12px; font-size: 8px; font-weight: 600; - line-height: 1.1; + /* Fixed 12.1px line box = the secondary number's own line box (11px x 1.1), + so the smaller label text centres on the number rather than riding above + it. Stated in px, not a ratio, so it holds when the mobile media query + drops the label's font-size. */ + line-height: 12.1px; color: #9a7d5a; } diff --git a/assets/css/ui.css b/assets/css/ui.css index 3f96a88..b1697a1 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -1537,6 +1537,54 @@ margin-top: 10px; } +/* Week rows carry a score bar, so they are a two-line grid rather than the + single flex line the glance rows use: day and window on top, bar beneath. */ +.insight-panel--week .insight-row { + display: grid; + grid-template-columns: 20px 1fr auto; + grid-template-areas: + "icon label value" + "icon score score"; + column-gap: 8px; + row-gap: 4px; + align-items: center; +} +.insight-panel--week .insight-icon { grid-area: icon; } +.insight-panel--week .insight-label { grid-area: label; } +.insight-panel--week .insight-value { grid-area: value; } + +/* How good the day is out of 100 — the bar does the comparing, the number + settles close calls. */ +.week-score { + grid-area: score; + display: flex; + align-items: center; + gap: 7px; +} + +.week-score__track { + flex: 1; + height: 5px; + border-radius: 3px; + background: #ede4cc; + overflow: hidden; +} + +/* Colour is set inline per row - it tracks the score itself (red at 0 through + amber to green at 100), so it can't live in a static rule. */ +.week-score__fill { + display: block; + height: 100%; + border-radius: 3px; +} + +.week-score__pct { + font-family: JetBrains Mono, monospace; + font-size: 11px; + color: #8a8375; + flex-shrink: 0; +} + /* ── Pro: Export day data ──────────────────────────────────────────────── */ .export-panel { margin-top: 10px; @@ -1618,6 +1666,16 @@ gap: 7px; } +/* Sits under the panel title to say what the listed days are being judged on + - the rows themselves are just dates and windows. */ +.insight-panel-sub { + font-family: Manrope, sans-serif; + font-size: 12px; + color: #8a8375; + text-align: center; + margin: -6px 0 10px; +} + .insight-thermal-cat { display: inline-block; padding: 2px 7px; @@ -1772,6 +1830,15 @@ row-gap: 1px; align-items: start; } + /* The rail is only 240px wide, so the window drops under the day name and + the score bar takes a third line rather than sharing one. */ + .insight-panel--week .insight-row { + grid-template-areas: + "icon label" + "icon value" + "icon score"; + row-gap: 3px; + } .insight-panel--glance .insight-icon, .insight-panel--week .insight-icon { grid-area: icon; diff --git a/assets/js/app.js b/assets/js/app.js index 664b52e..c558cfe 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -29,7 +29,7 @@ import { SKIN_TYPES, sunburnMinutes, burnLabel, VEHICLE_TYPES, VEHICLE_SPEEDS, BUILDING_TYPES, FUR_COLORS, - confidenceBand, moonGlyph, skyFillForElev, + confidenceBand, moonGlyph, skyFillForElev, titleCaseText, scoreFillColor, } from './utils.js'; import { SkyScope, WindVane, CloudIcon, ScopeReticle, PrecipIcon, CustomSelect, VentPill, RowStepper } from './components.js'; import { getCellTagEvents, getUpcomingEvents, PRIORITY_WEATHER_IDS } from './events.js'; @@ -40,7 +40,7 @@ import { ConfigPanel } from './components/ConfigPanel.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js'; -import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay } from './compute.js'; +import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel } from './compute.js'; import { solarElevationDeg } from './physics.js'; import { exportDayXls } from './export.js'; @@ -534,7 +534,7 @@ export function UTCIForecast() { ); // 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); + const weekAhead = computeBestDay(days, nowLocalISO); // Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026". const glanceDate = (() => { @@ -1582,8 +1582,8 @@ export function UTCIForecast() { const renderRow = ({ icon, label, value, alert, grp }, keyPrefix = '') => html`
${icon} - ${label} - ${value} + ${titleCaseText(label)} + ${titleCaseText(value)}
`; // Only the outdoors profile reorders items around the heat block // (it has a 'Peak felt temp' anchor). Other profiles render in order. @@ -1612,11 +1612,19 @@ export function UTCIForecast() { ${weekAhead.length > 0 && html`
The Week Ahead
- ${weekAhead.map(({ icon, label, value, grp }) => html` -
+
${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}
+ ${weekAhead.map(({ icon, label, value, score }) => html` +
${icon} - ${label} - ${value} + ${titleCaseText(label)} + ${titleCaseText(value)} + ${score != null && html` + + + + + ${score}% + `}
`)}
`} ${visible.length > 0 && html` diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 8c02873..21e626f 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -232,9 +232,9 @@ export function DayTabs({ const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; - // Vehicle/Driver has no secondary field — the cabin number already - // reflects the selected vehicle type, ventilation and speed, so the - // tab shows that alone rather than a second reference figure. + // Secondary row under each hi/lo: Shade for SunSoak, Managed for + // indoor, Pet Shade for pets, and plain air temperature for + // Vehicle/Driver so the outside-vs-cabin gap is visible. let shadeHi = null, shadeLo = null; if (secondaryField) { const secondaryVals = d.rows.map(r => r[secondaryField]).filter(v => isFinite(v)); diff --git a/assets/js/compute.js b/assets/js/compute.js index 123b718..f52cefd 100644 --- a/assets/js/compute.js +++ b/assets/js/compute.js @@ -17,7 +17,7 @@ import { calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp, calcFurSurfaceTempPass, } from './physics.js'; -import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS } from './utils.js'; +import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS } from './utils.js'; import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js'; export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) { @@ -959,13 +959,20 @@ function bestRun(arr, pred) { } // ------------------------------------------------------------------------ -// computeBestDay(weekDays, profile, variant, nowLocalISO) - the day with the -// longest unbroken run of pleasant outdoor hours, for "The Week Ahead". +// computeBestDay(weekDays, nowLocalISO) - every day in the +// next week with a decent unbroken run of pleasant outdoor hours, for +// "The Week Ahead". // // The app already works out comfort windows for the selected day, but // comparing days meant tapping through all fourteen tabs. This answers the // question the day tabs make you do by hand. // +// Naming a single winner was too thin to plan around: knowing Saturday is +// the best day says nothing about whether Sunday is also fine, or whether +// the whole week is a washout. So it lists the days that qualify, in date +// order, each with a 0-100 score so they can be compared at a glance +// without reordering them out of the order you plan in. +// // Deliberately NOT part of computeGlanceSummary: "At a glance" describes the // one selected day, and a week-scoped line reads as a category error inside // it. It renders in its own panel below the rail instead. @@ -980,16 +987,79 @@ function bestRun(arr, pred) { // ------------------------------------------------------------------------ const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for -const IDEAL_UTCI = 20; // centre of the "no thermal stress" band +const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist -function comfortableHour(r) { - return r.elev > 0 // daylight - && r.utciAdj != null && r.utciAdj >= 9 && r.utciAdj <= 28 // no thermal stress - && (r.precipProb ?? 0) < 50 // unlikely to rain on you - && (r.gust ?? r.va ?? 0) * 2.237 < 32; // below near-gale +// ── The four bands ─────────────────────────────────────────────────────── +// Each is [ideal, limit]: at the ideal end an hour scores full marks on that +// measure, at the limit it scores zero, and past the limit the hour is not a +// day-out hour at all. That split is what lets the panel say "Thursday is +// fine, Saturday is better" instead of only ever answering yes or no. +// +// SunSoak is the exception: rather than one ideal point it has a flat top +// spanning the Comfortable and Warm thermal-stress bands - anywhere in there +// is a good day out and splitting hairs between 20 and 25 would be false +// precision - then falls 100% to 0% across the Cool band below and the +// Caution band above. Past those it isn't a day-out hour at all. +// +// Read off UTCI_BANDS rather than written as numbers, so the panel and the +// table's colour bands can never drift apart: today that is a 19-27 plateau +// ramping out to 15 and 32. +const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max; +const SUNSOAK = { + min: bandMax('Chilly'), // bottom of Cool - score 0 + flatLo: bandMax('Cool'), // bottom of Comfortable - score 1 + flatHi: bandMax('Warm'), // top of Warm - score 1 + max: bandMax('Caution'), // top of Caution - score 0 +}; +const BAND_GUST = { ideal: 15, limit: 25 }; // mph +const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance +const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy + +// 1 at the ideal end, 0 at the limit, clamped. Direction is taken from the +// two numbers, so it reads the same whether lower or higher is better. +function bandScore(v, { ideal, limit }) { + if (v == null) return null; // unknown - caller decides + const f = (limit - v) / (limit - ideal); + return Math.max(0, Math.min(1, f)); } -export function computeBestDay(weekDays, profile, variant, nowLocalISO) { +function sunsoakScore(v) { + if (v == null) return null; + if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1; + const f = v < SUNSOAK.flatLo + ? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min) + : (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi); + return Math.max(0, Math.min(1, f)); +} + +function comfortableHour(r) { + if (!(r.elev > 0)) return false; // daylight only + if (r.utciAdj == null) return false; + if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false; + if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false; + if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false; + // Soil comes from a separate feed and is null wherever that fetch fails or + // the model has no coverage - treat missing as passing, or a soil outage + // would silently empty the panel everywhere. Only the wet end excludes: dust + // -dry ground is a poor day out for the garden, not for the person on it. + if (r.soilM != null && r.soilM > BAND_SOIL.limit) return false; + return true; +} + +// How good an hour is WITHIN the bands, 0-1. Weighted by how much each +// measure actually decides whether the day was worth going out for. +function hourQuality(r) { + const parts = [ + [sunsoakScore(r.utciAdj), 0.40], + [bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25], + [bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20], + [bandScore(r.soilM, BAND_SOIL), 0.15], + ].filter(([v]) => v != null); + const wsum = parts.reduce((s, [, w]) => s + w, 0); + return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; +} + +export function computeBestDay(weekDays, nowLocalISO) { if (!weekDays || weekDays.length === 0) return []; const candidates = []; @@ -1012,59 +1082,93 @@ export function computeBestDay(weekDays, profile, variant, nowLocalISO) { } if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue; - // How far the run sits from an ideal ~20 °C, averaged. Length alone can't - // separate days: in a temperate summer week half of them run comfortable - // from dawn to dusk, and picking arbitrarily among those is a coin toss. - const run = rows.slice(dayBest.start, dayBest.end + 1); - const miss = run.reduce((s, r) => s + Math.abs(r.utciAdj - IDEAL_UTCI), 0) / run.length; + // Average quality across the run. Length alone can't separate days: in a + // temperate summer week half of them qualify dawn to dusk, and picking + // arbitrarily among those is a coin toss. + const run = rows.slice(dayBest.start, dayBest.end + 1); + const quality = run.reduce((s, r) => s + hourQuality(r), 0) / run.length; candidates.push({ - len: dayBest.len, miss, key: day.key, + len: dayBest.len, quality, key: day.key, from: rows[dayBest.start], to: rows[dayBest.end], daylight: rows.filter(r => r.elev > 0).length, }); } - if (candidates.length === 0) return []; + // Nothing qualifying is itself worth saying - an empty panel reads as a bug, + // and "don't bother this week" is a real answer to the question being asked. + if (candidates.length === 0) { + return [{ + icon: '🌧️', + label: 'Next 7 days', + value: 'no settled outdoor windows', + alert: false, + }]; + } - // Longest run wins, but anything within an hour of the longest counts as a - // tie and is settled on which day is actually the most pleasant. - const maxLen = Math.max(...candidates.map(c => c.len)); - const best = candidates - .filter(c => c.len >= maxLen - 1) - .sort((a, b) => a.miss - b.miss)[0]; + // Score each day out of 100 against an ideal day out rather than against + // the rest of the week: a middling week should read as middling, not have + // its least-bad day flattered up to full marks. + // + // how much of the daylight is usable (50%) + // where in the bands those hours actually sit (50%) + // + // Even between the two: a long window of merely tolerable hours and a short + // window of perfect ones are both worth knowing about, and letting either + // half dominate hides one of them. + for (const c of candidates) { + const span = c.daylight > 0 ? Math.min(c.len / c.daylight, 1) : 0; + c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); + } + + // Only the top few are kept: past four rows the panel stops being a + // shortlist and turns back into the day tabs. + const shown = [...candidates] + .sort((a, b) => b.score - a.score) + .slice(0, PICK_MAX_DAYS) + .sort((a, b) => (a.key < b.key ? -1 : 1)); const hh = (iso) => { const h = parseInt(iso.slice(11, 13), 10); return `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`; }; - // Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back - // as UTC to stop the browser's own zone shifting the weekday. - const dayName = new Date(`${best.key}T00:00:00Z`) - .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' }); - // The panel title already says "The Week Ahead", so the row just names what - // is being judged rather than repeating the timeframe. - const label = profile === 'outdoors' && variant - ? `Best for ${deriveProfileMain(profile, variant).mainLabel}` - : 'Best day out'; + return shown.map(c => { + // Dates are keyed 'YYYY-MM-DD' in local wall-clock terms, so read them back + // as UTC to stop the browser's own zone shifting the weekday. + const dayName = new Date(`${c.key}T00:00:00Z`) + .toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', timeZone: 'UTC' }); - // The end row is the last comfortable hour, so the window runs to the end - // of it - 9am-12pm means 9:00 up to 12:59. - const endIso = `${best.to.iso.slice(0, 11)}${String((parseInt(best.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`; + // The end row is the last comfortable hour, so the window runs to the end + // of it - 9am-12pm means 9:00 up to 12:59. + const endIso = `${c.to.iso.slice(0, 11)}${String((parseInt(c.to.iso.slice(11, 13), 10) + 1) % 24).padStart(2, '0')}:00`; - // A run spanning nearly all the daylight is better said than shown: printing - // "6am - 9pm" makes the reader parse a time range to learn "all day". - const window = best.daylight > 0 && best.len >= best.daylight - 1 - ? 'comfortable all day' - : `${hh(best.from.iso)} – ${hh(endIso)}`; + // A run spanning nearly all the daylight is better said than shown: printing + // "6am - 9pm" makes the reader parse a time range to learn "all day". + const window = c.daylight > 0 && c.len >= c.daylight - 1 + ? 'comfortable all day' + : `${hh(c.from.iso)} – ${hh(endIso)}`; - return [{ - icon: '📅', - label, - value: `${dayName} · ${window}`, - alert: false, - grp: 'felt', - }]; + return { + icon: '📅', + label: dayName, + value: window, + // Rows stay in date order - the order you plan in - so the ranking is + // carried by the score bar instead of by position. + score: c.score, + alert: false, + // No grp: the group tints categorise the glance rail's mixed subjects, + // but here every row is the same kind of thing, so a colour would be + // decoration. Plain rows with hairline dividers instead. + }; + }); +} + +// Header line for the panel: names what is being judged when a variant makes +// that non-obvious, otherwise the plain framing. +export function bestDaysLabel(profile, variant) { + return profile === 'outdoors' && variant + ? `Best times for ${deriveProfileMain(profile, variant).mainLabel}` + : 'Best times out'; } export function computeCropAdvice(weekDays, lat) { diff --git a/assets/js/config.js b/assets/js/config.js index 69f96e4..2b396d4 100644 --- a/assets/js/config.js +++ b/assets/js/config.js @@ -198,7 +198,9 @@ export function deriveProfileMain(activeProfile, outdoorsVariant) { const mainField = isHomeOrOffice ? 'indoorT' : isVehicleProfile ? 'vehicleT' : isPetsProfile ? 'furSurfaceT' : 'utciAdj'; // Pets pairs Fur Temp with PET Shade: the human Shade column is now a UTCI // felt number on a human comfort scale, which says nothing about a cat. - const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'petShadeT' : isVehicleProfile ? null : 'shadeT'; + // Vehicle/Driver pairs the cabin temp with plain air temperature, so the + // gap between outside and inside the vehicle is visible at a glance. + const secondaryField = isHomeOrOffice ? 'managedT' : isPetsProfile ? 'petShadeT' : isVehicleProfile ? 'Ta' : 'shadeT'; const mainLabel = activeProfile === 'outdoors' ? (OUTDOORS_VARIANTS[outdoorsVariant]?.name || FILTER_PROFILES.outdoors.label) : (FILTER_PROFILES[activeProfile]?.label || 'SunSoak'); @@ -209,16 +211,18 @@ export function deriveProfileMain(activeProfile, outdoorsVariant) { // every field matches its column key except utciAdj, whose column is utciP. const colKey = mainField === 'utciAdj' ? 'utciP' : mainField; const mainMetric = COL_DESCRIPTIONS[colKey] || {}; - // Vehicle/Driver has no secondary column — the day tab shows only the cabin - // temp for the config actually selected (vehicle type, ventilation, speed). - const secondaryMetric = secondaryField ? (COL_DESCRIPTIONS[secondaryField] || {}) : null; + // Same mapping for the secondary field: Ta's column key is `air`. + const secColKey = secondaryField === 'Ta' ? 'air' : secondaryField; + const secondaryMetric = secColKey ? (COL_DESCRIPTIONS[secColKey] || {}) : null; return { isHomeOrOffice, isVehicleProfile, isPetsProfile, mainConfigKey, mainField, secondaryField, mainLabel, mainMetricLabel: mainMetric.title || '', mainMetricDesc: mainMetric.desc || '', - secondaryMetricLabel: secondaryMetric?.title || '', + // "Air Temperature" is too wide for the day-tab legend column, so the + // vehicle pairing shows it as just "Air" (the tooltip still spells it out). + secondaryMetricLabel: secColKey === 'air' ? 'Air' : (secondaryMetric?.title || ''), secondaryMetricDesc: secondaryMetric?.desc || '', }; } diff --git a/assets/js/utils.js b/assets/js/utils.js index 7bec911..37be0ca 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -515,4 +515,35 @@ export function grassFillForElev(e) { if (e > -8) return { top: '#665884', bot: '#3e3556' }; // civil twilight purple if (e > -14) return { top: '#363356', bot: '#1c1a34' }; // nautical night return { top: '#201d3a', bot: '#0d0b20' }; // astronomical night -} \ No newline at end of file +} +// --- scoreFillColor -------------------------------------------------------- +// Colour for the "Best Days Out" score bar, so the hue carries the same +// message as the length: red at 0, amber through the middle, green at 100. +// +// Straight hue interpolation across the red-yellow-green arc (0 deg to 120) +// rather than blending between three fixed hex stops - the blend route in RGB +// dips through brown around the midpoint, which reads as a fault rather than +// a middling day. Saturation and lightness stay put so the bar keeps one +// weight across the range instead of the yellow end glaring. +export function scoreFillColor(score) { + const pct = Math.max(0, Math.min(100, score ?? 0)); + return `hsl(${Math.round(pct * 1.2)} 68% 44%)`; +} + +// --- titleCaseText --------------------------------------------------------- +// Insight-panel labels and values were written ad hoc over time, so the rail +// mixed "Peak felt temp" with "Air quality" with "+9.0° warmer". Rather than +// hand-editing every string (many are built at runtime, and app.js matches +// some of them by exact text when ordering rows), the panels title-case at +// render time. +// +// Only capitalises a letter that STARTS a word - one preceded by nothing, +// whitespace or punctuation. A letter run glued to a number is a unit, not a +// word, so "6am" and "10°C" survive as written rather than becoming "6Am". +// Letters already capitalised are never touched, which keeps UV and AQI whole. +export function titleCaseText(s) { + return String(s ?? '').replace( + /(^|[^A-Za-z0-9°'])([a-z])/g, + (_, before, ch) => before + ch.toUpperCase(), + ); +}