diff --git a/internal/web/static/js/weather-forecast.js b/internal/web/static/js/weather-forecast.js index 59ffbd3..7c72419 100644 --- a/internal/web/static/js/weather-forecast.js +++ b/internal/web/static/js/weather-forecast.js @@ -9,7 +9,7 @@ // pages) doesn't re-hit Open-Meteo. (function () { var LOC_KEY = "pete.weather.loc.v1"; // {country, postal, lat, lon, name, unit} - var CACHE_KEY = "pete.weather.cache.v1"; // {key, fetchedAt, current, daily} + var CACHE_KEY = "pete.weather.cache.v2"; // {key, fetchedAt, current, daily, aqi} var TTL_MS = 2 * 60 * 60 * 1000; // 2 hours // ---- storage helpers ----------------------------------------------------- @@ -62,6 +62,18 @@ return r("clouds", "medium", "—", "☁️", "☁️"); } + // ---- US EPA AQI → label + color ----------------------------------------- + // https://www.airnow.gov/aqi/aqi-basics/ — standard six-band scale. + function describeAqi(v) { + if (v == null) return null; + if (v <= 50) return { label: "Good", color: "#16a34a" }; + if (v <= 100) return { label: "Moderate", color: "#ca8a04" }; + if (v <= 150) return { label: "Unhealthy for sensitive", color: "#ea580c" }; + if (v <= 200) return { label: "Unhealthy", color: "#dc2626" }; + if (v <= 300) return { label: "Very unhealthy", color: "#9333ea" }; + return { label: "Hazardous", color: "#7f1d1d" }; + } + // ---- API calls ----------------------------------------------------------- function geocode(country, postal) { var url = "https://api.zippopotam.us/" + encodeURIComponent(country.toLowerCase()) + @@ -94,6 +106,18 @@ }); } + // Air quality rides on the same lat/lon. Separate Open-Meteo host, no key, + // returns a pre-computed US EPA AQI so we don't reimplement the breakpoints. + function fetchAqi(loc) { + var url = "https://air-quality-api.open-meteo.com/v1/air-quality" + + "?latitude=" + loc.lat + "&longitude=" + loc.lon + + "¤t=us_aqi&timezone=auto"; + return fetch(url).then(function (res) { + if (!res.ok) throw new Error("AQI unavailable"); + return res.json(); + }); + } + // Cache key ties a forecast to one location; changing location invalidates it. function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); } @@ -103,13 +127,18 @@ var fresh = cached && cached.key === key && cached.fetchedAt && (nowMs() - cached.fetchedAt) < TTL_MS; if (fresh && !force) return Promise.resolve(cached); - return fetchForecast(loc).then(function (data) { + return Promise.all([ + fetchForecast(loc), + fetchAqi(loc).catch(function () { return null; }) // AQI is best-effort + ]).then(function (results) { + var data = results[0], air = results[1]; var entry = { key: key, fetchedAt: nowMs(), current: data.current, daily: data.daily, - unitSymbol: (data.current_units && data.current_units.temperature_2m) || "°" + unitSymbol: (data.current_units && data.current_units.temperature_2m) || "°", + aqi: (air && air.current && typeof air.current.us_aqi === "number") ? air.current.us_aqi : null }; writeJSON(CACHE_KEY, entry); return entry; @@ -125,6 +154,7 @@ // ---- rendering ----------------------------------------------------------- var chipEl = document.querySelector("[data-weather-chip]"); var chipBtn = document.querySelector("[data-weather-loc]"); + var aqiChipEl = document.querySelector("[data-aqi-chip]"); var cardMount = document.querySelector("[data-weather-card]"); function setChip(html, title) { @@ -132,6 +162,20 @@ if (chipBtn && title) chipBtn.title = title; } + // The AQI chip hides itself when there's no reading (no location, or the + // air-quality fetch failed) so it never shows an empty pill. + function setAqiChip(aqi) { + if (!aqiChipEl) return; + var air = describeAqi(aqi); + if (!air) { aqiChipEl.classList.add("hidden"); aqiChipEl.classList.remove("inline-flex"); return; } + aqiChipEl.innerHTML = + '' + + 'AQI ' + aqi; + aqiChipEl.title = "Air quality · " + air.label; + aqiChipEl.classList.remove("hidden"); + aqiChipEl.classList.add("inline-flex"); + } + function temp(v, sym) { return Math.round(v) + (sym || "°"); } function weekday(iso) { @@ -158,8 +202,17 @@ setChip(desc.emoji + " " + temp(cur.temperature_2m, sym) + "", loc.name + " · " + desc.label); + setAqiChip(entry.aqi); if (!cardMount) return; + var air = describeAqi(entry.aqi); + var aqiRow = air + ? '
' + + '' + + 'AQI ' + entry.aqi + '' + + '' + air.label + '' + + '
' + : ''; var daily = entry.daily; var days = ""; for (var i = 0; i < daily.time.length; i++) { @@ -188,6 +241,7 @@ '' + '' + '
' + days + '
' + + aqiRow + ''; // The injected "Change location" button needs the same handler. var changeBtn = cardMount.querySelector("[data-weather-loc]"); @@ -202,6 +256,7 @@ function showError(msg) { setChip("📍 Weather", "Set your location"); + setAqiChip(null); if (cardMount && getLoc()) { cardMount.innerHTML = '
' + @@ -296,6 +351,7 @@ // Hand the background back to the server's seasonal default. if (window.PeteWeather) window.PeteWeather.set(seasonalDefault.variant, seasonalDefault.intensity); setChip("📍 Weather", "Set your location"); + setAqiChip(null); if (cardMount) cardMount.innerHTML = ""; }); dlg.querySelectorAll("[data-loc-unit]").forEach(function (b) { diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 22242f0..1736794 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -65,6 +65,8 @@ class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition"> 📍 Weather + {{if .AuthEnabled}} {{if .User}}