955 lines
42 KiB
JavaScript
955 lines
42 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// 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,
|
||
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).
|
||
// Shade air temp: the air you'd feel in this Solar Model's typical shade
|
||
// (building shadow, beach umbrella, canopy...). Shares SunSoak's TaEnv
|
||
// baseline and env-reduced radiation, so the two columns stay consistent.
|
||
const shadeT = calcShadeAirTemp(TaEnv, dirEnv + difEnv * 0.2, va, elev, env.shade);
|
||
// 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);
|
||
// 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,
|
||
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]; });
|
||
|
||
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);
|
||
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, cabin/surface/indoor)
|
||
// - MEAN : smooth continuous quantities (air temp, RH, wind, cloud, ...)
|
||
// - FELT : worst-case felt temp - the warmest hour if it reaches >=20C
|
||
// (heat is the concern), otherwise the coldest (cold concern)
|
||
//
|
||
// 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 = [
|
||
'Ta', 'shadeT', 'RH', 'dew', 'va', 'dir', 'dif', 'cc', 'ccLow', 'ccMid', 'ccHigh',
|
||
'soilT0', 'soilT6', 'soilM', 'Tmrt', 'eh', 'visKm', 'aqi', 'effectiveRad',
|
||
'grassPollen', 'birchPollen', 'alderPollen', 'mugwortPollen', 'olivePollen', 'ragweedPollen',
|
||
];
|
||
const AGG_MAX = [
|
||
'precipProb', 'uv', 'uvA', 'uvB', 'gust',
|
||
'vehicleT', 'concreteT', 'indoorT', 'managedT',
|
||
];
|
||
const AGG_SUM = ['precip', 'snow'];
|
||
const AGG_FELT = ['utci', 'utciAdj'];
|
||
|
||
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 mean = (vals) => vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
||
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('shadeT');
|
||
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.shadeT)}° at ${hhmm(peakShade.iso)}` : '-',
|
||
alert: !!(peakShade && peakShade.shadeT >= 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.
|
||
//
|
||
// Parameters:
|
||
// weekDays - the `days` array: [{ key: 'YYYY-MM-DD', rows: [...] }, ...]
|
||
// lat - forecast latitude; < 0 flips the UK calendar by +6 months
|
||
// ------------------------------------------------------------------------
|
||
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);
|
||
};
|
||
|
||
// Warmth the seedbed actually reaches this week: the highest of each day's
|
||
// peak surface soil temperature.
|
||
let weekSoilT = null;
|
||
for (const d of days) {
|
||
const peak = Math.max(-Infinity, ...d.rows.map(r => r.soilT0 ?? -Infinity));
|
||
if (peak > -Infinity && (weekSoilT == null || peak > weekSoilT)) weekSoilT = peak;
|
||
}
|
||
|
||
// Latest known soil moisture - saturated ground (>=45%) is unworkable.
|
||
let soilM = null;
|
||
for (const d of days) {
|
||
for (const r of d.rows) if (r.soilM != null) soilM = r.soilM;
|
||
}
|
||
const groundWet = soilM != null && soilM >= 0.45;
|
||
|
||
// Dry days this week: under 2 mm total rain and rain chance staying < 40%.
|
||
const dryNames = [];
|
||
for (const d of days) {
|
||
const totalRain = d.rows.reduce((s, r) => s + (r.precip ?? 0), 0);
|
||
const maxProb = Math.max(0, ...d.rows.map(r => r.precipProb ?? 0));
|
||
if (totalRain < 2 && maxProb < 40) {
|
||
const dt = new Date((d.key || '') + 'T00:00Z');
|
||
dryNames.push(dt.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' }));
|
||
}
|
||
}
|
||
const hasDrySpell = dryNames.length > 0;
|
||
|
||
// Full crop-name list for the glance row.
|
||
const listCrops = (crops) => crops.map(c => c.label).join(', ');
|
||
const dryRange = () => dryNames.length === 1
|
||
? `dry ${dryNames[0]}`
|
||
: `dry ${dryNames[0]}–${dryNames[dryNames.length - 1]}`;
|
||
|
||
const out = [];
|
||
|
||
// ── Good to sow ──────────────────────────────────────────────────────────
|
||
const sowSeason = CROP_CALENDAR.filter(c => inSeason(c.sow.months));
|
||
if (sowSeason.length) {
|
||
const ready = sowSeason.filter(c =>
|
||
weekSoilT != null && weekSoilT >= c.sow.minSoilT && !groundWet);
|
||
out.push(ready.length ? {
|
||
icon: '🌱', label: 'Good to sow', value: listCrops(ready), alert: false, grp: 'surface',
|
||
} : {
|
||
icon: '🌱', label: 'Good to sow',
|
||
value: groundWet ? 'Hold off — ground too wet' : 'Hold off — soil still cold',
|
||
alert: true, grp: 'surface',
|
||
});
|
||
}
|
||
|
||
// ── Good to harvest ──────────────────────────────────────────────────────
|
||
const harvestSeason = CROP_CALENDAR.filter(c => inSeason(c.harvest.months));
|
||
if (harvestSeason.length) {
|
||
// Grain/rape/onions need a dry spell; everything else can be lifted in window.
|
||
const ready = harvestSeason.filter(c => !c.harvest.dry || hasDrySpell);
|
||
const dryDriven = hasDrySpell && ready.some(c => c.harvest.dry);
|
||
out.push(ready.length ? {
|
||
icon: '🌾', label: 'Good to harvest',
|
||
value: dryDriven ? `${listCrops(ready)} (${dryRange()})` : listCrops(ready),
|
||
alert: false, grp: 'surface',
|
||
} : {
|
||
icon: '🌾', label: 'Good to harvest',
|
||
value: 'Hold — too wet to harvest grain', alert: true, grp: 'surface',
|
||
});
|
||
}
|
||
|
||
return out;
|
||
} |