// ------------------------------------------------------------------------ // compute.js - Build the per-hour display rows from the raw API data. // // Pure-ish function: feed in (forecast, airQuality, location, vehicleType, // vehicleVent, vehicleSpeed, buildingType, furColor) and get back // { hourlyRows, days, utcOffsetMs }. // // Open-Meteo with timezone=auto returns local wall-clock strings like // "2026-05-13T14:00" - no Z suffix. Two forms are used in each row: // - String slices (iso.slice(...)) for display & day grouping // - A true UTC Date (dt) for solarElevationDeg (which uses .getUTC*). // ------------------------------------------------------------------------ import { vaporPressureHpa, solarElevationDeg, calcTmrt, utciApprox, calcConcreteTempPass, calcVehicleInteriorTempPass, calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp, calcFurSurfaceTempPass, } from './physics.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_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 }) { const env = UTCI_ENVIRONMENTS[utciEnv] ?? UTCI_ENVIRONMENTS.open; const utcOffsetMs = (forecast?.utc_offset_seconds ?? 0) * 1000; // Build a fast lookup map from the air quality hourly data: ISO string - index. // Normalise to "YYYY-MM-DDTHH" (13 chars) so forecast timestamps like // "2026-05-17T14:00" match AQ timestamps like "2026-05-17T14". const aqTimeMap = {}; if (airQuality?.hourly?.time) { airQuality.hourly.time.forEach((t, i) => { aqTimeMap[t.slice(0, 13)] = i; }); } const getAq = (field, iso) => { if (!airQuality?.hourly?.[field]) return null; const i = aqTimeMap[iso.slice(0, 13)]; if (i === undefined) return null; return airQuality.hourly[field][i] ?? null; }; const hourlyRows = forecast ? forecast.hourly.time.map((iso, i) => { const h = forecast.hourly; const Ta = h.temperature_2m[i]; const RH = h.relative_humidity_2m[i]; const dew = h.dew_point_2m ? h.dew_point_2m[i] : null; const va = h.wind_speed_10m[i]; const wd = h.wind_direction_10m ? h.wind_direction_10m[i] : null; const gust = h.wind_gusts_10m ? h.wind_gusts_10m[i] : null; const dir = h.direct_radiation[i] || 0; const dif = h.diffuse_radiation[i] || 0; const glob = h.shortwave_radiation[i] || 0; const cc = h.cloud_cover[i]; const ccLow = h.cloud_cover_low ? h.cloud_cover_low[i] : null; const ccMid = h.cloud_cover_mid ? h.cloud_cover_mid[i] : null; const ccHigh = h.cloud_cover_high ? h.cloud_cover_high[i] : null; const uv = h.uv_index ? (h.uv_index[i] || 0) : 0; const precip = h.precipitation[i] || 0; const precipProb = h.precipitation_probability ? (h.precipitation_probability[i] ?? 0) : 0; const lightning = h.lightning_potential ? (h.lightning_potential[i] ?? 0) : 0; const cape = h.cape ? (h.cape[i] ?? 0) : 0; const snow = h.snowfall[i] || 0; const soilT0 = h.soil_temperature_0cm ? h.soil_temperature_0cm[i] : null; const soilT6 = h.soil_temperature_6cm ? h.soil_temperature_6cm[i] : null; const soilM = h.soil_moisture_0_to_1cm ? h.soil_moisture_0_to_1cm[i] : null; // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // For display we slice the string directly - no Date object needed. // For solarElevationDeg (which uses .getUTC* internally) we need the // true UTC instant: treat the local time as UTC then subtract the offset. // e.g. Brisbane UTC+10: local 14:00 - parse as UTC 14:00 - subtract 10h - UTC 04:00 - // iso is a local wall-clock string e.g. "2026-05-13T14:00" (no Z). // For solarElevationDeg (which uses .getUTC* internally) we need the // true UTC instant: treat the local time as UTC then subtract the offset. const dt = new Date(Date.parse(iso + 'Z') - utcOffsetMs); const elev = solarElevationDeg(location.lat, location.lon, dt); // Use direct_radiation (beam sunlight) + a fraction of diffuse for concrete. // direct_radiation is zero on fully overcast days - far more accurate than // shortwave_radiation which can be unreliably high even at 100% cloud cover. // Diffuse (scattered light through cloud) contributes ~20% as much heat to // a surface as direct beam, so we weight it accordingly. const effectiveRad = dir + dif * 0.2; // Apply environment modifier - adjust solar inputs and air temp for shaded environments. const TaEnv = Ta + env.taOffset; const dirEnv = dir * env.dirFactor; const difEnv = dif * env.difFactor; const globEnv = glob * env.globFactor; // concreteT is now stamped in the two-pass section below (thermal lag). // vehicleT is stamped in the two-pass section below (thermal lag). const eh = vaporPressureHpa(Ta, RH); // UTCI: the standard, unmodified benchmark. Computed from the raw // open-ground observation only - NO Solar Model anywhere in it. Its whole // job is to be the fixed reference SunSoak is read against, so it must // not move when the user switches environment; if both numbers shifted // together the comparison would say nothing. Only the SunSoak family // (utciAdj, Shade, Tmrt) responds to the Solar Model. const TmrtStd = calcTmrt(Ta, dir, dif, glob, elev); const utci = utciApprox(Ta, TmrtStd, va, eh); // SunSoak: same peer-reviewed polynomial, every input shifted by the // active Solar Model (air temp via taOffset, radiation via dir/dif/glob // factors), then the rain/snow soak penalty on top. const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev); const utciEnv = utciApprox(TaEnv, Tmrt, va, eh); const utciAdj = utciEnv + precipPenalty(precip, snow, va); // Shade air temp: what a thermometer in this Solar Model's typical shade // (building shadow, beach umbrella, canopy...) would read. Shares // SunSoak's TaEnv baseline and env-reduced radiation, so the two stay // consistent. Pet Shade reads this directly; the human Shade column // below turns it into a felt temperature first. const shadeAirT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade); // Shade (felt): SunSoak with the direct beam taken away — still outdoors, // so the same wind, humidity and rain penalty apply. See // calcShadeFeltTemp() for why this is a felt number and not the air temp. const shadeT = calcShadeFeltTemp(shadeAirT, difEnv, va, eh, elev, env.shade) + precipPenalty(precip, snow, va); // Derived const compass = windCompass8(wd); const { uvA, uvB } = uvSplit(uv, elev); const cloudCat = cloudCategory(cc, ccLow, ccMid, ccHigh); // Visibility from the main forecast API (metres - km). const visKm = (() => { const v = h.visibility ? h.visibility[i] : null; return v != null ? v / 1000 : null; })(); const aqi = getAq('european_aqi', iso); const grassPollen = getAq('grass_pollen', iso); const birchPollen = getAq('birch_pollen', iso); const alderPollen = getAq('alder_pollen', iso); const mugwortPollen= getAq('mugwort_pollen', iso); const olivePollen = getAq('olive_pollen', iso); const ragweedPollen= getAq('ragweed_pollen', iso); // -- FUTURE FEATURE: Activity "What If" Modifier --------------------------- // Add two extra columns driven by a user-selected activity level. These are // intentionally kept SEPARATE from the core columns above so that baseline // profile data stays consistent and comparable across profiles. // // The user picks an activity from a simple UI picker (no live data needed - // this is a forecast/planning tool, not a tracker): // Resting - Walking - Cycling - Running - Sport/Intense // // Two output columns only (keep it clean): // // adjustedSafeTime - baseline UV safe exposure time - an activity multiplier. // Higher activity = shorter safe time, because: // - metabolic heat raises core body temp // - sweating washes away sunscreen faster // - more skin blood flow = higher UV sensitivity // Suggested multipliers (tune with real data): // Resting: 1.0 (no change) // Walking: 0.85 // Cycling: 0.75 *This will need a airflow calc as going at speed is cooling // Running: 0.60 *This will need a airflow calc as going at speed is cooling // Sport: 0.50 *This will need a airflow calc as going at speed is cooling // // *Danny Notes: These faster speed activities might need intergrating into the future "Vehicle Speed" calcs somehow? // // heatStressLevel - a simple label: 'Low' | 'Moderate' | 'High' | 'Very High' // Derived from UTCI + activity heat load. A runner at // UTCI 28-C should read 'High' even if a resting person // would read 'Moderate' at the same UTCI. // Colour code in the UI: - - - - // // Implementation sketch: // 1. Accept 'activityLevel' as a new param to buildHourlyRows() alongside // vehicleType, buildingType etc. // 2. Define ACTIVITY_PRESETS in utils.js (multiplier + utciOffset per level). // 3. Compute adjustedSafeTime = baseSafeTime * preset.multiplier // 4. Compute heatStressLevel from (utci + preset.utciOffset) banded into labels. // 5. Add both fields to the returned row object below. // 6. In components.js, render these as optional columns that only appear when // an activity other than 'Resting' is selected - keeps the default table clean. // ----------------------------------------------------------------------------- return { iso, dt, Ta, RH, dew, va, wd, gust, dir, dif, glob, cc, ccLow, ccMid, ccHigh, cloudCat, uv, uvA, uvB, precip, precipProb, lightning, cape, snow, soilT0, soilT6, soilM, shadeT, // Pet Shade stays on the shade AIR temperature: the human Shade column // is a UTCI felt number on a human comfort scale, which says nothing // useful about a cat. It also aggregates as a MEAN rather than a worst // case, so it needs its own field either way (same for pawT/concreteT). petShadeT: shadeAirT, effectiveRad, elev, Tmrt, utci, utciAdj, eh, compass, visKm, aqi, grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, }; }) : []; // Two-pass calculations: concrete thermal lag + indoor + vehicle cabin. // All need the full hourly arrays so they can look back at previous // hours. Run after hourlyRows is built, then stamp each row. if (hourlyRows.length > 0) { const TaArr = hourlyRows.map(r => r.Ta); const globArr = hourlyRows.map(r => r.glob); const elevArr = hourlyRows.map(r => r.elev); const radArr = hourlyRows.map(r => r.effectiveRad); const vaArr = hourlyRows.map(r => r.va); const uvArr = hourlyRows.map(r => r.uv); const cloudCatArr = hourlyRows.map(r => r.cloudCat); const soilMArr = hourlyRows.map(r => r.soilM); const precipArr = hourlyRows.map(r => r.precip); const snowArr = hourlyRows.map(r => r.snow); // Concrete surface temperature with thermal lag (1.5 h time constant). // A slab baking in the sun retains heat when cloud rolls in, and takes // a couple of hours of sunshine to fully heat up from a cold start. const concreteTemps = calcConcreteTempPass( TaArr, radArr, vaArr, elevArr, uvArr, cloudCatArr, soilMArr, precipArr, snowArr ); hourlyRows.forEach((r, i) => { r.concreteT = concreteTemps[i]; r.pawT = concreteTemps[i]; }); const indoorTemps = calcIndoorTempPass(TaArr, globArr, elevArr, buildingType); const managedTemps = calcManagedIndoorTempPass(TaArr, globArr, elevArr, buildingType); hourlyRows.forEach((r, i) => { r.indoorT = indoorTemps[i]; r.managedT = managedTemps[i]; }); // Vehicle cabin temperature with thermal lag. A parked vehicle climbs // toward the hour's equilibrium rather than jumping to it, so a van that // has been in the sun since morning reads hotter than the same van an // hour after parking. vaArr feeds ambient wind into shell convection. const vehicleTemps = calcVehicleInteriorTempPass( TaArr, globArr, elevArr, vaArr, vehicleType, vehicleVent, vehicleSpeed ); hourlyRows.forEach((r, i) => { r.vehicleT = vehicleTemps[i]; }); // Fur surface temperature with thermal lag - Pets profile. const furAlbedo = (FUR_COLORS[furColor] || FUR_COLORS.brown).albedo; const furTemps = calcFurSurfaceTempPass(TaArr, radArr, vaArr, elevArr, furAlbedo, uvArr, cloudCatArr); hourlyRows.forEach((r, i) => { r.furSurfaceT = furTemps[i]; }); } // Group those hourly rows into days for the day tabs. const days = []; hourlyRows.forEach(row => { const key = row.iso.slice(0, 10); let day = days.find(d => d.key === key); if (!day) { day = { key, rows: [] }; days.push(day); } day.rows.push(row); }); // -- FUTURE FEATURE v2: Today Summary + Alert System -------------------------- // After grouping rows into days, generate a per-day summary object that powers // a stylish "Today at a Glance" panel shown above or below the main dial. // // The summary is NOT a live alert/push system - it's a forecast digest that // refreshes with the forecast data. Think of it as a smart briefing card. // // WHAT TO COMPUTE (per day, from that day's rows): // - Peak UTCI+P and time it occurs - heat stress headline // - Min UTCI+P and time - cold stress headline // - Peak UV index and time - UV warning // - Max precipitation rate and time - rain/ice warning // - Max vehicle cabin temp - "dangerous to leave pets/children in car" // - Max pollen level + type - pollen advisory // - Road condition risk (low air temp + precip - ice risk) // // ALERT CATEGORIES (each generates a styled warning card if threshold exceeded): // -- Heat warning UTCI+P > 32-C // - Cold warning UTCI+P < 0-C // -- UV warning UV index > 6 // -- Heavy rain precip > 4mm/h // - Ice/road risk Ta < 3-C + any precip (or recent precip overnight) // - Vehicle danger vehicleT > 35-C ("don't leave pets or children in car") // - High pollen any pollen type > 50 grains/m- // // DESIGN NOTES: // - Cards should be concise - one line of bold text + a short explanation // - Colour-coded to match the existing UTCI stress band palette // - Collapsible - show top 2-3 alerts by default, expand for full list // - For today only (days[0]); optionally extend to day tabs in a later pass // - The "X-C above seasonal norm" historical context line (see app.js comment) // could live here too, as a subtle subheading under the dial temperature // // Suggested return shape - add to the return value below: // daySummaries: days.map(day => buildDaySummary(day.rows)) // // where buildDaySummary() is a new helper in this file (or a separate // summary.js module if it grows large). // ----------------------------------------------------------------------------- return { hourlyRows, days, utcOffsetMs }; } // ------------------------------------------------------------------------ // aggregateRows(rows, interval) - Compress a day's hourly rows into // multi-hour buckets for the table view (1h / 2h / 3h / 4h). // // Buckets are CLOCK-ALIGNED from midnight, so a 2h view groups 00-01, // 02-03, ...; 3h groups 00-02, 03-05, ...; etc. Each output row keeps the // LANDING (first) hour's positional fields - iso, dt, solar elevation, // global radiation, wind direction, sky/cloud category - so the little // scope, clock label and wind vane all show the time the column lands on. // // Every other column is aggregated with the rule that fits its meaning: // - SUM : precipitation totals (1mm/h x 3h = 3mm) // - MAX : risk/peak columns (rain %, UV, gusts, managed indoor) // - MEAN : smooth continuous quantities (RH, wind, cloud, Tmrt, ...) // - FELT : worst-case temperature - the warmest hour if the block reaches // >=20C (heat is the concern), otherwise the coldest hour (cold // is the concern). Applies to EVERY temperature column the user // judges comfort or risk by - SunSoak, Cabin, Indoors, Concrete, // Soil, Shade and Air - so they never disagree with each other // at 2h/3h/4h. Note Air is therefore a block worst case, not a // block average, and will read hotter than a mean on a warm day. // // Derived render values (Delta, Burn) are recomputed downstream from the // aggregated Air / UTCI / UV, so they stay consistent automatically. // // interval <= 1 returns the input untouched (zero behaviour change). // ------------------------------------------------------------------------ const AGG_MEAN = [ 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad', // Thermal columns that read as a plain block average rather than a worst // case: Tmrt and Dew above, plus the pet trio. pawT/petShadeT mirror // concreteT/shadeT hourly but must NOT inherit their FELT aggregation. 'furSurfaceT', 'pawT', 'petShadeT', 'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen', ]; const AGG_MAX = ['precipProb', 'uv', 'uvA', 'uvB', 'gust']; const AGG_SUM = ['precip', 'snow']; // Every temperature the user reads as "how hot/cold does this actually get" // shares one rule, so a 3h row never disagrees with the columns beside it. const AGG_FELT = [ 'utci', 'utciAdj', // SunSoak 'vehicleT', 'concreteT', // Cabin, Concrete 'indoorT', 'managedT', // Indoors + Managed Indoors (+ Pet Home) 'soilT0', 'soilT6', 'shadeT', 'Ta', // Soil surface + root, Shade, Air ]; // Arithmetic mean of an array, or null when empty. Shared by aggregateRows // and computeCropAdvice. const mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; export function aggregateRows(rows, interval) { if (!rows || rows.length === 0 || !interval || interval <= 1) return rows; // Numbers for a field across the group, skipping null / NaN. const nums = (group, f) => group.map(r => r[f]).filter(v => v != null && !isNaN(v)); const maxV = (vals) => vals.length ? Math.max(...vals) : null; const felt = (vals) => { if (!vals.length) return null; const hi = Math.max(...vals); return hi >= 20 ? hi : Math.min(...vals); }; // Split rows into clock-aligned buckets by floor(localHour / interval). const buckets = []; let cur = null, curKey = null; for (const r of rows) { const hour = parseInt(r.iso.slice(11, 13), 10); const key = Math.floor(hour / interval); if (key !== curKey) { cur = []; buckets.push(cur); curKey = key; } cur.push(r); } return buckets.map(group => { // Start from the landing row so positional fields (iso, dt, elev, glob, // wd, compass, cloudCat) carry through unchanged, then overwrite the // aggregatable columns. const out = { ...group[0] }; AGG_MEAN.forEach(f => { out[f] = mean(nums(group, f)); }); AGG_MAX.forEach(f => { out[f] = maxV(nums(group, f)); }); AGG_SUM.forEach(f => { out[f] = group.reduce((a, r) => a + (r[f] || 0), 0); }); AGG_FELT.forEach(f => { out[f] = felt(nums(group, f)); }); // Bucket span markers used by the table for "now" highlighting and for // matching event cell-tags that fall on any hour within the bucket. out.isoHours = group.map(r => r.iso.slice(0, 13)); out.isoEnd = group[group.length - 1].iso; return out; }); } // ------------------------------------------------------------------------ // computeWhyFeelsLike(row, env) - Break down the felt-temp delta into its // contributing factors for the "Why it feels like this" panel. // // Returns contributions in degrees C relative to plain air temperature. // Positive = warmer than air temp, negative = cooler. // // Attribution method - sequential isolation using utciApprox: // sunAndSky - Tmrt vs Ta. Mean radiant temp captures net heat from // direct sun, sky scatter, and ground reflection combined. // wind - UTCI with wind vs without (Tmrt = Ta, neutral RH 50%). // Almost always negative - wind cools. // humidity - UTCI at actual vapour pressure vs neutral RH 50%. // Positive when muggy, near-zero when dry. // environment - taOffset from the active UTCI environment modifier. // e.g. urban +2.5, forest -2.0, desert +3.5. // precipitation - precipPenalty() - always 0 or negative. // ------------------------------------------------------------------------ export function computeWhyFeelsLike(row, env) { if (!row) return null; const { Ta, Tmrt, va, eh, precip, snow } = row; const r1 = (v) => Math.round(v * 10) / 10; // Environment-adjusted air temp (same as used in buildHourlyRows). const TaEnv = Ta + (env ? env.taOffset : 0); // Sequential UTCI isolation — all deltas are in the same UTCI-polynomial // space so they add up: Ta + environment + sunAndSky + wind + humidity // + precipitation ≈ SunSoak (utciAdj), within rounding. // // Tmrt already has env radiation scaling (dirFactor/difFactor/globFactor) // baked in from buildHourlyRows, so each delta automatically reflects // the active Solar Model without any extra work here. const ehNeutral = vaporPressureHpa(TaEnv, 50); const base = utciApprox(TaEnv, TaEnv, 0, ehNeutral); // ≈ TaEnv // Radiation: effect of actual Tmrt vs no-radiation baseline (wind=0, RH=50%). const sunAndSky = r1(utciApprox(TaEnv, Tmrt, 0, ehNeutral) - base); // Wind: cooling effect of actual wind with solar present (RH still neutral). const wind = r1(utciApprox(TaEnv, Tmrt, va, ehNeutral) - utciApprox(TaEnv, Tmrt, 0, ehNeutral)); // Humidity: actual vapour pressure vs neutral RH 50%, with solar and wind. const humidity = r1(utciApprox(TaEnv, Tmrt, va, eh) - utciApprox(TaEnv, Tmrt, va, 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 { base: r1(base), 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, cols = null, vehicleSpeed = 'static', weekDays = null, lat = null, normals = null, dayKey = null) { if (!todayRows || todayRows.length === 0) return []; const show = (key) => !cols || !!cols[key]; const hhmm = (iso) => { if (!iso) return null; const h = parseInt(iso.slice(11, 13), 10); const m = iso.slice(14, 16); const period = h < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; const hhmmEnd = (iso) => { if (!iso) return null; const h = parseInt(iso.slice(11, 13), 10) + 1; const m = iso.slice(14, 16); const period = (h % 24) < 12 ? 'am' : 'pm'; const h12 = h % 12 || 12; return m === '00' ? `${h12}${period}` : `${h12}:${m}${period}`; }; 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 peakRainRow = todayRows.reduce((best, r) => (r.precipProb != null && (best == null || r.precipProb > best.precipProb)) ? r : best, null); const maxRainProb = peakRainRow ? (peakRainRow.precipProb ?? 0) : 0; // All contiguous runs of rows that satisfy a predicate. Each run is // { start, end, len }. Used so an "at a glance" window can report more than // one range - e.g. good driving in the morning AND again in the evening. const allWindows = (rows, cond) => { const out = []; let 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 }; } else if (cur) { out.push(cur); cur = null; } } if (cur) out.push(cur); return out; }; // Format the most significant windows as "h – h", one range per line, in // chronological order. Caps at `max` ranges (longest kept) so a split day // reads cleanly. Returns null when there are no windows. The newline is // preserved by `white-space: pre-line` on .insight-value. const formatWindows = (wins, max = 2) => { if (!wins || wins.length === 0) return null; return [...wins] .sort((a, b) => b.len - a.len) .slice(0, max) .sort((a, b) => (a.start.iso < b.start.iso ? -1 : 1)) .map(w => `${hhmm(w.start.iso)} – ${hhmmEnd(w.end.iso)}`) .join('\n'); }; 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 lightningLabel = (v) => { if (v <= 0) return null; if (v < 5) return 'Low'; if (v < 25) return 'Moderate'; return 'High'; }; const peakLightningRow = todayRows.reduce((best, r) => (r.lightning ?? 0) > (best?.lightning ?? 0) ? r : best, null); const peakLightning = peakLightningRow?.lightning ?? 0; const lightningRisk = lightningLabel(peakLightning); const lightningItem = lightningRisk ? [{ icon: '⚡', label: 'Lightning risk', value: `${lightningRisk} at ${hhmm(peakLightningRow.iso)}`, alert: peakLightning >= 5, grp: 'precip', }] : []; 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: maxRainProb > 0 && peakRainRow?.iso ? `${Math.round(maxRainProb)}% at ${hhmm(peakRainRow.iso)}` : `${Math.round(maxRainProb)}%`, alert: maxRainProb >= 60, }; // ── Vs seasonal norms (climate anomalies) ────────────────────────────── // One row comparing this day against the 1991-2020 norm for the date. The // headline is the standard climate anomaly — the day's 24h AVERAGE vs the // normal mean — with the day's HIGH vs the normal high carried alongside as // the peak people actually notice. Kept in a single row because two rows // sharing the icon and "1991-2020 … warmer" phrasing read as duplicates // giving contradictory answers. An honest climate cue. const climateItem = (() => { if (!normals) return []; const key = dayKey || todayRows[0]?.iso?.slice(0, 10); if (!key) return []; const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335]; const doy = LEAP_CUM[parseInt(key.slice(5, 7), 10) - 1] + (parseInt(key.slice(8, 10), 10) - 1); const taVals = todayRows.map(r => r.Ta).filter(v => v != null); if (taVals.length === 0) return []; const dayHigh = Math.max(...taVals); const dayMean = taVals.reduce((a, b) => a + b, 0) / taVals.length; // Either day-of-year table can have null slots — reduceNormals keeps the // { high, mean } shape as long as one series survived — so treat each as // independently optional. const nHigh = normals.high?.[doy]; const nMean = normals.mean?.[doy]; const aHigh = nHigh != null ? dayHigh - nHigh : null; const aMean = nMean != null ? dayMean - nMean : null; // Show the row when EITHER anomaly is notable. The mean anomaly is usually // the smaller of the two (a clear night pulls it back towards normal), so // gating on it alone would hide days with a dramatic peak. const notable = v => v != null && Math.abs(v) >= 3; if (!notable(aHigh) && !notable(aMean)) return []; const fmt = a => `${a >= 0 ? '+' : '−'}${Math.abs(a).toFixed(1)}°`; const word = a => (a >= 0 ? 'warmer' : 'cooler'); // Each half keeps its own sign and word, so a day whose peak and mean fall // on opposite sides of the norm still reads correctly. // `sub` is the secondary figure — rendered inline on mobile, on its own // line in the narrow desktop rail (see .insight-sub in ui.css). let value, sub = null; if (aMean != null && aHigh != null) { value = `${fmt(aMean)} ${word(aMean)}`; sub = `(${fmt(aHigh)} peak)`; } else if (aMean != null) { value = `${fmt(aMean)} ${word(aMean)}`; } else { value = `${fmt(aHigh)} peak ${word(aHigh)}`; } // Flagged off the high, matching the "5 degrees against the normal high" rule. const alert = (aHigh != null ? aHigh : aMean) >= 5; return [{ icon: '🌡', label: 'Pre-2020 24h Average', value, sub, alert, grp: 'ambient' }]; })(); // ── Good Driving Time ────────────────────────────────────────────────── // Shown whenever a road speed (not Static) is selected and the vehicle cabin // column is active - so it appears in both the Vehicle and Driver profiles. // Longest run of hours, across the full day since lorries run day and night, // that are NOT poor driving conditions: a hot cabin (>29 °C), heavy rain, // snow/ice, fog/thick mist, or gale-force wind. const GALE_MS = 39 / 2.237; // 39 mph gust = Gale (Force 8) const drivingShown = show('vehicleT') && vehicleSpeed !== 'static'; const drivingWins = drivingShown ? allWindows(todayRows, r => { const gustMs = r.gust ?? r.va; return (r.vehicleT == null || r.vehicleT <= 29) && // cabin not dangerously hot (r.precip == null || r.precip < 4) && // not heavy rain (mm/h) (r.snow == null || r.snow === 0) && // no snow (r.Ta == null || r.Ta > 1) && // no ice risk (r.visKm == null || r.visKm >= 4) && // not fog / thick mist (gustMs == null || gustMs < GALE_MS); // not gale-force wind }) : []; const drivingItem = { icon: '🚚', label: 'Good driving time', value: formatWindows(drivingWins) ?? 'Drive with care', alert: drivingWins.length === 0, grp: 'felt', }; // ── Farming ──────────────────────────────────────────────────────────── if (profile === 'farming') { const fieldWins = allWindows(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 [ ...climateItem, { icon: '⏱', label: 'Best field work', value: formatWindows(fieldWins) ?? 'No suitable window', alert: fieldWins.length === 0, grp: 'surface', }, ...(show('soilT') ? [{ icon: '🌱', label: 'Soil warms to 10°C by', value: soilWarmRow ? (hhmm(soilWarmRow.iso) === '00:00' ? 'All Day' : hhmm(soilWarmRow.iso)) : 'Not today', alert: !soilWarmRow, grp: 'surface', }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(show('soilM') ? [{ icon: '💧', label: 'Soil moisture', value: moistureLabel(soilM) ?? '-', alert: soilM != null && (soilM < 0.10 || soilM > 0.50), grp: 'surface', }] : []), ...(weekDays ? computeCropAdvice(weekDays, lat) : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌿', label: 'Grass pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, grp: 'airqual', }] : []), ]; } // ── Vehicle ──────────────────────────────────────────────────────────── if (profile === 'vehicle') { const peakCabin = peakRow('vehicleT'); const dangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29); return [ ...climateItem, ...(show('vehicleT') ? [{ icon: '🌡', label: 'Peak cabin temp', value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-', alert: !!(peakCabin && peakCabin.vehicleT > 31.5), grp: 'felt', }, { icon: '🧒', label: 'Children/pets in car', value: dangerWins.length ? `Unsafe ${formatWindows(dangerWins)}` : 'Safe all day', alert: dangerWins.length > 0, grp: 'felt', }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(drivingShown ? [drivingItem] : []), { 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, grp: 'felt', }, ]; } // ── Home ─────────────────────────────────────────────────────────────── if (profile === 'home') { const peakIndoor = peakRow('indoorT'); const peakManaged = peakRow('managedT'); const ventWins = allWindows(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 [ ...climateItem, ...(show('indoorT') ? [{ icon: '🌡', label: 'Peak indoor (unmanaged)', value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-', alert: !!(peakIndoor && peakIndoor.indoorT >= 28), grp: 'felt', }] : []), ...(show('managedT') ? [{ icon: '🌡', label: 'Peak indoor (managed)', value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-', alert: !!(peakManaged && peakManaged.managedT >= 28), grp: 'felt', }] : []), ...(show('indoorT') ? [{ icon: '🪟', label: 'Open windows', value: formatWindows(ventWins) ?? 'Keep closed', alert: false, grp: 'felt', }] : []), ...lightningItem, ...(show('aqi') && peakAqi > 0 ? [{ icon: '💨', label: 'Air quality', value: aqiLabel(peakAqi), alert: peakAqi >= 60, }] : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌼', label: 'Pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, grp: 'airqual', }] : []), ]; } // ── Pets (cats / small dogs) ──────────────────────────────────────────── if (profile === 'pets') { const peakFur = peakRow('furSurfaceT'); const peakShade = peakRow('petShadeT'); const peakIndoor = peakRow('indoorT'); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); const carDangerWins = allWindows(todayRows, r => r.vehicleT != null && r.vehicleT >= 29); return [ ...(show('furSurfaceT') ? [{ icon: '🐾', label: 'Sunsoak for pet', value: peakFur ? `${Math.round(peakFur.furSurfaceT)}° at ${hhmm(peakFur.iso)}` : '-', alert: !!(peakFur && peakFur.furSurfaceT >= 45), grp: 'surface', }] : []), ...(show('petShadeT') ? [{ icon: '🌳', label: 'Shade for pet', value: peakShade ? `${Math.round(peakShade.petShadeT)}° at ${hhmm(peakShade.iso)}` : '-', alert: !!(peakShade && peakShade.petShadeT >= 26), grp: 'ambient', }] : []), ...(show('petHomeT') ? [{ icon: '🏠', label: 'Pet at home', value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-', alert: !!(peakIndoor && peakIndoor.indoorT >= 26), grp: 'ambient', }] : []), ...(show('vehicleT') ? [{ icon: '🧒', label: 'Kids/pets in car', value: carDangerWins.length ? `Unsafe ${formatWindows(carDangerWins)}` : 'Safe all day', alert: carDangerWins.length > 0, grp: 'felt', }] : []), ...lightningItem, ...(show('aqi') && peakAqi > 0 ? [{ icon: '💨', label: 'Air quality', value: aqiLabel(peakAqi), alert: peakAqi >= 60, grp: 'airqual', }] : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌼', label: 'Pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, grp: 'airqual', }] : []), ]; } // ── Activities - running / cycling ───────────────────────────────────── if (variant === 'running' || variant === 'cycling') { const coolWins = allWindows(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 [ ...climateItem, { icon: '⏱', label: `Best ${variant} window`, value: formatWindows(coolWins) ?? 'No cool window today', alert: coolWins.length === 0, grp: 'felt', }, ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(show('burn') && burnMins ? [{ icon: '☀', label: 'UV burn time (peak)', value: burnMins, alert: !!(peakUvRow && peakUvRow.uv >= 6), grp: 'solar', }] : []), ...(show('aqi') && peakAqi > 0 ? [{ icon: '💨', label: 'Air quality', value: aqiLabel(peakAqi), alert: peakAqi >= 60, }] : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌿', label: 'Pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, }] : []), ]; } // ── Outdoors - beach, park, events etc. (and fallback) ───────────────── const comfortWins = allWindows(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 maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const burnMins = peakUvRow && peakUvRow.uv > 0 ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType)) : null; return [ ...climateItem, ...(drivingShown ? [drivingItem] : []), { icon: '🌤', label: 'Comfortable window', value: formatWindows(comfortWins) ?? 'No comfortable window', alert: comfortWins.length === 0, grp: 'felt', }, { icon: '🌡', label: 'Peak felt temp', value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-', alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)), grp: 'felt', }, ...((show('burn') || show('uvA')) && burnMins ? [{ icon: '☀', label: 'UV burn time (peak)', value: burnMins, alert: !!(peakUvRow && peakUvRow.uv >= 6), grp: 'solar', }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(show('aqi') && peakAqi > 0 ? [{ icon: '💨', label: 'Air quality', value: aqiLabel(peakAqi), alert: peakAqi >= 60, grp: 'airqual', }] : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌿', label: 'Pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, grp: 'airqual', }] : []), ]; } // ------------------------------------------------------------------------ // computeCropAdvice(weekDays, lat) - Seasonal "Good to sow / harvest" // advice for the Farming "At a glance" panel. // // Blends the current calendar month (which crops are in their sow/harvest // window) with the upcoming week's weather (is the seedbed warm and // workable, is there a dry spell to bring grain in?). Returns 0-2 // glance-style { icon, label, value, alert } items, appended after the // standard farming insights. // // Each verdict is one of three tiers - clearly good (crops listed plain), // borderline (crops listed with a "(marginal)" suffix) or hold. The middle // tier exists so a technically-passing but shaky call doesn't read with the // same confidence as an ideal one. // // Sowing looks at the DAILY MEAN soil temperature at 6 cm - drilling depth - // rather than a surface peak, because bare soil at 0 cm swings 15 °C+ over a // day and a sunny afternoon says nothing about the seedbed. It wants that // threshold held for a run of consecutive days AND the soil trending warmer // ("at temperature and rising"), plus workable moisture, no air frost ahead // for tender crops, and no downpour due straight after drilling. // // Harvest wants a genuinely CONSECUTIVE dry run for grain/rape/onions, and // gates root crops on soil moisture - lifting spuds off saturated ground // means ruts, compaction and damaged tubers. // // Accuracy caveats worth knowing before trusting a verdict: // - Soil fields come from ICON Global at ~11 km (see buildSoilUrl in // hooks/useForecast.js) - a regional average, not this field. // - The 0.45 m³/m³ wetness threshold is texture-agnostic: near saturation // on sand, around field capacity on clay. A proper fix needs a soil // texture lookup. // - soil_moisture_0_to_1cm is the skin layer and dries within hours of // rain, so it overstates workability after a shower. // soil_moisture_3_to_9cm would be the better input if this is revisited. // // Parameters: // weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...] // lat - forecast latitude; < 0 flips the UK calendar by +6 months // ------------------------------------------------------------------------ // Soil moisture bands shared by the sow and harvest gates (m³/m³). WET matches // the "Saturated" cut in moistureLabel() so the two glance rows agree. const SOIL_WET = 0.45; const SOIL_BORDERLINE = 0.40; // LONGEST run of consecutive entries satisfying `pred`, as { start, len }. // Deliberately the longest and not the first: one unsettled day early in the // week must not hide a good four-day spell behind it. function bestRun(arr, pred) { let best = { start: -1, len: 0 }, start = -1, len = 0; for (let i = 0; i < arr.length; i++) { if (pred(arr[i])) { if (start < 0) start = i; len++; if (len > best.len) best = { start, len }; } else { start = -1; len = 0; } } return best; } // ------------------------------------------------------------------------ // 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. // // Scored on utciAdj (SunSoak) rather than the profile's own main field. // Every row has it whatever profile is active, and it is the number that // actually answers "would I enjoy being outside" - unlike vehicleT or // indoorT, where a "best day" framing would be meaningless. // // Daylight hours only, and hours already past today are skipped, so a warm // morning that has been and gone can't win. // ------------------------------------------------------------------------ const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for const PICK_MAX_DAYS = 7; // the whole week, when the whole week is good // Said when no day qualifies, phrased for what was actually being judged - // "no settled outdoor windows" is meaningless when the panel was reading a // car cabin. const EMPTY_VALUE = { utciAdj: 'no settled outdoor windows', vehicleT: 'no comfortable cabin windows', indoorT: 'no comfortable indoor windows', furSurfaceT: 'no safe windows for a pet outside', }; // ── 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 = (bands, label) => bands.find(b => b.label === label).max; const plateau = (bands) => ({ min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0 flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1 flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1 max: bandMax(bands, 'Caution'), // top of Caution - score 0 }); const SUNSOAK = plateau(UTCI_BANDS); // Pets are judged on fur-surface temperature, which is coloured off PET_BANDS // in the table - reading the plateau off the same array keeps the panel and // the cell colours in step there too. const PET_PLATEAU = plateau(PET_BANDS); 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)); } function plateauScore(v, pl) { if (v == null) return null; if (v >= pl.flatLo && v <= pl.flatHi) return 1; const f = v < pl.flatLo ? (v - pl.min) / (pl.flatLo - pl.min) : (pl.max - v) / (pl.max - pl.flatHi); return Math.max(0, Math.min(1, f)); } // ── What "a good window" means for the active profile ──────────────────── // The panel used to answer one question - "would I enjoy standing outside" // - whatever profile was selected, which made it useless to the very users // who had told the app what they were doing. It now scores the SAME field the // rest of the app is built around for that profile (the day tabs, the dial, // the main table column), so "the week ahead" answers the question the // profile implies: when to drive, when the house is bearable, when to walk // the dog. // // thermal - weight on the profile's main temperature field // rain / gust / soil - the outdoor nuisances, only where they can matter // // Driving keeps rain and gust (both are what makes a journey unpleasant or // unsafe) and drops soil, which says nothing about a road. Indoors drops all // three: rain on the roof does not change whether the bedroom is sleepable. // Daylight-only is likewise a solar concern - a cool cabin at 6am is a real // answer to "when should I drive", so vehicle and indoor windows run around // the clock. function bestDayCriteria(profile, variant) { const { mainField } = deriveProfileMain(profile, variant); switch (mainField) { case 'vehicleT': return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false, w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } }; case 'indoorT': return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false, w: { thermal: 1, rain: 0, gust: 0, soil: 0 } }; case 'furSurfaceT': return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true, w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } }; default: return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true, w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } }; } } function comfortableHour(r, crit) { if (crit.daylightOnly && !(r.elev > 0)) return false; const t = r[crit.field]; if (t == null) return false; if (t < crit.pl.min || t > crit.pl.max) return false; // A measure that carries no weight for this profile can't exclude an hour // either, or an indoor window would still be cut short by a shower. if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false; if (crit.w.gust && (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 (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false; return true; } // Each measure's own 0-1 score for one hour, keyed so the same numbers can be // both averaged into a quality score and read back afterwards to say WHICH // measure cost the day its marks. function hourParts(r, crit) { return { thermal: plateauScore(r[crit.field], crit.pl), rain: bandScore(r.precipProb ?? 0, BAND_RAIN), gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), soil: bandScore(r.soilM, BAND_SOIL), }; } // 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, crit) { const p = hourParts(r, crit); const parts = Object.keys(p) .map(k => [p[k], crit.w[k]]) .filter(([v, w]) => v != null && w > 0); const wsum = parts.reduce((s, [, w]) => s + w, 0); return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; } // ── Why a day scored what it scored ────────────────────────────────────── // A calendar icon on every row said nothing the day name had not already // said. The icon now names the single biggest reason the day is not a // hundred, so a column of scores can be read without opening each day: three // windy days and one hot one is a different week from four wet ones. // // The icon is always a weather influence - heat, cold, rain, wind, wet ground // - never "the window was short". Length is a symptom: the window is short // BECAUSE the morning was cold or it rained after three, and the times beside // the icon already say how long it is. So when marks are lost to length, the // hours outside the window are asked what ruled them out and that is what the // row shows. // One vocabulary for both routes to a cause - hours inside the window scoring // poorly, and hours outside it being ruled out - so the same influence always // reads the same way whichever way it cost the day marks. const SHORTFALL_ICON = { rain: { icon: '🌧️', name: 'Rain' }, gust: { icon: '💨', name: 'Wind' }, soil: { icon: '💧', name: 'Wet ground' }, hot: { icon: '🔥', name: 'Heat' }, cold: { icon: '🥶', name: 'Cold' }, none: { icon: '✅', name: 'Nothing much against it' }, }; // The tooltip is the influence and what it cost, in points of the day's own // score: "Heat = 34%" says both what is wrong and how much it matters, so a // 90% row and a 60% row with the same icon are not read as the same warning. const causeTip = (cause) => cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name; // Why one hour failed to qualify. An hour can fail on more than one count - // a cold wet morning is both - so every breach is returned and the day is // decided on which comes up most, not on which happens to be tested first. function failReasons(r, crit) { const out = []; const t = r[crit.field]; if (t == null) return out; if (t < crit.pl.min) out.push('cold'); if (t > crit.pl.max) out.push('hot'); if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain'); if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust'); if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil'); return out; } // What kept the rest of the day out of the window. Returns null when those // hours give no reason at all - dark hours for a daylight profile, or a gap // in the data - so the caller can fall back to the in-window measures rather // than print a non-answer. function shortBecause(c, crit) { const inRun = new Set(c.run.map(r => r.iso)); const tally = {}; for (const r of c.usableRows) { if (inRun.has(r.iso)) continue; for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1; } // Cost is attached by the caller: those hours were ruled out entirely, so // what this influence cost is the whole of the length shortfall. const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0]; return worst ? SHORTFALL_ICON[worst] : null; } // Below this the day is as good as the bands allow and picking a "cause" // would be inventing one: at five points the strongest complaint about the // day is worth a twentieth of its score, which is noise, not a reason to // stay in. Set higher and genuinely breezy days went out labelled faultless. const SHORTFALL_FLOOR = 0.05; // Marks lost, as whole points of the 0-100 score the row already shows, so // the tooltip's number and the bar's number are in the same units. const pts = (frac) => Math.round(frac * 100); function limitingFactor(c, crit, span) { const run = c.run; // Mean score per measure across the run, so one bad hour in nine can't // name the day. const sums = {}, counts = {}; for (const r of run) { const p = hourParts(r, crit); for (const k of Object.keys(p)) { if (p[k] == null || !crit.w[k]) continue; sums[k] = (sums[k] ?? 0) + p[k]; counts[k] = (counts[k] ?? 0) + 1; } } const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0); // Marks lost, in points of the final score: quality is half of it, split // between the measures by weight; window length is the other half. const lost = [['short', 0.5 * (1 - span)]]; for (const k of Object.keys(counts)) { const mean = sums[k] / counts[k]; lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]); } lost.sort((a, b) => b[1] - a[1]); if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none; // Length lost the most marks: name what ruled the other hours out. If they // can't say, drop through to whichever measure was weakest inside the // window - still an influence, which is the whole point of the icon. if (lost[0][0] === 'short') { const because = shortBecause(c, crit); if (because) return { ...because, cost: pts(lost[0][1]) }; lost.shift(); if (lost.length === 0) return SHORTFALL_ICON.none; } const [worst, amount] = lost[0]; const cost = pts(amount); // Thermal has two opposite causes and they want opposite icons - a cold // morning and a scorching afternoon are not the same warning. if (worst === 'thermal') { const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length; return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost }; } return { ...SHORTFALL_ICON[worst], cost }; } export function computeBestDay(weekDays, nowLocalISO, profile, variant) { if (!weekDays || weekDays.length === 0) return []; const crit = bestDayCriteria(profile, variant); const candidates = []; for (const day of weekDays.slice(0, 7)) { if (!day?.rows?.length) continue; // Drop hours that have already passed - only ever bites on day 0. const rows = nowLocalISO ? day.rows.filter(r => r.iso.slice(0, 13) >= nowLocalISO) : day.rows; let start = -1, len = 0, dayBest = null; for (let i = 0; i < rows.length; i++) { if (comfortableHour(rows[i], crit)) { if (start < 0) start = i; len++; if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i }; } else { start = -1; len = 0; } } if (!dayBest || dayBest.len < PICK_MIN_HOURS) continue; // 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, crit), 0) / run.length; candidates.push({ len: dayBest.len, quality, key: day.key, run, from: rows[dayBest.start], to: rows[dayBest.end], // What the window is measured AGAINST - and, for the hours outside the // run, the evidence for WHY it stopped where it did: the daylight for a // profile that // only counts daylight hours, otherwise the whole day. Using daylight // for a round-the-clock profile would let a 14-hour overnight window // score over 100% of a 16-hour day and print "all day" for a spell that // ends at breakfast. usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows, }); } // 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: crit.field === 'utciAdj' ? '🌧️' : '🚫', label: 'Next 7 days', value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj, alert: false, }]; } // 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.usableRows.length > 0 ? Math.min(c.len / c.usableRows.length, 1) : 0; c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); // Same span the score used, so the icon can never blame the weather for // marks that were actually lost to a short window. c.cause = limitingFactor(c, crit, span); } // A settled week really can have seven good days, and cutting it to a // shortlist there would say the opposite of what the forecast shows. Only // days that actually qualify get a row, so the panel still stays short in // an unsettled week - the cap is the week itself, not an arbitrary four. 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'}`; }; 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 = `${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 = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1 ? 'all day' : `${hh(c.from.iso)} – ${hh(endIso)}`; return { // Not a calendar - the row already says which day. The icon carries the // one thing the score cannot: what is holding the day back. icon: c.cause.icon, iconTitle: causeTip(c.cause), 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) { const { mainField, mainLabel } = deriveProfileMain(profile, variant); // The vehicle and home profiles have no variant to name, but "best times // out" would be plainly wrong for them - the panel is now reading the cabin // and the room, not the pavement. if (profile === 'vehicle') return 'Best times to drive'; if (profile === 'home') return 'Best times indoors'; if (profile === 'pets') return 'Best times for pets'; return profile === 'outdoors' && variant ? `Best times for ${mainLabel}` : (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`); } // Tooltip on the score bar. It has to name the field being scored, or a // driver reads "temperatures nearer 20 °C" against the outside air and // concludes the panel is broken. export function bestDaysHint(profile, variant, score) { const { mainField } = deriveProfileMain(profile, variant); const what = { vehicleT: 'an ideal driving day — longer stretches with a comfortable cabin score higher', indoorT: 'an ideal day indoors — longer stretches with the building in comfortable range score higher', furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher', }[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher'; return `${score}% of ${what}`; } export function computeCropAdvice(weekDays, lat) { if (!weekDays || weekDays.length === 0) return []; const days = weekDays.slice(0, 7).filter(d => d && d.rows && d.rows.length); if (days.length === 0) return []; // "Now" - the month of the first available day (1-12). const month = parseInt((days[0].key || '').slice(5, 7), 10); if (!month) return []; // Southern hemisphere: shift the stored UK months by +6 before testing. const south = lat != null && lat < 0; const inSeason = (months) => { const shifted = south ? months.map(m => ((m + 5) % 12) + 1) : months; return shifted.includes(month); }; // ── Per-day aggregates ─────────────────────────────────────────────────── // One pass; everything below reads from these rather than re-scanning rows. const daily = days.map(d => { const at6 = d.rows.map(r => r.soilT6).filter(v => v != null); const at0 = d.rows.map(r => r.soilT0).filter(v => v != null); const dt = new Date((d.key || '') + 'T00:00Z'); return { // 6 cm is drilling depth; fall back to the 0 cm skin only if a model // swap drops the 6 cm field, so the row still says something. soilMeanT: at6.length ? mean(at6) : (at0.length ? mean(at0) : null), soilMMean: mean(d.rows.map(r => r.soilM).filter(v => v != null)), rainTotal: d.rows.reduce((s, r) => s + (r.precip ?? 0), 0), maxProb: Math.max(0, ...d.rows.map(r => r.precipProb ?? 0)), minTa: Math.min(Infinity, ...d.rows.map(r => r.Ta ?? Infinity)), name: dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }), }; }); // ── Seedbed signals ────────────────────────────────────────────────────── // Longest consecutive run of days whose mean seedbed temp clears `minT`. const runAt = (minT) => bestRun(daily, d => d.soilMeanT != null && d.soilMeanT >= minT).len; // "At temperature AND rising": end of the week warmer than the start. const early = mean(daily.slice(0, 3).map(d => d.soilMeanT).filter(v => v != null)); const late = mean(daily.slice(4, 7).map(d => d.soilMeanT).filter(v => v != null)); const rising = early != null && late != null && (late - early) >= 0.3; // Best margin over a threshold across the week - a soil sitting well above // the floor is a safer call than one scraping it. const bestMean = Math.max(-Infinity, ...daily.map(d => d.soilMeanT ?? -Infinity)); const soilKnown = bestMean > -Infinity; const marginOver = (minT) => (soilKnown ? bestMean - minT : null); // Workability now, not seven days out: mean over the next 48 h only. const nearMoist = mean(daily.slice(0, 2).map(d => d.soilMMean).filter(v => v != null)); const groundWet = nearMoist != null && nearMoist >= SOIL_WET; const groundDamp = nearMoist != null && nearMoist >= SOIL_BORDERLINE && nearMoist < SOIL_WET; // Air frost anywhere in the window rules out tender crops. const frostAhead = daily.some(d => d.minTa < 0); // A downpour onto a fresh seedbed caps it; downgrade, never block. const postSowSoak = daily.slice(0, 3).reduce((s, d) => s + d.rainTotal, 0) > 25; // ── Shared row plumbing ────────────────────────────────────────────────── const listCrops = (crops) => crops.map(c => c.label).join(', '); // Split a season list into ready / marginal by a per-crop classifier. const classify = (crops, fn) => { const ready = [], marginal = []; for (const c of crops) { const v = fn(c); if (v === 'ready') ready.push(c); else if (v === 'marginal') marginal.push(c); } return { ready, marginal }; }; // Ready crops list plain; otherwise marginal crops carry the suffix. Keeping // them separate means a plain list always means "genuinely good to go". const buildRow = (icon, label, { ready, marginal }, readyValue, holdValue) => ( ready.length ? { icon, label, value: readyValue(ready), alert: false, grp: 'surface', } : marginal.length ? { icon, label, value: `${listCrops(marginal)} (marginal)`, alert: false, grp: 'surface', } : { icon, label, value: holdValue, alert: true, grp: 'surface', } ); const out = []; // ── Good to sow ────────────────────────────────────────────────────────── const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months)); if (sowSeason.length) { const sowState = classify(sowSeason, (c) => { // Under glass: the outdoor seedbed simply doesn't apply. if (c.sow.underCover) return 'ready'; // No soil reading at all is "unknown", not "fine" - don't guess. if (!soilKnown) return 'no'; const run = runAt(c.sow.minSoilT); if (groundWet || run === 0) return 'no'; if (c.sow.tender && frostAhead) return 'no'; const margin = marginOver(c.sow.minSoilT); const solid = run >= 3 && (rising || (margin != null && margin >= 2)); return (solid && !groundDamp && !postSowSoak) ? 'ready' : 'marginal'; }); // Under-cover crops pass on the calendar alone, so on their own they must // NOT read as a green light for the field - qualify them instead. const onlyUnderCover = sowState.ready.length > 0 && sowState.ready.every(c => c.sow.underCover); const sowReadyValue = (ready) => onlyUnderCover ? `${listCrops(ready)} (under cover)` : listCrops(ready); // Hold reason by precedence: unknown beats wet beats cold beats frost. const holdValue = !soilKnown ? 'Soil data unavailable' : groundWet ? 'Hold off — ground too wet' : sowSeason.some(c => runAt(c.sow.minSoilT) === 0) ? 'Hold off — soil still cold' : 'Hold off — frost forecast'; out.push(buildRow('🌱', 'Good to sow', sowState, sowReadyValue, holdValue)); } // ── Good to harvest ────────────────────────────────────────────────────── const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months)); if (harvestSeason.length) { // A dry SPELL, not scattered dry days: under 2 mm and rain chance < 40%. const isDry = (d) => d.rainTotal < 2 && d.maxProb < 40; const spell = bestRun(daily, isDry); const dryRange = () => { const names = daily.slice(spell.start, spell.start + spell.len).map(d => d.name); return names.length === 1 ? `dry ${names[0]}` : `dry ${names[0]}–${names[names.length - 1]}`; }; const harvestState = classify(harvestSeason, (c) => { // Grain, rape and onions must come in / cure dry. if (c.harvest.dry) return spell.len >= 3 ? 'ready' : spell.len === 2 ? 'marginal' : 'no'; // Root crops are lifted by machine - saturated ground means ruts. if (c.harvest.lift) return groundWet ? 'no' : groundDamp ? 'marginal' : 'ready'; // Hand-cut (lettuce): month window is enough. return 'ready'; }); const dryDriven = spell.len > 0 && harvestState.ready.some(c => c.harvest.dry); const readyValue = (ready) => dryDriven ? `${listCrops(ready)} (${dryRange()})` : listCrops(ready); // Hold reason: only blame the ground when every stuck crop is a root crop. // (A hold means nothing was ready or marginal, so the whole season is stuck.) const holdValue = harvestSeason.every(c => c.harvest.lift) ? 'Hold — ground too wet to lift' : 'Hold — too wet to harvest grain'; out.push(buildRow('🌾', 'Good to harvest', harvestState, readyValue, holdValue)); } return out; }