/**
 * AirQualityPanel — dashboard card for outdoor air quality, sibling of WeatherPanel.
 * Same architecture verbatim-adapted: module-level in-memory cache, SWR against
 * AirCache (IndexedDB), the cachePainted/networkLanded guard pair, GeoLocationService +
 * AppConstants.FALLBACK_COORDS, and a three-rung fallback ladder for the very first load
 * a visitor ever makes: IndexedDB cache -> live network -> bundled sample data.
 *
 * Data:  GET /api/air?lat=&lon=&days=5   (same-origin, coords rounded 4dp)
 *        Every field optional. All shape knowledge lives in aqpMap() below.
 *
 * NAMESPACED. Mirrors the wx/wc split documented in WeatherPanel.jsx and
 * WeatherCache.jsx: this app has no module system, every top-level `var` in every
 * <script> becomes a `window` property, and the air PAGE (AirQualityPage.jsx) runs its
 * own independent mapper over the same raw /api/air response. Keep every panel-only
 * global `aqp`-prefixed so it can never collide with the page's `aqg`-prefixed globals,
 * or with `wx`/`wc`/`ac`/`ag` from the weather and air data-plane files.
 *
 * SAMPLE-DATA ATTRIBUTION OBLIGATION — see AirCache.jsx's header. Sample readings are
 * never persisted to AirCache and are never shown without a visible "sample" badge.
 *
 * Route: navigates to /air-quality.
 *
 * Written in ES5 for Babel 6 standalone — no arrow functions, no template literals, no
 * const/let, no spread. A syntax error here renders a blank panel with nothing in the
 * console, so keep it boring.
 */

var _useState_AQP = React.useState;
var _useEffect_AQP = React.useEffect;
var _useMemo_AQP = React.useMemo;

/* ────────────────────────────────────────────────────────────────────────────
   Module-level cross-mount cache — mirrors WeatherPanel's _wxCache exactly, so
   navigating to the dashboard and back doesn't re-run the whole load/skeleton/
   geolocation/request sequence for data that changes at most a few times a day.
   ──────────────────────────────────────────────────────────────────────────── */
var _aqCache = { at: 0, data: null, place: '' };
var AQ_CACHE_TTL_MS = 10 * 60 * 1000;

/** Daytime 3-hourly grid the ventilation planner cares about — matches the page. */
var AQP_DAY_HOURS = ['06', '09', '12', '15', '18', '21'];

/* ────────────────────────────────────────────────────────────────────────────
   Tolerant coercion helpers, aqp-prefixed.
   ──────────────────────────────────────────────────────────────────────────── */
var aqpNum = 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 aqpStr = function (v) {
    if (typeof v === 'string') { var t = v.trim(); return t || ''; }
    if (typeof v === 'number' && isFinite(v)) return String(v);
    return '';
};

/**
 * Parse the payload's own time fields. `now.time` is full ISO ("...T09:00Z"); `run` is an
 * hour-truncated ISO ("2026-08-04T00") the Date constructor cannot parse directly — padded
 * to a full ISO before retrying. Never throws; returns null on anything unusable.
 */
var aqpParseTime = 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;
};

/** null-safe "9h ago" — identical shape to WeatherPanel's wxFormatAge. */
var aqpFormatAge = 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';
};

/**
 * aqpMap — the entire contract with /api/air lives here. `ok` is false only when there
 * is genuinely nothing usable (no `now` block at all), in which case the panel falls
 * through the rest of the ladder rather than rendering a card full of dashes.
 */
// NAMESPACED — see file header. Air globals in this file are `aqp`-prefixed.
var aqpMap = function (raw) {
    var out = {
        ok: false,
        attribution: '',
        run: null,
        nowTime: null,
        ageHours: null,
        worstSpecies: null,
        worstBand: null,
        todayDate: null,
        hourlyToday: [],
    };
    if (!raw || typeof raw !== 'object') return out;

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

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

    out.attribution = aqpStr(raw.attribution);
    out.run = aqpStr(raw.run) || null;
    out.nowTime = aqpStr(now.time) || null;

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

    // Today's daytime 3-hourly cells, keyed off daily.date[0] — the payload's own idea of
    // "today", not the browser's clock, so the strip stays correct if the model run and
    // the visitor's local date briefly disagree near midnight.
    var daily = raw.daily || {};
    var todayDate = (daily.date instanceof Array && daily.date.length) ? aqpStr(daily.date[0]) : null;
    out.todayDate = todayDate;

    var hourly = raw.hourly || {};
    var times = hourly.time instanceof Array ? hourly.time : [];
    var pm25arr = hourly.pm2_5 instanceof Array ? hourly.pm2_5 : [];
    var pm10arr = hourly.pm10 instanceof Array ? hourly.pm10 : [];
    if (todayDate) {
        for (var t = 0; t < times.length && out.hourlyToday.length < 6; t++) {
            var ts = aqpStr(times[t]);
            if (ts.substring(0, 10) !== todayDate) continue;
            var hh = ts.substring(11, 13);
            if (AQP_DAY_HOURS.indexOf(hh) === -1) continue;
            var pm25v = aqpNum(pm25arr[t]);
            var pm10v = aqpNum(pm10arr[t]);
            out.hourlyToday.push({
                hh: hh,
                pm25Band: pm25v !== null ? AirGuidelines.bandForValue('pm2_5', pm25v) : null,
                pm10Band: pm10v !== null ? AirGuidelines.bandForValue('pm10', pm10v) : null,
            });
        }
    }

    return out;
};

/* ────────────────────────────────────────────────────────────────────────────
   Display constants
   ──────────────────────────────────────────────────────────────────────────── */
var AQP_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 AQP_BAND_ICON = { good: 'eco', fair: 'info', poor: 'warning', very_poor: 'error' };
var AQP_BAND_HEX  = { good: '#0fbd80', fair: '#d4a373', poor: '#D4836B', very_poor: '#c1440e' };
var AQP_SPECIES_SHORT = {
    pm2_5: 'PM2.5', pm10: 'PM10', o3: 'Ozone', no2: 'NO2', so2: 'SO2', co: 'CO', hcho: 'Formaldehyde'
};
var AQP_VENT_STYLE = {
    OPEN:    { icon: 'check_circle', hex: '#0fbd80' },
    CAUTION: { icon: 'warning',      hex: '#d4a373' },
    CLOSED:  { icon: 'error',        hex: '#D4836B' },
};
var AQP_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';
};

/* ────────────────────────────────────────────────────────────────────────────
   Panel
   ──────────────────────────────────────────────────────────────────────────── */
var AirQualityPanel = function (props) {
    var opts = props || {};
    var route = opts.route || '/air-quality';
    var navigate = ReactRouterDOM.useNavigate();

    // 'loading' | 'ready' | 'empty'
    var statusState = _useState_AQP('loading');
    var status = statusState[0];
    var setStatus = statusState[1];

    var dataState = _useState_AQP(null); // aqpMap() output + .source ('live'|'cached'|'sample')
    var data = dataState[0];
    var setData = dataState[1];

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

    var rhState = _useState_AQP(null); // today's mean RH, from WeatherCache, best-effort
    var todayRH = rhState[0];
    var setTodayRH = rhState[1];

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

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

    _useEffect_AQP(function () {
        var cancelled = false;
        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; };
        var clearTimers = function () {
            for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
            timers.length = 0;
        };

        // Fast path: a recent in-memory reading paints immediately, no network, no
        // skeleton — identical to WeatherPanel's _wxCache short-circuit.
        var fresh = _aqCache.data && (Date.now() - _aqCache.at) < AQ_CACHE_TTL_MS;
        if (fresh && attempt === 0) {
            setData(_aqCache.data);
            if (_aqCache.place) setPlace(_aqCache.place);
            setStatus('ready');
            return function () { cancelled = true; };
        }

        setStatus('loading');

        // GUARD 1 (ported verbatim from WeatherPanel): a background refresh failure must
        // never regress a reading that is already painted from cache.
        var cachePainted = false;
        // GUARD 2 (ported verbatim): a late IndexedDB read must never overpaint fresher
        // data that already landed from the network.
        var networkLanded = false;

        if (attempt === 0 && typeof AirCache !== 'undefined' && AirCache.load) {
            AirCache.load().then(function (rec) {
                if (cancelled || !rec || networkLanded) return;
                var mapped;
                try { mapped = aqpMap(rec.raw); } catch (e) { mapped = { ok: false }; }
                if (mapped.ok) {
                    cachePainted = true;
                    mapped.source = 'cached';
                    _aqCache = { at: Date.now(), data: mapped, place: rec.place || '' };
                    setData(mapped);
                    if (rec.place) setPlace(rec.place);
                    setStatus('ready');
                }
            })['catch'](function () { /* a broken cache read must never block the network path below */ });
        }

        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 (value) { if (settled) return; settled = true; resolve(value); };
                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 (!cancelled) setDenied(true);
                        finish(null);
                    });
            });
        };

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

        resolveCoords()
            .then(function (coords) {
                if (cancelled) return null;

                if (coords && window.GeoLocationService && typeof GeoLocationService.reverseGeocode === 'function') {
                    GeoLocationService.reverseGeocode(coords.lat, coords.lon)
                        .then(function (geo) {
                            if (!cancelled && geo && geo.displayName) {
                                // Local capture as well as state: the fetch success handler
                                // below reads `place` from a closure created BEFORE this
                                // geocode resolves, so the state variable there is stale.
                                resolvedPlace = geo.displayName;
                                setPlace(geo.displayName);
                            }
                        })
                        .catch(function () { /* a nameless location is fine */ });
                }

                var at = coords || fallback;
                lastCoords = at;

                // Opportunistic, non-blocking: today's mean RH feeds the ventilation
                // mini-strip. A missing or unreadable weather cache leaves todayRH null,
                // and AirGuidelines.ventStatus already fails safe (never OPEN) on that.
                if (typeof WeatherCache !== 'undefined' && WeatherCache.load) {
                    WeatherCache.load().then(function (rec) {
                        if (cancelled || !rec) return;
                        // LOCAL date, never toISOString(): that yields the UTC date, which
                        // in AEST is *yesterday* until 10am — the strip would score today's
                        // cells against the wrong day's humidity every morning.
                        var d = new Date();
                        var todayStr = d.getFullYear() + '-' +
                            ('0' + (d.getMonth() + 1)).slice(-2) + '-' +
                            ('0' + d.getDate()).slice(-2);
                        var rh = WeatherCache.dayMeanRH(rec.raw, todayStr);
                        if (rh !== null) setTodayRH(rh);
                    })['catch'](function () {});
                }

                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 (cancelled || !res) return null;
                if (!res.ok) throw new Error('Air service returned ' + res.status);
                return res.json();
            })
            .then(function (raw) {
                if (cancelled || raw === null) return;
                var mapped;
                try { mapped = aqpMap(raw); } catch (e) { mapped = { ok: false }; }
                if (mapped.ok) {
                    mapped.source = 'live';
                    networkLanded = true;
                    _aqCache = { at: Date.now(), data: mapped, place: resolvedPlace || place || '' };
                    setData(mapped);
                    setStatus('ready');
                    // Live data only — a sample fixture must never enter the cache.
                    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 (err) {
                if (cancelled || networkLanded) return;
                var aborted = err && err.name === 'AbortError';
                // A cached reading is already on screen — a failed refresh is not news.
                if (cachePainted) return;

                // First-ever-load rung: no valid cache, live fetch failed (or timed out) —
                // fall through to the bundled sample fixture, clearly labelled.
                if (typeof AirCache !== 'undefined' && AirCache.loadSample) {
                    AirCache.loadSample().then(function (sample) {
                        if (cancelled || networkLanded || cachePainted) return;
                        var mapped;
                        try { mapped = aqpMap(sample); } catch (e) { mapped = { ok: false }; }
                        if (mapped.ok) {
                            mapped.source = 'sample';
                            // Deliberately NOT written to _aqCache/AirCache — sample data
                            // must never be persisted or mistaken for a real reading on
                            // the next mount.
                            setData(mapped);
                            setStatus('ready');
                        } else {
                            setStatus('empty');
                        }
                    })['catch'](function () { if (!cancelled) setStatus('empty'); });
                    return;
                }
                setStatus('empty');
                void aborted;
            });

        return function () {
            cancelled = true;
            clearTimers();
            if (controller) controller.abort();
        };
    }, [attempt]);

    var retry = function (e) {
        if (e) { e.stopPropagation(); e.preventDefault(); }
        setDenied(false);
        setAttempt(attempt + 1);
    };

    var openDetail = function () { navigate(route); };
    var onKeyDown = function (e) {
        if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
            e.preventDefault();
            openDetail();
        }
    };

    // Ventilation cells depend on todayRH, which can land a tick after the air data
    // itself — memoised so a late RH update recomputes without re-running the mapper.
    var ventCells = _useMemo_AQP(function () {
        if (!data || !data.hourlyToday) return [];
        return data.hourlyToday.map(function (h) {
            return {
                hh: h.hh,
                status: AirGuidelines.ventStatus({ pm25Band: h.pm25Band, pm10Band: h.pm10Band, rhMean: todayRH }),
            };
        });
    }, [data, todayRH]);

    // Reserved height for every state, measured against the loaded card (measured content
    // bottom ~283px at 430px width: header + verdict + 6-cell vent strip + footer) so a
    // state swap never shifts the grid below it — same discipline as WeatherPanel's
    // CARD_MIN_H. If the content grows past this, re-measure and raise it.
    var CARD_MIN_H = 'min-h-[300px]';

    var SHELL = 'col-span-2 bio-bg bio-bg-20 p-6 rounded-xl shadow-soft border border-stone-100/50 btn-nature ' +
                'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
                'focus-visible:ring-offset-background-light';

    // ── Loading skeleton ─────────────────────────────────────────────────────
    if (status === 'loading') {
        return (
            <div className={SHELL + ' ' + CARD_MIN_H} aria-busy="true" aria-live="polite">
                <span className="sr-only">Loading air quality</span>
                <div className="animate-pulse motion-reduce:animate-none">
                    <div className="h-3 w-28 rounded-full bg-sage/30 mb-4"></div>
                    <div className="h-6 w-3/4 rounded-lg bg-sage/30 mb-2"></div>
                    <div className="h-4 w-1/2 rounded-full bg-sage/20 mb-4"></div>
                    <div className="h-16 w-full rounded-2xl bg-sage/15 mb-3"></div>
                    <div className="h-10 w-full rounded-2xl bg-sage/10"></div>
                </div>
            </div>
        );
    }

    // ── Empty / unavailable ──────────────────────────────────────────────────
    if (status === 'empty' || !data) {
        return (
            <div className={SHELL} aria-live="polite">
                <div className="flex items-center gap-4">
                    <div className="w-16 h-16 rounded-2xl bg-background-light flex items-center justify-center shrink-0">
                        <span className="material-symbols-outlined text-sage text-3xl">cloud_off</span>
                    </div>
                    <div className="min-w-0 flex-1">
                        <h4 className="font-extrabold text-sm text-forest">Air Quality</h4>
                        <p className="text-[11px] text-forest/70 leading-snug mt-0.5">
                            {denied
                                ? 'Air quality needs your location to know which forecast to show. You can turn it on any time.'
                                : 'Conditions aren’t available right now. Air data will appear here once the service responds.'}
                        </p>
                    </div>
                </div>
                <button
                    type="button"
                    onClick={retry}
                    className="mt-4 w-full bg-primary text-white font-extrabold py-2.5 rounded-xl shadow-clay flex items-center justify-center gap-2 btn-nature hover:brightness-110 transition-all text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-forest"
                >
                    <span className="material-symbols-outlined text-base">{denied ? 'my_location' : 'refresh'}</span>
                    {denied ? 'Use my location' : 'Try again'}
                </button>
            </div>
        );
    }

    // ── Ready ────────────────────────────────────────────────────────────────
    var band = data.worstBand || 'good';
    var verdict = AQP_VERDICT[band] || AQP_VERDICT.good;
    var bandHex = AQP_BAND_HEX[band] || AQP_BAND_HEX.good;
    var bandIcon = AQP_BAND_ICON[band] || AQP_BAND_ICON.good;
    var showChip = data.worstSpecies && band !== 'good';

    var ageText = aqpFormatAge(data.ageHours);
    // The card flips to solid forest on hover (btn-nature group), so badges with solid
    // light fills need group-hover variants — a translucent tint over the dark ground,
    // matching the worst-species chip's treatment. `live` is translucent already.
    var badge = data.source === 'sample'
        ? { symbol: '◐', text: 'sample', cls: 'text-warning bg-warning-light group-hover:bg-warning/15 group-hover:text-warning' }
        : data.source === 'cached'
            ? { symbol: '', text: 'cached', cls: 'text-sage bg-background-light group-hover:bg-white/10 group-hover:text-accent/80' }
            : { symbol: '●', text: 'live', cls: 'text-primary bg-primary/10' };

    var summaryLabel = 'Air quality' + (place ? ' for ' + place : '') + ': ' + verdict + '. ' +
        (ageText ? 'Updated ' + ageText + '. ' : '') +
        (data.source === 'sample' ? 'Showing sample data. ' : '') +
        'Open the air quality detail.';

    return (
        <div
            role="button"
            tabIndex={0}
            onClick={openDetail}
            onKeyDown={onKeyDown}
            aria-label={summaryLabel}
            className={SHELL + ' ' + CARD_MIN_H + ' cursor-pointer group hover:bg-forest transition-all duration-300'}
        >
            {/* Header */}
            <div className="flex items-start justify-between gap-3 mb-4">
                <div className="min-w-0">
                    <div className="flex items-center gap-1.5">
                        <span className="material-symbols-outlined text-primary text-base" aria-hidden="true">air</span>
                        <p className="text-[10px] font-black text-sage uppercase tracking-[0.15em] group-hover:text-accent/60">Air Quality</p>
                    </div>
                    <p className="text-[10px] font-bold text-muted uppercase tracking-wider group-hover:text-accent/60 truncate mt-0.5">
                        {place || 'Conditions near you'}
                    </p>
                </div>
                <span className="material-symbols-outlined text-muted group-hover:text-primary text-base shrink-0 transition-colors" aria-hidden="true">
                    arrow_forward
                </span>
            </div>

            {/* Headline verdict */}
            <div className="flex items-start gap-2.5">
                <span className="material-symbols-outlined text-lg shrink-0 mt-0.5" style={{ color: bandHex }} aria-hidden="true">
                    {bandIcon}
                </span>
                <div className="min-w-0">
                    <p className="text-sm font-extrabold text-forest group-hover:text-white leading-snug">{verdict}</p>
                    {showChip ? (
                        <span
                            className="inline-flex items-center gap-1 mt-1.5 px-2 py-0.5 rounded-full text-[9.5px] font-black uppercase tracking-wide"
                            style={{ color: bandHex, backgroundColor: bandHex + '1a' }}
                        >
                            {AQP_SPECIES_SHORT[data.worstSpecies] || data.worstSpecies} elevated
                        </span>
                    ) : null}
                </div>
            </div>

            {/* Today ventilation mini-strip */}
            {ventCells.length ? (
                <div className="mt-4 grid grid-cols-6 gap-1">
                    {ventCells.map(function (c) {
                        var s = AQP_VENT_STYLE[c.status] || AQP_VENT_STYLE.CAUTION;
                        return (
                            <div key={c.hh} className="flex flex-col items-center gap-0.5 rounded-lg bg-background-light/70 group-hover:bg-white/5 py-1.5 px-0.5 min-w-0">
                                <span className="text-[8px] font-black text-muted group-hover:text-accent/60 uppercase">{AQP_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-[7px] font-black uppercase tracking-wide" style={{ color: s.hex }}>{c.status}</span>
                            </div>
                        );
                    })}
                </div>
            ) : null}

            {/* Footer: freshness + provenance + attribution */}
            <div className="mt-4 pt-3 border-t border-stone-200/60 group-hover:border-white/10">
                <div className="flex items-center justify-between gap-2">
                    <p className="text-[10px] font-bold text-muted group-hover:text-accent/60 flex items-center gap-1 shrink-0">
                        <span className="material-symbols-outlined text-[12px] leading-none" aria-hidden="true">schedule</span>
                        {ageText ? 'Updated ' + ageText : 'Just now'}
                    </p>
                    <span className={'text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 ' + badge.cls}>
                        {badge.symbol ? badge.symbol + ' ' : ''}{badge.text}
                    </span>
                </div>
                {data.attribution ? (
                    <p
                        className="text-[9px] text-muted group-hover:text-accent/50 leading-snug mt-1.5 truncate"
                        title={data.attribution}
                    >
                        {data.attribution}
                    </p>
                ) : null}
            </div>
        </div>
    );
};

window.AirQualityPanel = AirQualityPanel;
