Add live air-quality (US EPA AQI) chip and forecast-card row

Reuses the saved weather location's lat/lon to fetch us_aqi from
Open-Meteo's air-quality API (no key), folded into the existing 2h
forecast cache (bumped v1->v2). AQI is best-effort: a failed fetch
never sinks the forecast. Shows as a header chip and a colored row
on the forecast card; both self-hide when there is no reading.
This commit is contained in:
prosolis
2026-06-22 01:14:16 -07:00
parent 4bdf9a7615
commit aaf6e551a0
2 changed files with 61 additions and 3 deletions

View File

@@ -9,7 +9,7 @@
// pages) doesn't re-hit Open-Meteo. // pages) doesn't re-hit Open-Meteo.
(function () { (function () {
var LOC_KEY = "pete.weather.loc.v1"; // {country, postal, lat, lon, name, unit} 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 var TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
// ---- storage helpers ----------------------------------------------------- // ---- storage helpers -----------------------------------------------------
@@ -62,6 +62,18 @@
return r("clouds", "medium", "—", "☁️", "☁️"); 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 ----------------------------------------------------------- // ---- API calls -----------------------------------------------------------
function geocode(country, postal) { function geocode(country, postal) {
var url = "https://api.zippopotam.us/" + encodeURIComponent(country.toLowerCase()) + 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 +
"&current=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. // Cache key ties a forecast to one location; changing location invalidates it.
function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); } function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); }
@@ -103,13 +127,18 @@
var fresh = cached && cached.key === key && cached.fetchedAt && var fresh = cached && cached.key === key && cached.fetchedAt &&
(nowMs() - cached.fetchedAt) < TTL_MS; (nowMs() - cached.fetchedAt) < TTL_MS;
if (fresh && !force) return Promise.resolve(cached); 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 = { var entry = {
key: key, key: key,
fetchedAt: nowMs(), fetchedAt: nowMs(),
current: data.current, current: data.current,
daily: data.daily, 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); writeJSON(CACHE_KEY, entry);
return entry; return entry;
@@ -125,6 +154,7 @@
// ---- rendering ----------------------------------------------------------- // ---- rendering -----------------------------------------------------------
var chipEl = document.querySelector("[data-weather-chip]"); var chipEl = document.querySelector("[data-weather-chip]");
var chipBtn = document.querySelector("[data-weather-loc]"); var chipBtn = document.querySelector("[data-weather-loc]");
var aqiChipEl = document.querySelector("[data-aqi-chip]");
var cardMount = document.querySelector("[data-weather-card]"); var cardMount = document.querySelector("[data-weather-card]");
function setChip(html, title) { function setChip(html, title) {
@@ -132,6 +162,20 @@
if (chipBtn && title) chipBtn.title = title; 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 =
'<span class="inline-block w-2.5 h-2.5 rounded-full" style="background:' + air.color + '"></span>' +
'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 temp(v, sym) { return Math.round(v) + (sym || "°"); }
function weekday(iso) { function weekday(iso) {
@@ -158,8 +202,17 @@
setChip(desc.emoji + " <span class=\"tabular-nums\">" + temp(cur.temperature_2m, sym) + "</span>", setChip(desc.emoji + " <span class=\"tabular-nums\">" + temp(cur.temperature_2m, sym) + "</span>",
loc.name + " · " + desc.label); loc.name + " · " + desc.label);
setAqiChip(entry.aqi);
if (!cardMount) return; if (!cardMount) return;
var air = describeAqi(entry.aqi);
var aqiRow = air
? '<div class="flex items-center gap-2 border-t border-[color:var(--ink)]/10 px-5 py-3 text-sm">' +
'<span class="inline-block w-2.5 h-2.5 rounded-full shrink-0" style="background:' + air.color + '"></span>' +
'<span class="font-bold tabular-nums">AQI ' + entry.aqi + '</span>' +
'<span class="text-[color:var(--ink)]/60">' + air.label + '</span>' +
'</div>'
: '';
var daily = entry.daily; var daily = entry.daily;
var days = ""; var days = "";
for (var i = 0; i < daily.time.length; i++) { for (var i = 0; i < daily.time.length; i++) {
@@ -188,6 +241,7 @@
'<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5 border-2 border-[color:var(--ink)]/10 transition">Change location</button>' + '<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5 border-2 border-[color:var(--ink)]/10 transition">Change location</button>' +
'</div>' + '</div>' +
'<div class="grid grid-cols-5 gap-1 border-t border-[color:var(--ink)]/10 bg-[color:var(--ink)]/[0.03] px-2 py-3">' + days + '</div>' + '<div class="grid grid-cols-5 gap-1 border-t border-[color:var(--ink)]/10 bg-[color:var(--ink)]/[0.03] px-2 py-3">' + days + '</div>' +
aqiRow +
'</div>'; '</div>';
// The injected "Change location" button needs the same handler. // The injected "Change location" button needs the same handler.
var changeBtn = cardMount.querySelector("[data-weather-loc]"); var changeBtn = cardMount.querySelector("[data-weather-loc]");
@@ -202,6 +256,7 @@
function showError(msg) { function showError(msg) {
setChip("📍 Weather", "Set your location"); setChip("📍 Weather", "Set your location");
setAqiChip(null);
if (cardMount && getLoc()) { if (cardMount && getLoc()) {
cardMount.innerHTML = cardMount.innerHTML =
'<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-5 text-center text-sm text-[color:var(--ink)]/60">' + '<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-5 text-center text-sm text-[color:var(--ink)]/60">' +
@@ -296,6 +351,7 @@
// Hand the background back to the server's seasonal default. // Hand the background back to the server's seasonal default.
if (window.PeteWeather) window.PeteWeather.set(seasonalDefault.variant, seasonalDefault.intensity); if (window.PeteWeather) window.PeteWeather.set(seasonalDefault.variant, seasonalDefault.intensity);
setChip("📍 Weather", "Set your location"); setChip("📍 Weather", "Set your location");
setAqiChip(null);
if (cardMount) cardMount.innerHTML = ""; if (cardMount) cardMount.innerHTML = "";
}); });
dlg.querySelectorAll("[data-loc-unit]").forEach(function (b) { dlg.querySelectorAll("[data-loc-unit]").forEach(function (b) {

View File

@@ -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"> 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">
<span data-weather-chip class="tabular-nums">📍 Weather</span> <span data-weather-chip class="tabular-nums">📍 Weather</span>
</button> </button>
<span data-aqi-chip
class="hidden 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 tabular-nums"></span>
{{if .AuthEnabled}} {{if .AuthEnabled}}
{{if .User}} {{if .User}}
<a href="/auth/logout" data-account <a href="/auth/logout" data-account