Files
Pete/internal/web/static/js/weather-forecast.js
T
prosolis a15d67e210 web: the muted text that was never muted, and the stylesheet fetched every time
Every text-[color:var(--ink)]/50, border-[color:var(--ink)]/10 and
bg-[color:var(--ink)]/5 in the templates compiled to nothing. Tailwind v3
cannot put an opacity on an arbitrary var() colour, so all 538 of them were
dropped from output.css without a word. Meta text rendered at full ink, card
borders fell back to Tailwind's default grey (bright white rings at night),
and the washes, backdrops and hover pills simply weren't there, on the news
site, the casino and the adventure pages alike.

The palette is now written once per phase and casino room as bare rgb
channels (--ink-rgb and friends), the plain vars are built from them, and
tailwind.config.js exposes ink, muted, accent, warn and card as real colours.
The classes are text-ink/80, border-ink/10, bg-accent/20 now, and they compile.

Restoring the intended opacities as-is would have traded flat for illegible:
ink at 50% measures 2.7 to 2.9:1 on the lit phases. So secondary text gets
its own colour, --muted, per phase and per casino room, taken as faint as it
can go while still clearing 4.5:1 on both the card and the page. Everything
that was ink at 35 to 70% is text-muted. The 20-30% ones are separators and
empty-cell dashes and stay faint.

The bg-theme-* chips carry white text everywhere they appear, and eight of
them measured 2.5 to 3.2:1. Same hues, taken just dark enough to pass (4.6 to
4.7:1). eu, lego and adventure already passed and are unchanged, and the
underline and border colours keep the old pastels since they carry no text.

Static files came out of the embedded FS with no Last-Modified, no ETag and
no Cache-Control, so every page view downloaded the stylesheet and every
script in full, and the service worker's "cheap 304" never happened. Each
file is hashed once now. Templates link through {{asset}}, which appends
?v=<hash>; a request naming the current hash is served immutable for a year,
and everything else gets the hash as its ETag and revalidates to a 304. The
service worker serves versioned URLs cache-first, drops older versions of the
same file as new ones land, and moves to cache v6.
2026-09-22 22:54:50 -07:00

437 lines
21 KiB
JavaScript

// Live local weather. When the visitor saves a postal code we geocode it once
// (Zippopotam — free, no key), then poll Open-Meteo (free, no key) for the
// current conditions + a 5-day forecast. The result drives two bits of UI — a
// header chip and a home-page card — and overrides the seasonal canvas via
// PeteWeather.set(). Everything lives in the browser: no server calls, no keys.
//
// Politeness: the geocode never changes for a postal code, so it's cached
// forever. Forecasts are cached for 2 hours so a reload (or hopping between
// 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.v2"; // {key, fetchedAt, current, daily, aqi}
var TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
// ---- storage helpers -----------------------------------------------------
function readJSON(key) {
try { var r = localStorage.getItem(key); return r ? JSON.parse(r) : null; }
catch (e) { return null; }
}
function writeJSON(key, val) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
}
function getLoc() { return readJSON(LOC_KEY); }
function setLoc(loc) {
if (loc) writeJSON(LOC_KEY, loc);
else { try { localStorage.removeItem(LOC_KEY); } catch (e) {} }
if (window.PetePrefs) window.PetePrefs.push();
}
// ---- WMO weather code → canvas variant + label + emoji -------------------
// https://open-meteo.com/en/docs — `weather_code` is the WMO code.
function describe(code, isDay) {
var c = Number(code);
function r(variant, intensity, label, dayEmoji, nightEmoji) {
return { variant: variant, intensity: intensity, label: label,
emoji: (isDay === 0 && nightEmoji) ? nightEmoji : dayEmoji };
}
if (c === 0) return r("clear", "heavy", "Clear sky", "☀️", "🌙");
if (c === 1) return r("clouds", "light", "Mainly clear", "🌤️", "🌙");
if (c === 2) return r("clouds", "medium", "Partly cloudy", "⛅", "☁️");
if (c === 3) return r("clouds", "heavy", "Overcast", "☁️", "☁️");
if (c === 45 || c === 48) return r("fog", "heavy", "Fog", "🌫️", "🌫️");
if (c === 51) return r("rain", "light", "Light drizzle", "🌦️", "🌧️");
if (c === 53) return r("rain", "medium", "Drizzle", "🌦️", "🌧️");
if (c === 55) return r("rain", "heavy", "Heavy drizzle", "🌧️", "🌧️");
if (c === 56 || c === 57) return r("rain", "medium", "Freezing drizzle", "🌧️", "🌧️");
if (c === 61) return r("rain", "light", "Light rain", "🌦️", "🌧️");
if (c === 63) return r("rain", "medium", "Rain", "🌧️", "🌧️");
if (c === 65) return r("rain", "heavy", "Heavy rain", "🌧️", "🌧️");
if (c === 66 || c === 67) return r("rain", "medium", "Freezing rain", "🌧️", "🌧️");
if (c === 71) return r("snow", "light", "Light snow", "🌨️", "🌨️");
if (c === 73) return r("snow", "medium", "Snow", "🌨️", "🌨️");
if (c === 75) return r("snow", "heavy", "Heavy snow", "❄️", "❄️");
if (c === 77) return r("snow", "light", "Snow grains", "🌨️", "🌨️");
if (c === 80) return r("rain", "medium", "Light showers", "🌦️", "🌧️");
if (c === 81) return r("rain", "medium", "Showers", "🌧️", "🌧️");
if (c === 82) return r("rain", "heavy", "Heavy showers", "⛈️", "⛈️");
if (c === 85) return r("snow", "medium", "Snow showers", "🌨️", "🌨️");
if (c === 86) return r("snow", "heavy", "Heavy snow showers", "❄️", "❄️");
if (c === 95) return r("storm", "heavy", "Thunderstorm", "⛈️", "⛈️");
if (c === 96 || c === 99) return r("storm", "heavy", "Thunderstorm + hail", "⛈️", "⛈️");
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()) +
"/" + encodeURIComponent(postal);
return fetch(url).then(function (res) {
if (!res.ok) throw new Error("Unknown postal code");
return res.json();
}).then(function (data) {
var place = data.places && data.places[0];
if (!place) throw new Error("Unknown postal code");
return {
lat: parseFloat(place.latitude),
lon: parseFloat(place.longitude),
name: place["place name"] + (place["state abbreviation"] ? ", " + place["state abbreviation"] : "")
};
});
}
function fetchForecast(loc) {
var unit = loc.unit === "fahrenheit" ? "fahrenheit" : "celsius";
var url = "https://api.open-meteo.com/v1/forecast" +
"?latitude=" + loc.lat + "&longitude=" + loc.lon +
"&current=temperature_2m,weather_code,is_day" +
"&daily=weather_code,temperature_2m_max,temperature_2m_min" +
"&temperature_unit=" + unit +
"&timezone=auto&forecast_days=5";
return fetch(url).then(function (res) {
if (!res.ok) throw new Error("Forecast unavailable");
return res.json();
});
}
// 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.
function cacheKey(loc) { return loc.country + ":" + loc.postal + ":" + (loc.unit || "celsius"); }
function loadForecast(loc, force) {
var key = cacheKey(loc);
var cached = readJSON(CACHE_KEY);
var fresh = cached && cached.key === key && cached.fetchedAt &&
(nowMs() - cached.fetchedAt) < TTL_MS;
if (fresh && !force) return Promise.resolve(cached);
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) || "°",
aqi: (air && air.current && typeof air.current.us_aqi === "number") ? air.current.us_aqi : null
};
writeJSON(CACHE_KEY, entry);
return entry;
}).catch(function (err) {
// Stale-but-present beats nothing if the network blips.
if (cached && cached.key === key) return cached;
throw err;
});
}
function nowMs() { return new Date().getTime(); }
// ---- 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) {
if (chipEl) chipEl.innerHTML = html;
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) {
var d = new Date(iso + "T00:00:00");
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()];
}
// The /weather page is a variant preview — never let the live forecast
// hijack the canvas the user is inspecting.
var isDemoPage = location.pathname === "/weather";
function applyBackground(desc) {
if (isDemoPage) return;
if (window.PeteWeather && typeof window.PeteWeather.set === "function") {
window.PeteWeather.set(desc.variant, desc.intensity);
}
}
function renderForecast(loc, entry) {
var cur = entry.current;
var sym = entry.unitSymbol;
var desc = describe(cur.weather_code, cur.is_day);
applyBackground(desc);
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-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-muted">' + air.label + '</span>' +
'</div>'
: '';
var daily = entry.daily;
var days = "";
for (var i = 0; i < daily.time.length; i++) {
var dd = describe(daily.weather_code[i], 1);
days +=
'<div class="flex flex-col items-center gap-1 rounded-2xl px-2 py-2 min-w-0">' +
'<span class="text-xs font-semibold text-muted">' + (i === 0 ? "Today" : weekday(daily.time[i])) + '</span>' +
'<span class="text-2xl leading-none" aria-hidden="true">' + dd.emoji + '</span>' +
'<span class="text-sm font-bold tabular-nums">' + Math.round(daily.temperature_2m_max[i]) + '°</span>' +
'<span class="text-xs tabular-nums text-muted">' + Math.round(daily.temperature_2m_min[i]) + '°</span>' +
'</div>';
}
cardMount.innerHTML =
'<div class="rounded-3xl bg-card border-2 border-ink/10 shadow-pete overflow-hidden">' +
'<div class="flex flex-wrap items-center justify-between gap-4 p-5">' +
'<div class="flex items-center gap-4 min-w-0">' +
'<span class="text-5xl leading-none" aria-hidden="true">' + desc.emoji + '</span>' +
'<div class="min-w-0">' +
'<div class="flex items-baseline gap-2">' +
'<span class="font-display text-4xl font-bold tabular-nums">' + temp(cur.temperature_2m, sym) + '</span>' +
'<span class="text-sm font-semibold text-muted truncate">' + desc.label + '</span>' +
'</div>' +
'<div class="text-sm text-muted truncate">📍 ' + escapeHtml(loc.name) + '</div>' +
'</div>' +
'</div>' +
'<button type="button" data-weather-loc class="shrink-0 rounded-full px-3 py-1.5 text-xs font-semibold text-muted hover:bg-ink/5 border-2 border-ink/10 transition">Change location</button>' +
'</div>' +
'<div class="grid grid-cols-5 gap-1 border-t border-ink/10 bg-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]");
if (changeBtn) changeBtn.addEventListener("click", openPopover);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showError(msg) {
setChip("📍 Weather", "Set your location");
setAqiChip(null);
if (cardMount && getLoc()) {
cardMount.innerHTML =
'<div class="rounded-3xl bg-card border-2 border-dashed border-ink/15 p-5 text-center text-sm text-muted">' +
escapeHtml(msg) + ' · <button type="button" data-weather-loc class="font-semibold underline">try again</button>' +
'</div>';
var b = cardMount.querySelector("[data-weather-loc]");
if (b) b.addEventListener("click", openPopover);
}
}
// ---- location popover ----------------------------------------------------
// Built in JS so layout.html only needs the chip button. Class strings are
// full literals so Tailwind's content scan keeps them.
var popover = null;
var selectedUnit = "celsius"; // default for everyone; °F is opt-in
function setUnit(u) {
selectedUnit = u === "fahrenheit" ? "fahrenheit" : "celsius";
if (!popover) return;
popover.querySelectorAll("[data-loc-unit]").forEach(function (b) {
var on = b.getAttribute("data-loc-unit") === selectedUnit;
b.classList.toggle("bg-accent", on);
b.classList.toggle("text-white", on);
b.classList.toggle("text-muted", !on);
});
}
function defaultCountry() {
var lang = (navigator.language || "en-US");
var m = lang.match(/[-_]([A-Za-z]{2})$/);
return m ? m[1].toUpperCase() : "US";
}
function buildPopover() {
var dlg = document.createElement("div");
dlg.id = "pete-weather-loc";
dlg.className = "hidden fixed inset-0 z-50 bg-ink/40 backdrop-blur-sm flex items-start justify-center pt-[12vh] px-4";
dlg.setAttribute("role", "dialog");
dlg.setAttribute("aria-modal", "true");
dlg.innerHTML =
'<div class="w-full max-w-sm rounded-3xl bg-card shadow-pete-lg border-2 border-ink/10 overflow-hidden">' +
'<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-ink/10">' +
'<h2 class="font-display text-lg font-bold">Weather location</h2>' +
'<button type="button" data-loc-close class="rounded-full px-2 py-1 text-sm text-muted hover:bg-ink/5">esc</button>' +
'</div>' +
'<div class="p-5 space-y-3">' +
'<p class="text-xs text-muted">Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.</p>' +
'<div class="flex gap-2">' +
'<select data-loc-country class="rounded-2xl border-2 border-ink/10 bg-[color:var(--bg)] px-3 py-2 text-sm font-semibold outline-none focus:border-accent"></select>' +
'<input data-loc-postal type="text" inputmode="text" autocomplete="postal-code" placeholder="Postal code" class="flex-1 min-w-0 rounded-2xl border-2 border-ink/10 bg-[color:var(--bg)] px-3 py-2 text-sm outline-none focus:border-accent">' +
'</div>' +
'<div class="flex items-center justify-between gap-2">' +
'<span class="text-xs font-semibold text-muted">Units</span>' +
'<div class="inline-flex rounded-full border-2 border-ink/10 p-0.5 text-xs font-bold">' +
'<button type="button" data-loc-unit="celsius" class="rounded-full px-3 py-1 transition">°C</button>' +
'<button type="button" data-loc-unit="fahrenheit" class="rounded-full px-3 py-1 transition">°F</button>' +
'</div>' +
'</div>' +
'<p data-loc-error class="hidden text-xs font-semibold text-red-500"></p>' +
'<div class="flex items-center justify-between gap-2 pt-1">' +
'<button type="button" data-loc-clear class="rounded-full px-3 py-2 text-xs font-semibold text-muted hover:text-ink">Clear</button>' +
'<button type="button" data-loc-save class="rounded-full bg-accent px-5 py-2 text-sm font-bold text-white shadow-pete hover:brightness-105 transition disabled:opacity-50">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(dlg);
// A small, friendly country list (Zippopotam supports ~60). Common ones up
// top; the user can type any postal code that matches their selected country.
var countries = [
["US", "🇺🇸 United States"], ["PT", "🇵🇹 Portugal"], ["GB", "🇬🇧 United Kingdom"],
["ES", "🇪🇸 Spain"], ["FR", "🇫🇷 France"], ["DE", "🇩🇪 Germany"], ["IT", "🇮🇹 Italy"],
["NL", "🇳🇱 Netherlands"], ["BE", "🇧🇪 Belgium"], ["CH", "🇨🇭 Switzerland"],
["AT", "🇦🇹 Austria"], ["IE", "🇮🇪 Ireland"], ["DK", "🇩🇰 Denmark"],
["SE", "🇸🇪 Sweden"], ["PL", "🇵🇱 Poland"], ["CZ", "🇨🇿 Czechia"],
["CA", "🇨🇦 Canada"], ["AU", "🇦🇺 Australia"], ["NZ", "🇳🇿 New Zealand"],
["BR", "🇧🇷 Brazil"], ["MX", "🇲🇽 Mexico"], ["JP", "🇯🇵 Japan"], ["IN", "🇮🇳 India"]
];
var sel = dlg.querySelector("[data-loc-country]");
countries.forEach(function (c) {
var o = document.createElement("option");
o.value = c[0]; o.textContent = c[1];
sel.appendChild(o);
});
dlg.addEventListener("click", function (e) { if (e.target === dlg) closePopover(); });
dlg.querySelector("[data-loc-close]").addEventListener("click", closePopover);
dlg.querySelector("[data-loc-clear]").addEventListener("click", function () {
setLoc(null);
try { localStorage.removeItem(CACHE_KEY); } catch (e) {}
closePopover();
// 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) {
b.addEventListener("click", function () { setUnit(b.getAttribute("data-loc-unit")); });
});
dlg.querySelector("[data-loc-save]").addEventListener("click", onSave);
dlg.querySelector("[data-loc-postal]").addEventListener("keydown", function (e) {
if (e.key === "Enter") onSave();
});
return dlg;
}
function showLocError(msg) {
var el = popover.querySelector("[data-loc-error]");
if (!el) return;
if (msg) { el.textContent = msg; el.classList.remove("hidden"); }
else { el.classList.add("hidden"); }
}
function onSave() {
var country = popover.querySelector("[data-loc-country]").value;
var postal = popover.querySelector("[data-loc-postal]").value.trim();
var saveBtn = popover.querySelector("[data-loc-save]");
if (!postal) { showLocError("Enter a postal code."); return; }
showLocError("");
saveBtn.disabled = true; saveBtn.textContent = "…";
geocode(country, postal).then(function (geo) {
var loc = {
country: country, postal: postal,
lat: geo.lat, lon: geo.lon, name: geo.name,
unit: selectedUnit
};
setLoc(loc);
try { localStorage.removeItem(CACHE_KEY); } catch (e) {}
closePopover();
run(true);
}).catch(function (err) {
showLocError((err && err.message) || "Couldn't find that postal code.");
}).then(function () {
saveBtn.disabled = false; saveBtn.textContent = "Save";
});
}
function openPopover() {
if (!popover) popover = buildPopover();
var loc = getLoc();
popover.querySelector("[data-loc-country]").value = loc ? loc.country : defaultCountry();
popover.querySelector("[data-loc-postal]").value = loc ? loc.postal : "";
setUnit(loc && loc.unit ? loc.unit : "celsius");
showLocError("");
popover.classList.remove("hidden");
var inp = popover.querySelector("[data-loc-postal]");
if (inp) setTimeout(function () { inp.focus(); }, 30);
}
function closePopover() {
if (popover) popover.classList.add("hidden");
}
// ---- boot ----------------------------------------------------------------
// Remember the server's seasonal pick so "Clear" can restore it.
var seasonalDefault = {
variant: document.documentElement.dataset.weather || "",
intensity: document.documentElement.dataset.intensity || "heavy"
};
function run(force) {
var loc = getLoc();
if (!loc) { setChip("📍 Weather", "Set your location"); return; }
setChip("📍 " + escapeHtml(loc.name.split(",")[0]) + "…", "Loading forecast…");
loadForecast(loc, force)
.then(function (entry) { renderForecast(loc, entry); })
.catch(function () { showError("Forecast unavailable"); });
}
document.addEventListener("DOMContentLoaded", function () {
if (chipBtn) chipBtn.addEventListener("click", openPopover);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closePopover();
});
run(false);
});
})();