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:
@@ -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 =
|
||||
'<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 weekday(iso) {
|
||||
@@ -158,8 +202,17 @@
|
||||
|
||||
setChip(desc.emoji + " <span class=\"tabular-nums\">" + temp(cur.temperature_2m, sym) + "</span>",
|
||||
loc.name + " · " + desc.label);
|
||||
setAqiChip(entry.aqi);
|
||||
|
||||
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 days = "";
|
||||
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>' +
|
||||
'</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>';
|
||||
// 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 =
|
||||
'<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.
|
||||
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) {
|
||||
|
||||
@@ -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">
|
||||
<span data-weather-chip class="tabular-nums">📍 Weather</span>
|
||||
</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 .User}}
|
||||
<a href="/auth/logout" data-account
|
||||
|
||||
Reference in New Issue
Block a user