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.
|
// 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 +
|
||||||
|
"¤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.
|
// 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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user