var _useState_AQG = React.useState;
var _useEffect_AQG = React.useEffect;
var _useMemo_AQG = React.useMemo;
var _useRef_AQG = React.useRef;

/**
 * AirQualityPage — the destination behind the dashboard AirQualityPanel. Route: /air-quality.
 *
 * Sibling of WeatherPage: same SWR-against-a-cache architecture, same never-block-on-a-
 * failure posture, same freshness-ticker-touches-only-text discipline. What this page adds
 * that the panel doesn't have room for is the Ventilation Planner (a 5-day x 6-cell OPEN /
 * CAUTION / CLOSED grid, AirGuidelines.ventStatus's reason for existing) and progressive
 * disclosure on all seven pollutants for an audience that ranges from a homeowner glancing
 * at a badge to a hygienist wanting the WHO multiple and the raw µg/m³ value.
 *
 * NAMESPACED. Every top-level global in this file is `aqg`-prefixed so it can never
 * collide with AirQualityPanel.jsx's `aqp`-prefixed globals — same discipline as the
 * wx/wc split documented in WeatherPanel.jsx. Panel and page each run their OWN mapper
 * over the same raw /api/air response; nothing mapped is shared between them.
 *
 * Written in ES5 for Babel 6 standalone.
 */

/* ────────────────────────────────────────────────────────────────────────────
   Coercion + time helpers, aqg-prefixed (independent copies of AirQualityPanel's —
   see the file header for why they are not shared).
   ──────────────────────────────────────────────────────────────────────────── */
var aqgNum = function (v) {
    if (typeof v === 'number') return isFinite(v) ? v : null;
    if (typeof v === 'string') { var n = parseFloat(v); return isFinite(n) ? n : null; }
    return null;
};
var aqgStr = function (v) {
    if (typeof v === 'string') { var t = v.trim(); return t || ''; }
    if (typeof v === 'number' && isFinite(v)) return String(v);
    return '';
};
var aqgParseTime = function (str) {
    if (!str || typeof str !== 'string') return null;
    var d = new Date(str);
    if (!isNaN(d.getTime())) return d;
    if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(str)) {
        d = new Date(str + ':00:00Z');
        if (!isNaN(d.getTime())) return d;
    }
    return null;
};
var aqgFormatAge = function (hours) {
    if (hours === null || hours === undefined) return '';
    if (hours < 0.03) return 'just now';
    if (hours < 1) return Math.max(1, Math.round(hours * 60)) + 'm ago';
    if (hours < 24) return Math.round(hours) + 'h ago';
    return Math.round(hours / 24) + 'd ago';
};

/* ────────────────────────────────────────────────────────────────────────────
   aqgMap — the entire contract with /api/air lives here.
   ──────────────────────────────────────────────────────────────────────────── */
var aqgMap = function (raw) {
    var out = {
        ok: false,
        attribution: '', run: null, nowTime: null, ageHours: null,
        guideline: '', vocProxy: '',
        worstSpecies: null, worstBand: null,
        speciesNow: {},
        hourly: { time: [] },
        daily: { date: [] },
    };
    if (!raw || typeof raw !== 'object') return out;

    var now = (raw.now && typeof raw.now === 'object') ? raw.now : {};
    var anyValue = false;
    for (var i = 0; i < AirGuidelines.SPECIES.length; i++) {
        var species = AirGuidelines.SPECIES[i];
        var entry = now[species];
        var value = aqgNum(entry && entry.value);
        var band = (entry && typeof entry.band === 'string') ? entry.band : null;
        if (!band && value !== null) band = AirGuidelines.bandForValue(species, value);
        out.speciesNow[species] = { value: value, band: band };
        if (value !== null) anyValue = true;
    }
    out.ok = anyValue;
    if (!out.ok) return out;

    var worst = AirGuidelines.worstBand(out.speciesNow);
    if (worst) { out.worstSpecies = worst.species; out.worstBand = worst.band; }

    out.attribution = aqgStr(raw.attribution);
    out.run = aqgStr(raw.run) || null;
    out.nowTime = aqgStr(now.time) || null;
    out.guideline = aqgStr(raw.guideline);
    out.vocProxy = aqgStr(raw.voc_proxy);

    var whenDate = aqgParseTime(out.nowTime) || aqgParseTime(out.run);
    if (whenDate) out.ageHours = Math.max(0, (Date.now() - whenDate.getTime()) / 3600000);

    if (raw.hourly && typeof raw.hourly === 'object') out.hourly = raw.hourly;
    if (raw.daily && typeof raw.daily === 'object') out.daily = raw.daily;

    return out;
};

/* ────────────────────────────────────────────────────────────────────────────
   Display constants
   ──────────────────────────────────────────────────────────────────────────── */
var AQG_VERDICT = {
    good:      'Good air for airing out',
    fair:      'Reasonable — some pollutants elevated',
    poor:      'Poor outdoor air today',
    very_poor: 'Very poor — keep windows closed',
};
var AQG_BAND_ICON = { good: 'eco', fair: 'info', poor: 'warning', very_poor: 'error' };
var AQG_BAND_HEX  = { good: '#0fbd80', fair: '#d4a373', poor: '#D4836B', very_poor: '#c1440e' };
var AQG_BAND_LABEL = { good: 'Good', fair: 'Fair', poor: 'Poor', very_poor: 'Very poor' };

var AQG_DAY_HOURS = ['06', '09', '12', '15', '18', '21'];
var AQG_VENT_STYLE = {
    OPEN:    { icon: 'check_circle', hex: '#0fbd80', bg: 'rgba(15,189,128,0.12)' },
    CAUTION: { icon: 'warning',      hex: '#b8863f', bg: 'rgba(212,163,115,0.18)' },
    CLOSED:  { icon: 'error',        hex: '#a85a3f', bg: 'rgba(212,131,107,0.16)' },
};
var AQG_HOUR_LABEL = function (hh) {
    var h = parseInt(hh, 10);
    if (h === 0) return '12am';
    if (h === 12) return '12pm';
    return h > 12 ? (h - 12) + 'pm' : h + 'am';
};

/** species metadata: display name, short meaning, full label (hcho gets the VOC caveat
 *  in its label per the brief), and a plain-English health/mould-relevance sentence. */
var AQG_SPECIES = [
    {
        key: 'pm2_5', label: 'PM2.5', fullLabel: 'PM2.5',
        meaning: 'fine particles from smoke and traffic',
        sentence: 'Small enough to reach deep into the lungs. Not a mould signal on its own, but on a high day it is worth managing indoor humidity by other means rather than opening windows to air a room out.',
    },
    {
        key: 'pm10', label: 'PM10', fullLabel: 'PM10',
        meaning: 'larger dust and pollen particles',
        sentence: 'Coarser than PM2.5 and settles out of the air faster. Worth a glance before a long ventilation session on a dusty or high-pollen day.',
    },
    {
        key: 'o3', label: 'Ozone', fullLabel: 'Ozone (O₃)',
        meaning: 'ground-level ozone, from sunlight and traffic exhaust',
        sentence: 'Builds through the day and usually peaks in the afternoon sun. Not linked to indoor damp, but a high reading is still a fair reason to time ventilation for the morning instead.',
    },
    {
        key: 'no2', label: 'Nitrogen dioxide', fullLabel: 'Nitrogen dioxide (NO₂)',
        meaning: 'traffic exhaust, mostly near busy roads',
        sentence: 'A marker of traffic-heavy air rather than of mould risk — useful context if a property sits close to a main road.',
    },
    {
        key: 'so2', label: 'Sulfur dioxide', fullLabel: 'Sulfur dioxide (SO₂)',
        meaning: 'fuel and industrial combustion',
        sentence: 'Rarely elevated away from industrial areas or bushfire smoke — worth a glance if either applies to this property.',
    },
    {
        key: 'co', label: 'Carbon monoxide', fullLabel: 'Carbon monoxide (CO)',
        meaning: 'vehicle exhaust and incomplete combustion',
        sentence: 'Not linked to damp or mould on its own, but a reminder not to air a room out right beside idling traffic or a running generator.',
    },
    {
        key: 'hcho', label: 'Formaldehyde (VOC indicator)', fullLabel: 'Formaldehyde (VOC indicator)',
        meaning: 'a heuristic proxy for other volatile organic compounds outdoors',
        sentence: 'This is a heuristic indicator, not a certified formaldehyde measurement — read it as a general sense of outdoor VOC activity, not a precise number.',
    },
];

var AQG_PERSONAS = [
    { icon: 'cleaning_services', role: 'Contract cleaner', text: 'Schedule post-clean airing on an OPEN window — clears residual damp fastest without fighting outdoor pollutants.' },
    { icon: 'plumbing', role: 'Plumber', text: 'After fixing a leak, dry the area out on OPEN windows rather than CLOSED — the planner shows when that is today.' },
    { icon: 'search', role: 'Building inspector', text: 'Note the outdoor air context for the inspection record — a CLOSED day explains why a property felt stuffy at inspection.' },
    { icon: 'apartment', role: 'Strata manager', text: 'Use the planner to time common-area ventilation guidance to residents across the week.' },
    { icon: 'construction', role: 'Remediator / Hygienist', text: 'CLOSED days matter for containment decisions — outdoor air quality shapes when a negative-pressure setup can vent safely.' },
    { icon: 'verified_user', role: 'Insurer / Researcher', text: 'Conditions at scan time are recorded on every scan — a premium insight for claims history and research datasets.' },
];

/* ────────────────────────────────────────────────────────────────────────────
   AqSparkline — 5-day min/max range for one species. Single hue (the app's primary
   green), matching the established local precedent (WxHumiditySpark in WeatherPanel.jsx,
   HumidityTrend in WeatherPage.jsx): thin 2px line on the daily max, a light fill down to
   the daily min, rounded endpoint, fixed viewBox, ARIA label carrying the same numbers as
   the visual, plus a visible text summary underneath — the chart is never the only carrier
   of the information. min is derived from the hourly series for that date because the
   /v1/air contract's `daily` block ships max/mean only, no min field.
   ──────────────────────────────────────────────────────────────────────────── */
var AqSparkline = function (props) {
    var days = props.days || []; // [{ label, min, max }]
    var unit = props.unit || 'µg/m³';
    if (days.length < 2) return null;

    var w = 280, h = 56, padL = 4, padR = 4, padT = 6, padB = 16;
    var plotW = w - padL - padR;
    var plotH = h - padT - padB;

    var lo = days[0].min, hi = days[0].max;
    for (var i = 1; i < days.length; i++) {
        if (days[i].min !== null && days[i].min < lo) lo = days[i].min;
        if (days[i].max !== null && days[i].max > hi) hi = days[i].max;
    }
    if (lo === hi) { lo = lo - 1; hi = hi + 1; }
    var span = hi - lo;

    var xAt = function (i) { return padL + (i / (days.length - 1)) * plotW; };
    var yAt = function (v) { return padT + plotH - ((v - lo) / span) * plotH; };

    var maxLine = '', minLine = '', band = '';
    for (var k = 0; k < days.length; k++) {
        var x = xAt(k).toFixed(1);
        var yMax = yAt(days[k].max !== null ? days[k].max : lo).toFixed(1);
        maxLine += (k === 0 ? 'M' : 'L') + x + ' ' + yMax + ' ';
    }
    for (var m = days.length - 1; m >= 0; m--) {
        var xr = xAt(m).toFixed(1);
        var yMin = yAt(days[m].min !== null ? days[m].min : lo).toFixed(1);
        minLine += (m === days.length - 1 ? 'M' : 'L') + xr + ' ' + yMin + ' ';
    }
    band = maxLine + minLine + 'Z';

    var summary = days.map(function (d) {
        return d.label + ': ' + (d.min !== null ? d.min.toFixed(1) : '—') + '–' +
            (d.max !== null ? d.max.toFixed(1) : '—') + ' ' + unit;
    }).join(', ');

    return (
        <div>
            <svg
                viewBox={'0 0 ' + w + ' ' + h}
                width="100%" height={h}
                preserveAspectRatio="none"
                role="img"
                aria-label={'5-day range: ' + summary}
                style={{ display: 'block' }}
            >
                <path d={band} fill="rgba(15,189,128,0.14)" />
                <path d={maxLine} fill="none" stroke="#0fbd80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                <path d={minLine} fill="none" stroke="#86a697" strokeWidth="1.3" strokeDasharray="2.5 2.5" strokeLinecap="round" opacity="0.8" />
                {days.map(function (d, i) {
                    return (
                        <text key={'l' + i} x={xAt(i)} y={h - 3} textAnchor={i === 0 ? 'start' : i === days.length - 1 ? 'end' : 'middle'}
                              fontSize="8" fontWeight="700" fill="#86a697">{d.label}</text>
                    );
                })}
            </svg>
            <p className="text-[10px] text-sage leading-relaxed mt-1">{summary}</p>
        </div>
    );
};

/* ────────────────────────────────────────────────────────────────────────────
   Collapse styles — transform/opacity + a pre-measured max-height only. No animated
   layout property survives past that one max-height transition; the chevron rotates via
   transform. Respects prefers-reduced-motion.
   ──────────────────────────────────────────────────────────────────────────── */
var AQG_CSS = [
    '.aq-row-body{max-height:0;opacity:0;overflow:hidden;transition:max-height .32s ease,opacity .22s ease;}',
    '.aq-row-body.aq-open{max-height:640px;opacity:1;}',
    '.aq-chevron{transition:transform .22s ease;}',
    '.aq-chevron.aq-open{transform:rotate(180deg);}',
    '.aq-rise{animation:aqRise .4s cubic-bezier(.22,.8,.3,1) both;}',
    '@keyframes aqRise{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}',
    '@media (prefers-reduced-motion: reduce){',
    '.aq-row-body{transition:opacity .22s ease;max-height:none !important;}',
    '.aq-chevron{transition:none;}',
    '.aq-rise{animation:none !important;opacity:1 !important;transform:none !important;}',
    '}',
].join('');

/* ────────────────────────────────────────────────────────────────────────────
   PollutantRow — one collapsed row, expandable to value/WHO-multiple/sentence/sparkline.
   ──────────────────────────────────────────────────────────────────────────── */
var PollutantRow = function (props) {
    var meta = props.meta;
    var entry = props.entry; // { value, band }
    var open = props.open;
    var onToggle = props.onToggle;
    var sparkDays = props.sparkDays;
    var guideline = props.guideline;

    var band = entry.band || null;
    var hex = AQG_BAND_HEX[band] || '#8BA888';
    var whoMultiple = (meta.key !== 'hcho' && entry.value !== null && AirGuidelines.GUIDELINES[meta.key])
        ? (entry.value / AirGuidelines.GUIDELINES[meta.key])
        : null;

    var rowId = 'aq-row-' + meta.key;

    return (
        <div className="border-b border-sage/15 last:border-b-0">
            <button
                type="button"
                aria-expanded={open}
                aria-controls={rowId}
                onClick={onToggle}
                className="w-full flex items-center gap-3 py-3 text-left btn-nature focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
            >
                <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: hex }} aria-hidden="true"></span>
                <div className="min-w-0 flex-1">
                    <p className="text-sm font-extrabold text-forest">{meta.label}</p>
                    <p className="text-[11px] text-forest/60 truncate">{meta.meaning}</p>
                </div>
                {band ? (
                    <span
                        className="text-[9.5px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0"
                        style={{ color: hex, backgroundColor: hex + '1a' }}
                    >
                        {AQG_BAND_LABEL[band]}
                    </span>
                ) : (
                    <span className="text-[9.5px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0 text-sage bg-sage/10">
                        No data
                    </span>
                )}
                <span className={'material-symbols-outlined text-sage text-lg shrink-0 aq-chevron ' + (open ? 'aq-open' : '')} aria-hidden="true">
                    expand_more
                </span>
            </button>

            <div id={rowId} className={'aq-row-body ' + (open ? 'aq-open' : '')}>
                <div className="pb-4 pl-[22px] pr-1">
                    <div className="flex items-baseline gap-3 flex-wrap mb-2">
                        <p className="text-2xl font-black text-forest leading-none">
                            {entry.value !== null ? entry.value.toFixed(1) : '—'}
                            <span className="text-xs font-bold text-sage align-top ml-1">µg/m³</span>
                        </p>
                        {whoMultiple !== null ? (
                            <span className="text-[11px] font-extrabold px-2 py-0.5 rounded-full" style={{ color: hex, backgroundColor: hex + '1a' }}>
                                {whoMultiple.toFixed(1)}× WHO guideline
                            </span>
                        ) : meta.key === 'hcho' ? (
                            <span className="text-[11px] font-extrabold px-2 py-0.5 rounded-full text-sage bg-sage/10">
                                No WHO guideline — heuristic proxy
                            </span>
                        ) : null}
                    </div>
                    <p className="text-xs text-forest/80 leading-relaxed">{meta.sentence}</p>
                    {sparkDays && sparkDays.length >= 2 ? (
                        <div className="mt-3">
                            <p className="text-[9.5px] font-black uppercase tracking-widest text-sage mb-1">5-day range</p>
                            <AqSparkline days={sparkDays} />
                            <p className="text-[9.5px] font-bold text-sage mt-1">solid — daily high · dashed — daily low</p>
                        </div>
                    ) : null}
                </div>
            </div>
        </div>
    );
};

/* ────────────────────────────────────────────────────────────────────────────
   Ventilation Planner
   ──────────────────────────────────────────────────────────────────────────── */
var VentPlanner = function (props) {
    var plannerDays = props.days || []; // [{ date, label, cells: [{hh,status,pm25Band,pm10Band,rhMean}] }]
    if (!plannerDays.length) {
        return (
            <p className="text-xs text-sage leading-relaxed">
                No hourly forecast available yet for the ventilation planner — check back shortly.
            </p>
        );
    }
    return (
        <div>
            <div className="overflow-x-auto no-scrollbar -mx-1">
                <div className="flex gap-1.5 px-1 min-w-min">
                    {plannerDays.map(function (d) {
                        return (
                            <div key={d.date} className="shrink-0 w-[62px] rounded-xl bg-background-light/70 border border-stone-200/50 py-2 px-1 flex flex-col items-center gap-1">
                                <p className="text-[10px] font-black uppercase tracking-wider text-forest">{d.label}</p>
                                {d.cells.map(function (c) {
                                    var s = AQG_VENT_STYLE[c.status] || AQG_VENT_STYLE.CAUTION;
                                    var pmText = 'PM2.5 ' + (c.pm25Band ? AQG_BAND_LABEL[c.pm25Band] : 'unknown') +
                                        ', PM10 ' + (c.pm10Band ? AQG_BAND_LABEL[c.pm10Band] : 'unknown') +
                                        ', humidity ' + (c.rhMean !== null ? Math.round(c.rhMean) + '%' : 'unknown');
                                    return (
                                        <div
                                            key={c.hh}
                                            title={AQG_HOUR_LABEL(c.hh) + ' — ' + c.status + '. ' + pmText}
                                            aria-label={AQG_HOUR_LABEL(c.hh) + ' — ' + c.status + '. ' + pmText}
                                            className="w-full rounded-lg py-1.5 flex flex-col items-center gap-0.5"
                                            style={{ backgroundColor: s.bg }}
                                        >
                                            <span className="text-[8px] font-bold text-forest/60">{AQG_HOUR_LABEL(c.hh)}</span>
                                            <span className="material-symbols-outlined text-[13px] leading-none" style={{ color: s.hex }} aria-hidden="true">{s.icon}</span>
                                            <span className="text-[7.5px] font-black uppercase tracking-wide" style={{ color: s.hex }}>{c.status}</span>
                                        </div>
                                    );
                                })}
                            </div>
                        );
                    })}
                </div>
            </div>
            <div className="mt-3 pt-3 border-t border-sage/15 space-y-1">
                <p className="text-[10px] font-extrabold text-forest flex items-center gap-1.5">
                    <span className="material-symbols-outlined text-[13px]" style={{ color: AQG_VENT_STYLE.OPEN.hex }} aria-hidden="true">check_circle</span>
                    OPEN — PM2.5 &amp; PM10 good, humidity &lt;65%
                </p>
                <p className="text-[10px] font-extrabold text-forest flex items-center gap-1.5">
                    <span className="material-symbols-outlined text-[13px]" style={{ color: AQG_VENT_STYLE.CAUTION.hex }} aria-hidden="true">warning</span>
                    CAUTION — either fair, or humidity 65–75%
                </p>
                <p className="text-[10px] font-extrabold text-forest flex items-center gap-1.5">
                    <span className="material-symbols-outlined text-[13px]" style={{ color: AQG_VENT_STYLE.CLOSED.hex }} aria-hidden="true">error</span>
                    CLOSED — either poor/very poor, or humidity &gt;75%
                </p>
            </div>
        </div>
    );
};

/* ────────────────────────────────────────────────────────────────────────────
   AirQualityPage
   ──────────────────────────────────────────────────────────────────────────── */
var AirQualityPage = function () {
    var statusState = _useState_AQG('loading'); // loading | ready | empty
    var status = statusState[0];
    var setStatus = statusState[1];

    var dataState = _useState_AQG(null); // aqgMap() output + .source
    var data = dataState[0];
    var setData = dataState[1];

    var placeState = _useState_AQG('');
    var place = placeState[0];
    var setPlace = placeState[1];

    var weatherRawState = _useState_AQG(null); // best-effort, for planner rhMean
    var weatherRaw = weatherRawState[0];
    var setWeatherRaw = weatherRawState[1];

    var deniedState = _useState_AQG(false);
    var denied = deniedState[0];
    var setDenied = deniedState[1];

    var attemptState = _useState_AQG(0);
    var attempt = attemptState[0];
    var setAttempt = attemptState[1];

    var openRowState = _useState_AQG(null); // currently expanded species key, or null
    var openRow = openRowState[0];
    var setOpenRow = openRowState[1];

    var footnoteOpenState = _useState_AQG(false);
    var footnoteOpen = footnoteOpenState[0];
    var setFootnoteOpen = footnoteOpenState[1];

    var tickState = _useState_AQG(Date.now()); // 60s ticker — freshness TEXT only
    var setTick = tickState[1];

    var mountedRef = _useRef_AQG(true);

    _useEffect_AQG(function () {
        mountedRef.current = true;
        var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
        var timers = [];
        var later = function (fn, ms) { var id = setTimeout(fn, ms); timers.push(id); return id; };

        setStatus('loading');

        var cachePainted = false;
        var networkLanded = false;

        if (attempt === 0 && typeof AirCache !== 'undefined' && AirCache.load) {
            AirCache.load().then(function (rec) {
                if (!mountedRef.current || !rec || networkLanded) return;
                var mapped;
                try { mapped = aqgMap(rec.raw); } catch (e) { mapped = { ok: false }; }
                if (mapped.ok) {
                    cachePainted = true;
                    mapped.source = 'cached';
                    setData(mapped);
                    if (rec.place) setPlace(rec.place);
                    setStatus('ready');
                }
            })['catch'](function () {});
        }

        if (typeof WeatherCache !== 'undefined' && WeatherCache.load) {
            WeatherCache.load().then(function (rec) {
                if (mountedRef.current && rec && rec.raw) setWeatherRaw(rec.raw);
            })['catch'](function () {});
        }

        var lastCoords = null;
        var resolvedPlace = '';
        var LOCATION_BUDGET_MS = 6000;
        var REQUEST_TIMEOUT_MS = 22000;

        var resolveCoords = function () {
            return new Promise(function (resolve) {
                if (!window.GeoLocationService || typeof GeoLocationService.getCurrentPosition !== 'function') {
                    resolve(null);
                    return;
                }
                var settled = false;
                var finish = function (v) { if (settled) return; settled = true; resolve(v); };
                later(function () { finish(null); }, LOCATION_BUDGET_MS);
                GeoLocationService.getCurrentPosition()
                    .then(function (c) { finish(c && typeof c.lat === 'number' && typeof c.lon === 'number' ? c : null); })
                    .catch(function () { if (mountedRef.current) setDenied(true); finish(null); });
            });
        };

        var fallback = (window.AppConstants && AppConstants.FALLBACK_COORDS) ||
            { lat: -33.8688, lon: 151.2093, name: 'Sydney' };

        resolveCoords()
            .then(function (coords) {
                if (!mountedRef.current) return null;
                if (coords && window.GeoLocationService && typeof GeoLocationService.reverseGeocode === 'function') {
                    GeoLocationService.reverseGeocode(coords.lat, coords.lon)
                        .then(function (geo) {
                            if (mountedRef.current && geo && geo.displayName) {
                                // Local capture as well as state — the save call below closes
                                // over a stale `place` from before this geocode resolved.
                                resolvedPlace = geo.displayName;
                                setPlace(geo.displayName);
                            }
                        })
                        .catch(function () {});
                }
                var at = coords || fallback;
                lastCoords = at;
                var url = '/api/air?lat=' + encodeURIComponent(at.lat.toFixed(4)) +
                          '&lon=' + encodeURIComponent(at.lon.toFixed(4)) + '&days=5';
                later(function () { if (controller) controller.abort(); }, REQUEST_TIMEOUT_MS);
                return fetch(url, { headers: { 'Accept': 'application/json' }, signal: controller ? controller.signal : undefined });
            })
            .then(function (res) {
                if (!mountedRef.current || !res) return null;
                if (!res.ok) throw new Error('Air service returned ' + res.status);
                return res.json();
            })
            .then(function (raw) {
                if (!mountedRef.current || raw === null) return;
                var mapped;
                try { mapped = aqgMap(raw); } catch (e) { mapped = { ok: false }; }
                if (mapped.ok) {
                    mapped.source = 'live';
                    networkLanded = true;
                    setData(mapped);
                    setStatus('ready');
                    if (typeof AirCache !== 'undefined' && AirCache.save && lastCoords) {
                        AirCache.save(lastCoords.lat, lastCoords.lon, resolvedPlace || place || null, raw);
                    }
                } else if (!cachePainted) {
                    return Promise.reject(new Error('empty_payload'));
                }
            })
            .catch(function () {
                if (!mountedRef.current || networkLanded || cachePainted) return;
                if (typeof AirCache !== 'undefined' && AirCache.loadSample) {
                    AirCache.loadSample().then(function (sample) {
                        if (!mountedRef.current || networkLanded || cachePainted) return;
                        var mapped;
                        try { mapped = aqgMap(sample); } catch (e) { mapped = { ok: false }; }
                        if (mapped.ok) {
                            mapped.source = 'sample';
                            setData(mapped);
                            setStatus('ready');
                        } else {
                            setStatus('empty');
                        }
                    })['catch'](function () { if (mountedRef.current) setStatus('empty'); });
                    return;
                }
                setStatus('empty');
            });

        var tickId = setInterval(function () { if (mountedRef.current) setTick(Date.now()); }, 60000);

        return function () {
            mountedRef.current = false;
            clearInterval(tickId);
            for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
            if (controller) controller.abort();
        };
    }, [attempt]);

    var retry = function () { setDenied(false); setAttempt(attempt + 1); };

    // ── Derived view model ──────────────────────────────────────────────────
    var freshness = _useMemo_AQG(function () {
        if (!data) return '';
        var t = aqgFormatAge(data.ageHours);
        return t ? 'Updated ' + t : '';
    }, [data, tickState[0]]);

    var todayLabel = data && data.daily && data.daily.date && data.daily.date.length ? data.daily.date[0] : null;

    var plannerDays = _useMemo_AQG(function () {
        if (!data || !data.hourly || !data.daily || !(data.daily.date instanceof Array)) return [];
        var hourly = data.hourly;
        var times = hourly.time instanceof Array ? hourly.time : [];
        var idxByKey = {};
        for (var i = 0; i < times.length; i++) {
            var ts = aqgStr(times[i]);
            idxByKey[ts.substring(0, 10) + ts.substring(11, 13)] = i;
        }
        var todayStr = todayLabel;
        return data.daily.date.map(function (dateStr, di) {
            var label = 'Day ' + (di + 1);
            if (dateStr === todayStr) label = 'Today';
            else {
                var ymd = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr);
                if (ymd) {
                    var dLocal = new Date(parseInt(ymd[1], 10), parseInt(ymd[2], 10) - 1, parseInt(ymd[3], 10));
                    label = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][dLocal.getDay()];
                }
            }
            var rhMean = WeatherCache.dayMeanRH(weatherRaw, dateStr);
            var cells = AQG_DAY_HOURS.map(function (hh) {
                var idx = idxByKey[dateStr + hh];
                var pm25v = idx !== undefined && hourly.pm2_5 instanceof Array ? aqgNum(hourly.pm2_5[idx]) : null;
                var pm10v = idx !== undefined && hourly.pm10 instanceof Array ? aqgNum(hourly.pm10[idx]) : null;
                var pm25Band = pm25v !== null ? AirGuidelines.bandForValue('pm2_5', pm25v) : null;
                var pm10Band = pm10v !== null ? AirGuidelines.bandForValue('pm10', pm10v) : null;
                return {
                    hh: hh,
                    pm25Band: pm25Band,
                    pm10Band: pm10Band,
                    rhMean: rhMean,
                    status: AirGuidelines.ventStatus({ pm25Band: pm25Band, pm10Band: pm10Band, rhMean: rhMean }),
                };
            });
            return { date: dateStr, label: label, cells: cells };
        });
    }, [data, weatherRaw, todayLabel]);

    var sparkByspecies = _useMemo_AQG(function () {
        if (!data || !data.daily || !(data.daily.date instanceof Array)) return {};
        var out = {};
        var dates = data.daily.date;
        var hourly = data.hourly || {};
        var htimes = hourly.time instanceof Array ? hourly.time : [];
        for (var s = 0; s < AQG_SPECIES.length; s++) {
            var key = AQG_SPECIES[s].key;
            var maxArr = data.daily[key + '_max'];
            var hourlyArr = hourly[key];
            var days = [];
            for (var d = 0; d < dates.length; d++) {
                var dateStr = dates[d];
                var max = (maxArr instanceof Array) ? aqgNum(maxArr[d]) : null;
                var min = null;
                if (hourlyArr instanceof Array) {
                    for (var t = 0; t < htimes.length; t++) {
                        if (aqgStr(htimes[t]).substring(0, 10) !== dateStr) continue;
                        var v = aqgNum(hourlyArr[t]);
                        if (v === null) continue;
                        if (min === null || v < min) min = v;
                        if (max === null || v > max) max = v; // fills max too if daily._max absent
                    }
                }
                var ymd = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr);
                var lbl = ymd ? ymd[3] + '/' + ymd[2] : dateStr;
                days.push({ label: lbl, min: min, max: max });
            }
            out[key] = days;
        }
        return out;
    }, [data]);

    // ── States ──────────────────────────────────────────────────────────────
    var renderLoading = function () {
        // Independent of WeatherPage's .wx-shimmer — this page must render its own
        // loading state correctly even if WeatherPage's <style> was never injected
        // (a visitor can reach /air-quality without ever having opened /weather).
        return (
            <div className="px-6 pt-2 space-y-4 animate-pulse motion-reduce:animate-none" aria-busy="true" aria-live="polite">
                <div className="rounded-xl bg-sage/15" style={{ height: '96px' }} />
                <div className="rounded-xl bg-sage/15" style={{ height: '260px' }} />
                <div className="rounded-xl bg-sage/15" style={{ height: '320px' }} />
                <p className="sr-only">Loading air quality</p>
            </div>
        );
    };

    var renderEmpty = function () {
        return (
            <div className="px-6 pt-6" role="alert">
                <div className="bio-bg bio-bg-30 rounded-xl p-5 border border-stone-100/50 shadow-soft">
                    <div className="flex items-center gap-2 mb-2">
                        <span className="material-symbols-outlined text-forest text-lg">cloud_off</span>
                        <h2 className="text-sm font-extrabold text-forest">Air quality unavailable</h2>
                    </div>
                    <p className="text-sm text-forest leading-relaxed">
                        {denied
                            ? 'Air quality needs your location to know which forecast to show.'
                            : 'Conditions aren’t available right now — the rest of the app works without it.'}
                    </p>
                    <button
                        type="button"
                        onClick={retry}
                        className="btn-nature mt-4 inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-forest text-white text-sm font-extrabold focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
                    >
                        <span className="material-symbols-outlined text-base">{denied ? 'my_location' : 'refresh'}</span>
                        {denied ? 'Use my location' : 'Try again'}
                    </button>
                </div>
            </div>
        );
    };

    var renderReady = function () {
        var band = data.worstBand || 'good';
        var verdict = AQG_VERDICT[band] || AQG_VERDICT.good;
        var bandHex = AQG_BAND_HEX[band] || AQG_BAND_HEX.good;
        var bandIcon = AQG_BAND_ICON[band] || AQG_BAND_ICON.good;

        var badge = data.source === 'sample'
            ? { symbol: '◐', text: 'sample', cls: 'text-warning bg-warning-light' }
            : data.source === 'cached'
                ? { symbol: '', text: 'cached', cls: 'text-sage bg-background-light' }
                : { symbol: '●', text: 'live', cls: 'text-primary bg-primary/10' };

        return (
            <div className="px-6 pt-2 space-y-4">

                {/* ── 1. Intro ── */}
                <div className="aq-rise">
                    <p className="text-sm text-forest/80 leading-relaxed">
                        Ventilation is one of the few moisture controls you can act on today. Outdoor air quality
                        decides whether opening windows helps dry a room out, or trades damp for smoke, dust or
                        traffic pollution instead. This page reads today's outdoor conditions for {place || 'your area'} and
                        tells you when airing out is worth it.
                    </p>
                </div>

                {/* ── 2. Headline verdict banner ── */}
                <div className="bio-bg bio-bg-10 rounded-xl p-5 border border-stone-100/50 shadow-soft aq-rise">
                    <div className="flex items-start gap-3">
                        <span className="material-symbols-outlined text-2xl shrink-0" style={{ color: bandHex }} aria-hidden="true">{bandIcon}</span>
                        <div className="min-w-0 flex-1">
                            <p className="text-base font-extrabold text-forest leading-snug">{verdict}</p>
                            <div className="flex items-center justify-between gap-2 mt-2">
                                <p className="text-[11px] font-bold text-sage flex items-center gap-1">
                                    <span className="material-symbols-outlined text-[13px]" aria-hidden="true">schedule</span>
                                    {freshness || 'Just now'}
                                </p>
                                <span className={'text-[9.5px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full ' + badge.cls}>
                                    {badge.symbol ? badge.symbol + ' ' : ''}{badge.text}
                                </span>
                            </div>
                        </div>
                    </div>
                </div>

                {/* ── 3. Ventilation Planner (hero) ── */}
                <section className="bio-bg bio-bg-20 rounded-xl p-5 border border-stone-100/50 shadow-soft aq-rise">
                    <div className="flex items-center gap-2 mb-2">
                        <span className="material-symbols-outlined text-forest text-lg" aria-hidden="true">air</span>
                        <h2 className="text-sm font-extrabold text-forest">Ventilation Planner</h2>
                    </div>
                    <p className="text-xs text-forest/80 leading-relaxed mb-4">
                        Opening windows at the right time removes the moisture mould needs — a preventive habit, not
                        a cure. If mould is already suspected or found, ventilating at the right times still helps
                        limit spread while you arrange an assessment.
                    </p>
                    <VentPlanner days={plannerDays} />
                </section>

                {/* ── 4. Pollutant detail — progressive disclosure ── */}
                <section className="bio-bg bio-bg-30 rounded-xl p-5 border border-stone-100/50 shadow-soft aq-rise">
                    <div className="flex items-center gap-2 mb-1">
                        <span className="material-symbols-outlined text-forest text-lg" aria-hidden="true">science</span>
                        <h2 className="text-sm font-extrabold text-forest">Pollutant detail</h2>
                    </div>
                    <p className="text-[11px] text-forest/60 leading-relaxed mb-2">Tap a row for the reading, the WHO multiple, and a 5-day range.</p>
                    <div>
                        {AQG_SPECIES.map(function (meta) {
                            var entry = data.speciesNow[meta.key] || { value: null, band: null };
                            return (
                                <PollutantRow
                                    key={meta.key}
                                    meta={meta}
                                    entry={entry}
                                    open={openRow === meta.key}
                                    onToggle={function (k) { return function () { setOpenRow(openRow === k ? null : k); }; }(meta.key)}
                                    sparkDays={sparkByspecies[meta.key]}
                                    guideline={data.guideline}
                                />
                            );
                        })}
                    </div>
                    <div className="border-t border-sage/15 pt-2 mt-1">
                        <button
                            type="button"
                            aria-expanded={footnoteOpen}
                            onClick={function () { setFootnoteOpen(!footnoteOpen); }}
                            className="w-full flex items-center gap-2 py-2 text-left btn-nature focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
                        >
                            <span className="material-symbols-outlined text-sage text-base" aria-hidden="true">info</span>
                            <span className="text-[11px] font-bold text-sage flex-1">A note on gas-reading accuracy</span>
                            <span className={'material-symbols-outlined text-sage text-base aq-chevron ' + (footnoteOpen ? 'aq-open' : '')} aria-hidden="true">expand_more</span>
                        </button>
                        <div className={'aq-row-body ' + (footnoteOpen ? 'aq-open' : '')}>
                            <p className="text-[11px] text-forest/70 leading-relaxed pb-2">
                                Gas-phase readings — ozone, nitrogen dioxide, sulfur dioxide, carbon monoxide and the
                                formaldehyde VOC proxy — come from a global atmospheric model, not a ground sensor at
                                this address. Treat them as accurate to roughly ±10–20%; particulate matter (PM2.5,
                                PM10) readings are the more reliable of the seven.
                            </p>
                        </div>
                    </div>
                </section>

                {/* ── 5. Persona strip ── */}
                <section className="aq-rise">
                    <h2 className="text-sm font-extrabold text-forest mb-3 px-1">How the trades use this</h2>
                    <div className="grid grid-cols-2 gap-3">
                        {AQG_PERSONAS.map(function (p) {
                            return (
                                <div key={p.role} className="bio-bg bio-bg-40 rounded-xl p-4 border border-stone-100/50 shadow-soft">
                                    <span className="material-symbols-outlined text-primary text-lg mb-1.5 block" aria-hidden="true">{p.icon}</span>
                                    <p className="text-[11px] font-extrabold text-forest leading-tight mb-1">{p.role}</p>
                                    <p className="text-[10px] text-forest/70 leading-snug">{p.text}</p>
                                </div>
                            );
                        })}
                    </div>
                </section>

                {/* ── 6. Footer ── */}
                {/* forest, not sage: this sits on the green page ground, where light text
                    breaks the legibility golden rule (light text only on solid dark fills) */}
                <p className="text-[10.5px] text-forest/80 font-medium text-center leading-relaxed pt-1 pb-2">
                    {data.attribution || 'Air quality via the Mould Detect data platform.'}
                    <br />
                    Bands use the WHO 2021 short-term air quality guideline multiples.
                    <br />
                    Guidance only — not medical advice. Coverage is Australia-only.
                </p>
            </div>
        );
    };

    return (
        <Layout>
            <main className="flex-1 overflow-y-auto overflow-x-hidden pb-36">
                <style dangerouslySetInnerHTML={{ __html: AQG_CSS }} />
                <PageHeader title="Air Quality" showMenu={false} />
                {status === 'loading' && renderLoading()}
                {status === 'empty' && renderEmpty()}
                {status === 'ready' && data && renderReady()}
            </main>
        </Layout>
    );
};

window.AirQualityPage = AirQualityPage;
