// ------------------------------------------------------------------------ // compute.js - Build the per-hour display rows from the raw API data. // // Pure-ish function: feed in (forecast, airQuality, location, vehicleType, // vehicleVent, buildingType) 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, calcVehicleInteriorTemp, calcIndoorTempPass, calcManagedIndoorTempPass, } from './physics.js'; import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel } from './utils.js'; import { UTCI_ENVIRONMENTS } from './config.js'; export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, buildingType, 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; // concreteT is now stamped in the two-pass section below (thermal lag). const vehicleT = calcVehicleInteriorTemp(Ta, glob, elev, vehicleType, vehicleVent); const eh = vaporPressureHpa(Ta, RH); // 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; 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, vehicleT, effectiveRad, elev, Tmrt, utci, utciAdj, eh, compass, visKm, aqi, grassPollen, birchPollen, alderPollen, mugwortPollen, olivePollen, ragweedPollen, }; }) : []; // Two-pass calculations: concrete thermal lag + indoor temperature. // Both 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]; }); } // 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', '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) { 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; // Longest run of rows that satisfy a predicate. const longestWindow = (rows, cond) => { let best = null, cur = null; for (const r of rows) { if (cond(r)) { cur = cur ? { start: cur.start, end: r, len: cur.len + 1 } : { start: r, end: r, len: 1 }; if (!best || cur.len > best.len) best = { ...cur }; } else { cur = null; } } return best; }; const pollenLabel = (v) => { if (v == null || v < 0) return null; if (v < 10) return 'Low'; if (v < 50) return 'Moderate'; if (v < 200) return 'High'; return 'Very High'; }; const aqiLabel = (v) => { if (v == null) return null; if (v < 20) return 'Good'; if (v < 40) return 'Fair'; if (v < 60) return 'Moderate'; return 'Poor'; }; const 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, }] : []; 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, }; // ── Farming ──────────────────────────────────────────────────────────── if (profile === 'farming') { const fieldWindow = longestWindow(dayRows, r => r.utciAdj >= 8 && r.utciAdj <= 32 && r.precipProb < 30 && r.va < 12 ); const soilWarmRow = todayRows.find(r => r.soilT0 != null && r.soilT0 >= 10); const soilM = todayRows.find(r => r.soilM != null)?.soilM ?? null; const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); return [ { icon: '⏱', label: 'Best field work', value: fieldWindow ? `${hhmm(fieldWindow.start.iso)} – ${hhmmEnd(fieldWindow.end.iso)}` : 'No suitable window', alert: !fieldWindow, }, ...(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, }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(show('soilM') ? [{ icon: '💧', label: 'Soil moisture', value: moistureLabel(soilM) ?? '-', alert: soilM != null && (soilM < 0.10 || soilM > 0.50), }] : []), ...(show('pollen') && maxPollen >= 10 ? [{ icon: '🌿', label: 'Grass pollen', value: pollenLabel(maxPollen), alert: maxPollen >= 50, }] : []), ]; } // ── Vehicle ──────────────────────────────────────────────────────────── if (profile === 'vehicle') { const peakCabin = peakRow('vehicleT'); const dangerFrom = todayRows.find(r => r.vehicleT != null && r.vehicleT >= 29); return [ ...(show('vehicleT') ? [{ icon: '🌡', label: 'Peak cabin temp', value: peakCabin ? `${Math.round(peakCabin.vehicleT)}° at ${hhmm(peakCabin.iso)}` : '-', alert: !!(peakCabin && peakCabin.vehicleT > 31.5), }, { icon: '🧒', label: 'Children/pets in car', value: dangerFrom ? `Unsafe from ${hhmm(dangerFrom.iso)}` : 'Safe all day', alert: !!dangerFrom, }] : []), ...(show('precipProb') ? [rainItem] : []), ...lightningItem, { icon: '🌤', label: 'Best travel comfort', value: (() => { const best = todayRows.reduce((b, r) => (b == null || r.utciAdj < b.utciAdj) ? r : b, null); return best ? `${hhmm(best.iso)} (${Math.round(best.utciAdj)}° felt)` : '-'; })(), alert: false, }, ]; } // ── Home ─────────────────────────────────────────────────────────────── if (profile === 'home') { const peakIndoor = peakRow('indoorT'); const peakManaged = peakRow('managedT'); const ventWindow = longestWindow(todayRows, r => r.Ta != null && r.indoorT != null && r.Ta < r.indoorT && r.precipProb < 20 ); const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); return [ ...(show('indoorT') ? [{ icon: '🌡', label: 'Peak indoor (unmanaged)', value: peakIndoor ? `${Math.round(peakIndoor.indoorT)}° at ${hhmm(peakIndoor.iso)}` : '-', alert: !!(peakIndoor && peakIndoor.indoorT >= 28), }] : []), ...(show('managedT') ? [{ icon: '🌡', label: 'Peak indoor (managed)', value: peakManaged ? `${Math.round(peakManaged.managedT)}°` : '-', alert: !!(peakManaged && peakManaged.managedT >= 28), }] : []), ...(show('indoorT') ? [{ icon: '🪟', label: 'Open windows', value: ventWindow ? `${hhmm(ventWindow.start.iso)} – ${hhmmEnd(ventWindow.end.iso)}` : 'Keep closed', alert: false, }] : []), ...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, }] : []), ]; } // ── Activities - running / cycling ───────────────────────────────────── if (variant === 'running' || variant === 'cycling') { const coolWindow = longestWindow(dayRows, r => r.utciAdj >= 5 && r.utciAdj <= 22 && r.precipProb < 30 ); const peakUvRow = peakRow('uv'); const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const burnMins = peakUvRow && peakUvRow.uv > 0 ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType)) : null; return [ { icon: '⏱', label: `Best ${variant} window`, value: coolWindow ? `${hhmm(coolWindow.start.iso)} – ${hhmmEnd(coolWindow.end.iso)}` : 'No cool window today', alert: !coolWindow, }, ...(show('precipProb') ? [rainItem] : []), ...lightningItem, ...(show('burn') && burnMins ? [{ icon: '☀', label: 'UV burn time (peak)', value: burnMins, alert: !!(peakUvRow && peakUvRow.uv >= 6), }] : []), ...(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 comfortWindow = longestWindow(dayRows, r => r.utciAdj >= 9 && r.utciAdj <= 26 && r.precipProb < 30 ); const peakFelt = peakRow('utciAdj'); const peakUvRow = peakRow('uv'); const peakAqi = Math.max(0, ...todayRows.map(r => r.aqi ?? 0)); const maxPollen = Math.max(0, ...todayRows.map(r => r.grassPollen ?? 0)); const burnMins = peakUvRow && peakUvRow.uv > 0 ? burnLabel(sunburnMinutes(peakUvRow.uv, skinType)) : null; return [ { icon: '🌤', label: 'Comfortable window', value: comfortWindow ? `${hhmm(comfortWindow.start.iso)} – ${hhmmEnd(comfortWindow.end.iso)}` : 'No comfortable window', alert: !comfortWindow, }, { icon: '🌡', label: 'Peak felt temp', value: peakFelt ? `${Math.round(peakFelt.utciAdj)}° at ${hhmm(peakFelt.iso)}` : '-', alert: !!(peakFelt && (peakFelt.utciAdj >= 32 || peakFelt.utciAdj < 0)), }, ...((show('burn') || show('uvA')) && burnMins ? [{ icon: '☀', label: 'UV burn time (peak)', value: burnMins, alert: !!(peakUvRow && peakUvRow.uv >= 6), }] : []), ...(show('precipProb') ? [rainItem] : []), ...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, }] : []), ]; }