// 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 + "¤t=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 + "¤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"); } 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 = '' + '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 + " " + 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++) { var dd = describe(daily.weather_code[i], 1); days += '
' + '' + (i === 0 ? "Today" : weekday(daily.time[i])) + '' + '' + '' + Math.round(daily.temperature_2m_max[i]) + '°' + '' + Math.round(daily.temperature_2m_min[i]) + '°' + '
'; } cardMount.innerHTML = '
' + '
' + '
' + '' + '
' + '
' + '' + temp(cur.temperature_2m, sym) + '' + '' + desc.label + '' + '
' + '
📍 ' + escapeHtml(loc.name) + '
' + '
' + '
' + '' + '
' + '
' + days + '
' + aqiRow + '
'; // 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } function showError(msg) { setChip("📍 Weather", "Set your location"); setAqiChip(null); if (cardMount && getLoc()) { cardMount.innerHTML = '
' + escapeHtml(msg) + ' · ' + '
'; 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-[color:var(--accent)]", on); b.classList.toggle("text-white", on); b.classList.toggle("text-[color:var(--ink)]/60", !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-[color:var(--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 = '
' + '
' + '

Weather location

' + '' + '
' + '
' + '

Enter your postal code to see the local forecast. The background follows the weather. Saved in this browser only.

' + '
' + '' + '' + '
' + '
' + 'Units' + '
' + '' + '' + '
' + '
' + '' + '
' + '' + '' + '
' + '
' + '
'; 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); }); })();