Files
sunscope/assets/js/compute.js
T
fraxle ffb5ed1d7d 3.8.5
Correct 'Shade' temps for night
2026-08-02 17:38:59 +01:00

1108 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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 } from './utils.js';
import { UTCI_ENVIRONMENTS, CROP_CALENDAR } 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);
const Tmrt = calcTmrt(TaEnv, dirEnv, difEnv, globEnv, elev);
const utci = utciApprox(TaEnv, Tmrt, va, eh);
const utciAdj = utci + 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) ──────────────────────────────
// Shown across every profile: two rows comparing this day against the
// 1991-2020 norm for the date — the day's HIGH vs the normal high, and the
// day's 24h AVERAGE vs the normal mean. High tracks the daytime peak people
// notice; the average is the standard climate anomaly. 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;
const out = [];
const nHigh = normals.high?.[doy];
if (nHigh != null) {
const a = dayHigh - nHigh;
if (Math.abs(a) >= 3) {
out.push({ icon: '🌡', label: '1991-2020 High', value: `${a >= 0 ? '+' : ''}${Math.abs(a).toFixed(1)}° ${a >= 0 ? 'warmer' : 'cooler'}`, alert: a >= 5, grp: 'ambient' });
}
}
const nMean = normals.mean?.[doy];
if (nMean != null) {
const a = dayMean - nMean;
if (Math.abs(a) >= 3) {
out.push({ icon: '🌡', label: '1991-2020 Average', value: `${a >= 0 ? '+' : ''}${Math.abs(a).toFixed(1)}° ${a >= 0 ? 'warmer' : 'cooler'}`, alert: a >= 5, grp: 'ambient' });
}
}
return out;
})();
// ── 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') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? 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: 'felt',
}] : []),
...(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') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? 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') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? 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') ? [{
icon: '💨',
label: 'Air quality',
value: peakAqi > 0 ? 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;
}
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;
}