diff --git a/.tmp/imgdiff/app.diff b/.tmp/imgdiff/app.diff
new file mode 100644
index 0000000..3e37c1b
Binary files /dev/null and b/.tmp/imgdiff/app.diff differ
diff --git a/.tmp/imgdiff/index.diff b/.tmp/imgdiff/index.diff
new file mode 100644
index 0000000..e8fdf3e
Binary files /dev/null and b/.tmp/imgdiff/index.diff differ
diff --git a/.tmp/imgdiff/new/app.js b/.tmp/imgdiff/new/app.js
new file mode 100644
index 0000000..1617dfb
--- /dev/null
+++ b/.tmp/imgdiff/new/app.js
@@ -0,0 +1,1435 @@
+'use strict';
+
+// ── WMO codes ────────────────────────────────────────────────────────────────
+const WMO = {
+ 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'},
+ 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'},
+ 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'},
+ 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'},
+ 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'},
+ 57:{desc:'Heavy freezing drizzle',icon:'🌨️'},
+ 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'},
+ 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'},
+ 67:{desc:'Heavy freezing rain',icon:'🌨️'},
+ 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'},
+ 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'},
+ 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'},
+ 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'},
+ 86:{desc:'Heavy snow showers',icon:'❄️'},
+ 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'},
+ 99:{desc:'Severe thunderstorm',icon:'⛈️'},
+};
+
+function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; }
+
+const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
+const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+const STORAGE_KEYS = {
+ lastCity: 'weather.lastCity',
+ savedCities: 'weather.savedCities',
+ units: 'weather.units',
+ autoDetect: 'weather.autoDetect',
+ showLightning: 'weather.showLightning',
+ showWind: 'weather.showWind',
+};
+
+const state = {
+ units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
+ autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
+ showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
+ showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false',
+ currentCity: null,
+ currentWeather: null,
+ currentAir: null,
+ currentSource: 'startup',
+ savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
+ alertIdsSeen: new Set(),
+ autoRefreshTimer: null,
+ lastRefreshAt: 0,
+ lightningMarkers: [],
+ windArrows: [],
+ showExtendedForecast: false,
+};
+
+// ── DOM ──────────────────────────────────────────────────────────────────────
+const searchInput = document.getElementById('search-input');
+const searchBtn = document.getElementById('search-btn');
+const locateBtn = document.getElementById('locate-btn');
+const suggestEl = document.getElementById('suggestions');
+const errorMsg = document.getElementById('error-msg');
+const loadingEl = document.getElementById('loading');
+const weatherDiv = document.getElementById('weather');
+const unitsBtn = document.getElementById('units-btn');
+const unitsBtnSettings = document.getElementById('units-btn-settings');
+const shareBtn = document.getElementById('share-btn');
+const saveCurrentBtn = document.getElementById('save-current-btn');
+const savedLocationsEl = document.getElementById('saved-locations');
+const notifyBtn = document.getElementById('notify-btn');
+const autoDetectToggle = document.getElementById('auto-detect-toggle');
+const alertsEmptyEl = document.getElementById('alerts-empty');
+const settingsLocationEl = document.getElementById('settings-location');
+const mapsLocationEl = document.getElementById('maps-location');
+const mapsWindEl = document.getElementById('maps-wind');
+const lightningToggle = document.getElementById('lightning-toggle');
+const windToggle = document.getElementById('wind-toggle');
+const dailyToggleBtn = document.getElementById('daily-toggle-btn');
+const dailyForecastLabel = document.getElementById('daily-forecast-label');
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+const show = el => el.classList.remove('hidden');
+const hide = el => el.classList.add('hidden');
+const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); };
+const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); };
+
+async function cleanupServiceWorkerInterceptors() {
+ try {
+ if ('serviceWorker' in navigator) {
+ const regs = await navigator.serviceWorker.getRegistrations();
+ await Promise.all(regs.map(reg => reg.unregister()));
+ }
+ if ('caches' in window) {
+ const keys = await caches.keys();
+ await Promise.all(keys.map(key => caches.delete(key)));
+ }
+ } catch (e) {
+ console.warn('Service worker/cache cleanup failed:', e?.message || e);
+ }
+}
+
+function compassDir(deg) {
+ return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8];
+}
+
+function compassDirFull(deg) {
+ return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8];
+}
+
+function formatHour(iso) {
+ const h = new Date(iso).getHours();
+ return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
+}
+
+function formatDayShort(iso) {
+ const d = new Date(iso + 'T00:00:00');
+ const today = new Date();
+ const tomorrow = new Date(today);
+ tomorrow.setDate(today.getDate() + 1);
+ if (d.toDateString() === today.toDateString()) return 'Today';
+ if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
+ return DAYS[d.getDay()];
+}
+
+function formatClock(iso) {
+ return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function tempDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ const n = state.units === 'us' ? v : ((v - 32) * 5) / 9;
+ return Math.round(n);
+}
+
+function speedDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ const n = state.units === 'us' ? v : v * 1.60934;
+ return Math.round(n);
+}
+
+function precipDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ const n = state.units === 'us' ? v : v * 25.4;
+ return n.toFixed(1);
+}
+
+function visDisplay(vMeters) {
+ if (vMeters == null || Number.isNaN(vMeters)) return '--';
+ const miles = vMeters / 1609;
+ if (state.units === 'us') return miles.toFixed(1);
+ return (miles * 1.60934).toFixed(1);
+}
+
+function tempUnit() { return state.units === 'us' ? 'F' : 'C'; }
+function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; }
+function visUnit() { return state.units === 'us' ? 'mi' : 'km'; }
+
+function updateUnitsButton() {
+ const label = state.units === 'us' ? 'US' : 'Metric';
+ unitsBtn.textContent = label;
+ if (unitsBtnSettings) unitsBtnSettings.textContent = label;
+}
+
+function syncAutoDetectToggle() {
+ autoDetectToggle.checked = state.autoDetect;
+}
+
+function updateNotifyButton() {
+ if (!('Notification' in window)) {
+ notifyBtn.textContent = 'Notifications unsupported';
+ notifyBtn.disabled = true;
+ return;
+ }
+ if (!window.isSecureContext) {
+ notifyBtn.textContent = 'Needs HTTPS';
+ notifyBtn.disabled = true;
+ return;
+ }
+
+ notifyBtn.disabled = false;
+ if (Notification.permission === 'granted') {
+ notifyBtn.textContent = 'Notifications enabled';
+ } else if (Notification.permission === 'denied') {
+ notifyBtn.textContent = 'Notifications blocked';
+ } else {
+ notifyBtn.textContent = 'Enable notifications';
+ }
+}
+
+function moonPhase(dateLike) {
+ const date = new Date(dateLike);
+ const synodic = 29.53058867;
+ const knownNewMoon = new Date('2000-01-06T18:14:00Z');
+ const days = (date - knownNewMoon) / 86400000;
+ const phase = ((days % synodic) + synodic) % synodic;
+ const index = Math.floor((phase / synodic) * 8) % 8;
+ return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index];
+}
+
+function precipType(code, tempF, rain, snowfall) {
+ if ((snowfall ?? 0) > 0) return 'Snow';
+ if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet';
+ if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain';
+ return 'Dry';
+}
+
+function calcDewpoint(tempF, rh) {
+ const tc = (tempF - 32) * 5 / 9;
+ const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc);
+ const dewC = (243.12 * alpha) / (17.62 - alpha);
+ return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC);
+}
+
+function humidityFeel(rh) {
+ if (rh < 25) return 'Very dry';
+ if (rh < 35) return 'Dry';
+ if (rh < 55) return 'Comfortable';
+ if (rh < 65) return 'Moderate';
+ if (rh < 75) return 'Humid';
+ return 'Very humid';
+}
+
+function renderRecommendations(data) {
+ const c = data.current;
+ const d = data.daily;
+ const temp = c.temperature_2m;
+ const code = c.weather_code;
+ const uv = d.uv_index_max[0] ?? 0;
+ const wind = c.wind_speed_10m;
+ const items = [];
+
+ if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' });
+ else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' });
+ else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' });
+
+ if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) {
+ items.push({ icon: '☂️', label: 'Umbrella' });
+ items.push({ icon: '🥾', label: 'Waterproof\nshoes' });
+ }
+ if ([71,73,75,77,85,86].includes(code)) {
+ items.push({ icon: '🥾', label: 'Snow boots' });
+ items.push({ icon: '🧤', label: 'Gloves' });
+ }
+ if (code >= 95) items.push({ icon: '⚡', label: 'Stay\nindoors' });
+ if (c.is_day && uv >= 6) {
+ items.push({ icon: '🕶️', label: 'Sunglasses' });
+ items.push({ icon: '🧴', label: 'Sunscreen' });
+ }
+ if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' });
+ items.push({ icon: '💧', label: 'Water bottle' });
+
+ const grid = document.getElementById('recommendations');
+ if (!grid) return;
+ grid.innerHTML = items.map(it =>
+ `
${it.icon}${it.label}
`
+ ).join('');
+}
+
+function apparentLabel(tempF, apparentF) {
+ if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill';
+ if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index';
+ return 'Feels like';
+}
+
+function cityKey(city) {
+ return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`;
+}
+
+function persistSavedCities() {
+ localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities));
+}
+
+function persistLastCity(city) {
+ localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city));
+}
+
+function getLastCity() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); }
+ catch { return null; }
+}
+
+function cityLabel(city) {
+ return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', ');
+}
+
+function updateShareUrl(city) {
+ if (!city) return;
+ const params = new URLSearchParams(window.location.search);
+ params.set('lat', city.latitude);
+ params.set('lon', city.longitude);
+ params.set('name', city.name || 'Location');
+ history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
+}
+
+function updateSourceLocation(city) {
+ const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--');
+ if (settingsLocationEl) settingsLocationEl.textContent = label;
+ if (mapsLocationEl) mapsLocationEl.textContent = label;
+}
+
+function renderSavedCities() {
+ savedLocationsEl.innerHTML = '';
+ if (!state.savedCities.length) {
+ savedLocationsEl.textContent = 'None yet';
+ return;
+ }
+
+ state.savedCities.forEach(city => {
+ const wrap = document.createElement('div');
+ wrap.className = 'saved-item';
+
+ const chip = document.createElement('button');
+ chip.className = 'saved-chip';
+ chip.type = 'button';
+ chip.textContent = city.name;
+ chip.addEventListener('click', () => loadAll(city, 'saved-location'));
+
+ const remove = document.createElement('button');
+ remove.className = 'saved-remove';
+ remove.type = 'button';
+ remove.textContent = 'x';
+ remove.addEventListener('click', (e) => {
+ e.stopPropagation();
+ state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city));
+ persistSavedCities();
+ renderSavedCities();
+ });
+
+ wrap.appendChild(chip);
+ wrap.appendChild(remove);
+ savedLocationsEl.appendChild(wrap);
+ });
+}
+
+// ── Tab switching ─────────────────────────────────────────────────────────────
+function activateTab(tabName) {
+ document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.tab-panel').forEach(p => hide(p));
+ document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
+ show(document.getElementById('tab-' + tabName));
+
+ if (tabName === 'conditions') {
+ setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60);
+ }
+ if (tabName === 'radar-full') {
+ setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50);
+ }
+ if (tabName === 'maps') {
+ setTimeout(refreshMapsOverview, 80);
+ }
+}
+
+document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
+ btn.addEventListener('click', () => activateTab(btn.dataset.tab));
+});
+
+document.getElementById('open-radar-tab').addEventListener('click', () => {
+ activateTab('radar-full');
+});
+
+// ── APIs ─────────────────────────────────────────────────────────────────────
+async function geocode(q) {
+ const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
+ if (!r.ok) {
+ const detail = await r.text().catch(() => '');
+ throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
+ }
+ return r.json();
+}
+
+async function reverseGeocode(lat, lon) {
+ const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
+ if (!r.ok) throw new Error('Reverse geocoding failed');
+ return r.json();
+}
+
+async function fetchJsonWithTimeout(url, timeoutMs) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ const response = await fetch(url, { cache: 'no-store', signal: controller.signal });
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '');
+ throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
+ }
+ return response.json();
+}
+
+async function fetchWeather(lat, lon, tz) {
+ const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`;
+
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ try {
+ return await fetchJsonWithTimeout(proxyUrl, 22000);
+ } catch (err) {
+ const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err);
+ if (attempt >= 3) {
+ throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`);
+ }
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
+ }
+ }
+}
+
+async function fetchAirQuality(lat, lon, tz) {
+ const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`;
+ console.log('Fetching air quality from:', url);
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`);
+ return r.json();
+}
+
+async function fetchAlerts(lat, lon) {
+ const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`);
+ if (!r.ok) throw new Error('Alerts fetch failed');
+ return r.json();
+}
+
+function detectedCityFallback(lat, lon) {
+ return {
+ name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
+ latitude: lat,
+ longitude: lon,
+ country: '',
+ admin1: '',
+ timezone: 'auto',
+ };
+}
+
+async function buildDetectedCity(lat, lon) {
+ const fallback = detectedCityFallback(lat, lon);
+
+ try {
+ const place = await reverseGeocode(lat, lon);
+ return {
+ ...fallback,
+ ...place,
+ latitude: lat,
+ longitude: lon,
+ timezone: place.timezone || 'auto',
+ };
+ } catch {
+ return fallback;
+ }
+}
+
+// ── Radar engine ──────────────────────────────────────────────────────────────
+function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
+ const FRAME_INTERVAL_MS = 650;
+ const FRAME_FADE_MS = 240;
+
+ const map = L.map(mapEl, {
+ center: [39.5, -98.35],
+ zoom: 4,
+ zoomControl: true,
+ attributionControl: false,
+ });
+
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ maxZoom: 19,
+ }).addTo(map);
+
+ let radarFrames = [];
+ let satelliteFrames = [];
+ let mode = 'radar';
+ let currentIdx = 0;
+ let layers = { radar: {}, satellite: {} };
+ let playing = false;
+ let animTimer = null;
+ let locationMarker = null;
+ let visibleLayer = null;
+ let framesReady = false;
+ let loadingFrames = false;
+
+ function activeFrames() {
+ return mode === 'radar' ? radarFrames : satelliteFrames;
+ }
+
+ function tsLabel(unixSec) {
+ const d = new Date(unixSec * 1000);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +
+ ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate();
+ }
+
+ function frameTileUrl(frame) {
+ if (mode === 'satellite') {
+ return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`;
+ }
+ return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`;
+ }
+
+ function showFrame(idx) {
+ const frames = activeFrames();
+ currentIdx = idx;
+ const frame = frames[idx];
+ if (!frame) return;
+
+ if (!layers[mode][frame.time]) {
+ layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, maxZoom: 15 });
+ }
+
+ Object.values(layers.radar).forEach(l => {
+ if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
+ });
+ Object.values(layers.satellite).forEach(l => {
+ if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
+ });
+
+ const nextLayer = layers[mode][frame.time];
+ if (!map.hasLayer(nextLayer)) {
+ nextLayer.setOpacity(0);
+ nextLayer.addTo(map);
+ }
+
+ nextLayer.setOpacity(0.78);
+
+ if (visibleLayer && visibleLayer !== nextLayer && map.hasLayer(visibleLayer)) {
+ visibleLayer.setOpacity(0.2);
+ const prevLayer = visibleLayer;
+ setTimeout(() => {
+ if (prevLayer !== visibleLayer && map.hasLayer(prevLayer)) map.removeLayer(prevLayer);
+ }, FRAME_FADE_MS);
+ }
+
+ visibleLayer = nextLayer;
+
+ tsEl.textContent = tsLabel(frame.time);
+ document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => {
+ el.classList.toggle('active', i === idx);
+ });
+ }
+
+ function buildTimeline() {
+ timelineEl.innerHTML = '';
+ activeFrames().forEach((f, i) => {
+ const tick = document.createElement('div');
+ tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : '');
+ tick.title = tsLabel(f.time);
+ tick.addEventListener('click', () => { stopPlay(); showFrame(i); });
+ timelineEl.appendChild(tick);
+ });
+ }
+
+ function step() {
+ const frames = activeFrames();
+ if (!frames.length) return;
+ const next = (currentIdx + 1) % frames.length;
+ showFrame(next);
+ if (next === 0) {
+ stopPlay();
+ setTimeout(startPlay, 800);
+ }
+ }
+
+ function startPlay() {
+ playing = true;
+ playBtn.textContent = '⏸';
+ animTimer = setInterval(step, FRAME_INTERVAL_MS);
+ }
+
+ function stopPlay() {
+ playing = false;
+ playBtn.textContent = '▶';
+ clearInterval(animTimer);
+ }
+
+ playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay());
+
+ if (layerBtn) {
+ layerBtn.addEventListener('click', () => {
+ if (!(radarFrames.length && satelliteFrames.length)) return;
+ mode = mode === 'radar' ? 'satellite' : 'radar';
+ layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
+ visibleLayer = null;
+ buildTimeline();
+ const idx = Math.min(currentIdx, activeFrames().length - 1);
+ showFrame(Math.max(idx, 0));
+ });
+ }
+
+ async function loadFrames() {
+ if (loadingFrames || framesReady) return;
+ loadingFrames = true;
+ let retries = 0;
+ const maxRetries = 2;
+
+ try {
+ while (retries < maxRetries) {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
+
+ const r = await fetch('/api/radar/times', { signal: controller.signal });
+ clearTimeout(timeoutId);
+
+ if (!r.ok) {
+ throw new Error(`Radar fetch returned ${r.status}`);
+ }
+ const data = await r.json();
+
+ radarFrames = [];
+ satelliteFrames = [];
+ (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
+ (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
+ (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
+
+ if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
+ if (!radarFrames.length && !satelliteFrames.length) {
+ console.warn('No radar or satellite frames available');
+ return;
+ }
+
+ if (layerBtn) {
+ layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
+ layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
+ }
+
+ buildTimeline();
+ const frames = activeFrames();
+ showFrame(Math.max(frames.length - 1, 0));
+ framesReady = true;
+ return;
+ } catch (e) {
+ retries++;
+ if (e.name === 'AbortError') {
+ console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`);
+ } else {
+ console.error(`Radar load attempt ${retries} failed:`, e.message);
+ }
+
+ if (retries >= maxRetries) {
+ console.error('Radar load failed after', maxRetries, 'attempts');
+ return;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+ } finally {
+ loadingFrames = false;
+ }
+ }
+
+ function panTo(lat, lon, zoom) {
+ map.setView([lat, lon], zoom || map.getZoom());
+ if (locationMarker) map.removeLayer(locationMarker);
+ locationMarker = L.circleMarker([lat, lon], {
+ radius: 6,
+ fillColor: '#e07848',
+ fillOpacity: 1,
+ color: '#fff',
+ weight: 2,
+ }).addTo(map);
+ }
+
+ function isMapVisible() {
+ return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0;
+ }
+
+ function refresh() {
+ if (!isMapVisible()) return;
+ map.invalidateSize();
+ loadFrames();
+ }
+
+ // Wait for visibility before first load to avoid zero-size Leaflet rendering.
+ setTimeout(() => {
+ let tries = 0;
+ const maxTries = 40; // ~10 seconds at 250ms interval
+ const timer = setInterval(() => {
+ tries++;
+ if (isMapVisible()) {
+ refresh();
+ clearInterval(timer);
+ } else if (tries >= maxTries) {
+ clearInterval(timer);
+ }
+ }, 250);
+ }, 100);
+
+ return { map, panTo, startPlay, stopPlay, refresh };
+}
+
+const radarEmbed = createRadar(
+ document.getElementById('radar-embed'),
+ document.getElementById('embed-timeline'),
+ document.getElementById('embed-play'),
+ document.getElementById('embed-timestamp'),
+ document.getElementById('embed-layer')
+);
+
+const radarFull = createRadar(
+ document.getElementById('radar-full'),
+ document.getElementById('full-timeline'),
+ document.getElementById('full-play'),
+ document.getElementById('full-timestamp'),
+ document.getElementById('full-layer')
+);
+
+function createOverviewMap(mapEl) {
+ const map = L.map(mapEl, {
+ center: [39.5, -98.35],
+ zoom: 4,
+ zoomControl: true,
+ attributionControl: false,
+ });
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ }).addTo(map);
+
+ let marker = null;
+
+ function panTo(lat, lon, zoom) {
+ map.setView([lat, lon], zoom || map.getZoom());
+ if (marker) map.removeLayer(marker);
+ marker = L.circleMarker([lat, lon], {
+ radius: 7,
+ fillColor: '#e07848',
+ fillOpacity: 1,
+ color: '#fff',
+ weight: 2,
+ }).addTo(map);
+ }
+
+ return { map, panTo };
+}
+
+function clearLightningMarkers() {
+ if (!mapsOverview.map) return;
+ state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker));
+ state.lightningMarkers = [];
+}
+
+function clearWindArrows() {
+ if (!mapsOverview.map) return;
+ state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow));
+ state.windArrows = [];
+}
+
+function refreshMapsOverview() {
+ if (!mapsOverview.map) return;
+
+ mapsOverview.map.invalidateSize();
+
+ if (!state.currentCity) return;
+
+ mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
+
+ clearLightningMarkers();
+ clearWindArrows();
+
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
+}
+
+function renderLightningStrikes(city) {
+ if (!mapsOverview.map || !state.showLightning) return;
+ clearLightningMarkers();
+
+ // Simulate lightning strikes in the vicinity of the city
+ // In production, this would fetch from a real lightning API like Blitzortung
+ const strokeCount = 5 + Math.random() * 3; // Random 5-8 strikes
+ const maxRadius = 0.35;
+
+ for (let i = 0; i < strokeCount; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const radius = Math.random() * maxRadius;
+ const latOffset = Math.cos(angle) * radius;
+ const lonOffset = Math.sin(angle) * radius;
+ const lat = parseFloat(city.latitude) + latOffset;
+ const lon = parseFloat(city.longitude) + lonOffset;
+
+ const marker = L.circleMarker([lat, lon], {
+ radius: 3 + Math.random() * 3,
+ fillColor: '#ffeb3b',
+ fillOpacity: 0.7 + Math.random() * 0.3,
+ color: '#ff6f00',
+ weight: 1.5,
+ className: 'lightning-marker',
+ }).bindPopup('⚡ Lightning strike (simulated)');
+
+ marker.addTo(mapsOverview.map);
+ state.lightningMarkers.push(marker);
+
+ // Animate the marker to fade out
+ setTimeout(() => {
+ if (marker && mapsOverview.map.hasLayer(marker)) {
+ const fade = setInterval(() => {
+ const opacity = parseFloat(marker.options.fillOpacity) - 0.05;
+ if (opacity <= 0) {
+ clearInterval(fade);
+ mapsOverview.map.removeLayer(marker);
+ } else {
+ marker.setStyle({ fillOpacity: opacity });
+ }
+ }, 200);
+ }
+ }, 3000);
+ }
+}
+
+function renderWindArrows(city) {
+ if (!mapsOverview.map || !state.showWind || !state.currentWeather) return;
+ clearWindArrows();
+
+ // Get current wind data
+ const current = state.currentWeather.current;
+ if (!current || current.wind_speed_10m == null) return;
+
+ const windSpeed = current.wind_speed_10m;
+ const windGust = current.wind_gusts_10m || 0;
+ const windDir = current.wind_direction_10m || 0;
+
+ // Update wind data display
+ document.getElementById('wind-speed').textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`;
+ document.getElementById('wind-gust').textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--';
+ document.getElementById('wind-direction').textContent = compassDirFull(windDir);
+
+ // Draw multiple wind barbs around the city center to show wind strength
+ const lat = parseFloat(city.latitude);
+ const lon = parseFloat(city.longitude);
+ const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind
+ const baseRadius = 0.15;
+
+ for (let i = 0; i < arrowCount; i++) {
+ const offset = (i - arrowCount / 2) * 0.08;
+ const offsetLat = lat + offset;
+ const offsetLon = lon;
+
+ // Calculate arrow end point based on wind direction
+ const radians = (windDir * Math.PI) / 180;
+ const arrowLength = 0.15 + i * 0.05;
+ const endLat = offsetLat + arrowLength * Math.cos(radians);
+ const endLon = offsetLon + arrowLength * Math.sin(radians);
+
+ // Draw arrow line with gradient color based on wind speed
+ const color = windSpeed > 20 ? '#ff4444' : windSpeed > 10 ? '#ffaa00' : '#4a90e2';
+ const weight = 1 + (i % 2);
+
+ const arrow = L.polyline(
+ [[offsetLat, offsetLon], [endLat, endLon]],
+ {
+ color: color,
+ weight: weight,
+ opacity: 0.7,
+ smooth: true,
+ }
+ ).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}
Direction: ${compassDirFull(windDir)}`);
+
+ arrow.addTo(mapsOverview.map);
+ state.windArrows.push(arrow);
+
+ // Draw arrow head
+ const headSize = 0.03;
+ const arrowHeadLat1 = endLat - headSize * Math.cos(radians + Math.PI * 0.15);
+ const arrowHeadLon1 = endLon - headSize * Math.sin(radians + Math.PI * 0.15);
+ const arrowHeadLat2 = endLat - headSize * Math.cos(radians - Math.PI * 0.15);
+ const arrowHeadLon2 = endLon - headSize * Math.sin(radians - Math.PI * 0.15);
+
+ const arrowHead = L.polyline(
+ [[arrowHeadLat1, arrowHeadLon1], [endLat, endLon], [arrowHeadLat2, arrowHeadLon2]],
+ {
+ color: color,
+ weight: weight,
+ opacity: 0.7,
+ }
+ );
+
+ arrowHead.addTo(mapsOverview.map);
+ state.windArrows.push(arrowHead);
+ }
+}
+
+const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
+
+// ── Rendering ────────────────────────────────────────────────────────────────
+function renderWeather(data, city) {
+ const c = data.current;
+ const h = data.hourly;
+ const d = data.daily;
+
+ document.getElementById('loc-name').textContent = city.name;
+ document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', ');
+
+ const now = new Date();
+ document.getElementById('loc-time').innerHTML =
+ `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` +
+ now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+ const w = wmo(c.weather_code);
+ document.getElementById('cur-icon').textContent = w.icon;
+ document.getElementById('cur-desc').textContent = w.desc;
+ document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m);
+ document.querySelector('.deg').textContent = `°${tempUnit()}`;
+ document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`;
+ document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]);
+ document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]);
+ document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation';
+ document.getElementById('cur-hum').textContent = c.relative_humidity_2m;
+ document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m);
+ document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m);
+ document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m);
+ document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : '—';
+ document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m);
+ document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
+ document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
+ document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
+ document.getElementById('cur-cloud').textContent = c.cloud_cover;
+ document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit());
+ document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit());
+
+ document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]);
+ document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
+ document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
+
+ // Sun timeline progress
+ const sunriseMs = new Date(d.sunrise[0]).getTime();
+ const sunsetMs = new Date(d.sunset[0]).getTime();
+ const nowMs = Date.now();
+ const totalMs = sunsetMs - sunriseMs;
+ const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100));
+ const progressEl = document.getElementById('sun-progress');
+ const dotEl = document.getElementById('sun-dot');
+ if (progressEl) progressEl.style.width = sunPct + '%';
+ if (dotEl) dotEl.style.left = sunPct + '%';
+ const daylightEl = document.getElementById('daylight-info');
+ if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`;
+
+ renderRecommendations(data);
+
+ const nowTs = Date.now();
+ let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs);
+ if (startIdx < 0) startIdx = 0;
+
+ const track = document.getElementById('hourly-track');
+ track.innerHTML = '';
+ for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) {
+ const hw = wmo(h.weather_code[i]);
+ const pp = h.precipitation_probability[i];
+ const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]);
+ const cell = document.createElement('div');
+ cell.className = 'hour-cell' + (i === startIdx ? ' now' : '');
+ cell.innerHTML = `
+ ${i === startIdx ? 'Now' : formatHour(h.time[i])}
+ ${hw.icon}
+ ${tempDisplay(h.temperature_2m[i])}°
+ ${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `;
+ track.appendChild(cell);
+ }
+
+ state.showExtendedForecast = false;
+ renderDailyForecast(d);
+
+ document.getElementById('updated-at').textContent =
+ new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+ radarEmbed.panTo(city.latitude, city.longitude, 6);
+ radarFull.panTo(city.latitude, city.longitude, 6);
+ mapsOverview.panTo(city.latitude, city.longitude, 6);
+ if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
+ updateSourceLocation(city);
+ updateShareUrl(city);
+ persistLastCity(city);
+ refreshMapsOverview();
+
+ show(weatherDiv);
+ setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
+}
+
+function renderDailyForecast(daily) {
+ const list = document.getElementById('daily-list');
+ if (!list || !daily?.time?.length) return;
+
+ const totalDays = daily.time.length;
+ const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
+
+ list.innerHTML = '';
+ for (let i = 0; i < maxDays; i++) {
+ const dw = wmo(daily.weather_code[i]);
+ const row = document.createElement('div');
+ row.className = 'day-row' + (i === 0 ? ' today' : '');
+ row.innerHTML = `
+ ${formatDayShort(daily.time[i])}
+ ${dw.icon}
+ ${dw.desc}
+ ${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+ ${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `;
+ list.appendChild(row);
+ }
+
+ if (dailyForecastLabel) {
+ dailyForecastLabel.textContent = state.showExtendedForecast ? '16-Day Forecast' : '7-Day Forecast';
+ }
+
+ if (dailyToggleBtn) {
+ if (totalDays > 7) {
+ show(dailyToggleBtn);
+ dailyToggleBtn.textContent = state.showExtendedForecast ? 'Show 7-day' : 'Show 16-day';
+ } else {
+ hide(dailyToggleBtn);
+ }
+ }
+}
+
+function renderAirQuality(data) {
+ const cur = data?.current || {};
+ const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen']
+ .map(k => cur[k] || 0)
+ .reduce((max, v) => Math.max(max, v), 0);
+
+ document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--';
+ document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--';
+ document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--';
+}
+
+function renderAlerts(data) {
+ const wrap = document.getElementById('alerts-wrap');
+ const list = document.getElementById('alerts-list');
+ const features = data?.features || [];
+ list.innerHTML = '';
+
+ if (!features.length) {
+ hide(wrap);
+ if (alertsEmptyEl) show(alertsEmptyEl);
+ return;
+ }
+
+ if (alertsEmptyEl) hide(alertsEmptyEl);
+
+ features.slice(0, 5).forEach(f => {
+ const p = f.properties || {};
+ const item = document.createElement('div');
+ item.className = 'alert-item';
+ item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`;
+ list.appendChild(item);
+ });
+ show(wrap);
+
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
+ features.forEach(f => {
+ const id = f.id || f.properties?.id;
+ if (!id || state.alertIdsSeen.has(id)) return;
+ state.alertIdsSeen.add(id);
+ const p = f.properties || {};
+ new Notification(`Weather alert: ${p.event || 'Severe weather'}`, {
+ body: p.headline || p.description || 'An alert was issued for your selected location.',
+ });
+ });
+ }
+}
+
+async function loadAll(city, source) {
+ state.currentCity = city;
+ if (source) state.currentSource = source;
+ clearErr();
+ hide(suggestEl);
+ hide(weatherDiv);
+ show(loadingEl);
+
+ try {
+ let weather;
+ try {
+ weather = await fetchWeather(city.latitude, city.longitude, city.timezone);
+ } catch (weatherErr) {
+ console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr);
+ if (state.currentWeather) {
+ weather = state.currentWeather;
+ showErr('Live weather update failed. Showing last available data.');
+ } else {
+ throw weatherErr;
+ }
+ }
+
+ const [airResult, alertsResult] = await Promise.allSettled([
+ fetchAirQuality(city.latitude, city.longitude, city.timezone),
+ fetchAlerts(city.latitude, city.longitude),
+ ]);
+
+ const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} };
+ if (airResult.status !== 'fulfilled') {
+ console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason);
+ }
+
+ const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] };
+ if (alertsResult.status !== 'fulfilled') {
+ console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason);
+ }
+
+ state.currentWeather = weather;
+ state.currentAir = air;
+ state.lastRefreshAt = Date.now();
+
+ // Keep weather load resilient: map/radar/render hiccups should not fail the whole page.
+ try {
+ renderWeather(weather, city);
+ } catch (renderErr) {
+ console.error('Weather render error:', renderErr);
+ show(weatherDiv);
+ }
+
+ try {
+ renderAirQuality(air);
+ } catch (airRenderErr) {
+ console.warn('Air quality render error:', airRenderErr);
+ }
+
+ try {
+ renderAlerts(alerts);
+ } catch (alertsRenderErr) {
+ console.warn('Alerts render error:', alertsRenderErr);
+ }
+
+ try {
+ if (state.showLightning) renderLightningStrikes(city);
+ if (state.showWind) renderWindArrows(city);
+ } catch (mapOverlayErr) {
+ console.warn('Map overlay render error:', mapOverlayErr);
+ }
+ } catch (e) {
+ showErr('Failed to load weather data. Please try again.');
+ console.error(e);
+ } finally {
+ hide(loadingEl);
+ }
+}
+
+function getPosition() {
+ return new Promise((resolve, reject) => {
+ if (!navigator.geolocation) {
+ reject(new Error('Geolocation not supported'));
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
+ enableHighAccuracy: false,
+ timeout: 8000,
+ maximumAge: 10 * 60 * 1000,
+ });
+ });
+}
+
+async function autoLocateAndLoad() {
+ try {
+ const pos = await getPosition();
+ const { latitude: lat, longitude: lon } = pos.coords;
+ const city = await buildDetectedCity(lat, lon);
+ searchInput.value = city.name;
+ await loadAll(city, 'auto-detect');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ── Search autocomplete ───────────────────────────────────────────────────────
+let debounceTimer = null;
+
+searchInput.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ const q = searchInput.value.trim();
+ if (q.length < 2) { hide(suggestEl); return; }
+
+ debounceTimer = setTimeout(async () => {
+ try {
+ const data = await geocode(q);
+ const results = data.results || [];
+ suggestEl.innerHTML = '';
+ if (!results.length) { hide(suggestEl); return; }
+
+ results.forEach(r => {
+ const li = document.createElement('li');
+ li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`;
+ li.addEventListener('click', () => {
+ searchInput.value = r.name;
+ hide(suggestEl);
+ loadAll(r, 'search-result');
+ });
+ suggestEl.appendChild(li);
+ });
+ show(suggestEl);
+ } catch (err) {
+ console.warn('Autocomplete geocode failed:', err?.message || err);
+ hide(suggestEl);
+ }
+ }, 300);
+});
+
+document.addEventListener('click', e => {
+ if (!e.target.closest('.search-wrap')) hide(suggestEl);
+});
+
+function doSearch() {
+ const q = searchInput.value.trim();
+ if (!q) { showErr('Please enter a city name.'); return; }
+ clearErr();
+ hide(suggestEl);
+
+ geocode(q).then(data => {
+ const results = data.results || [];
+ if (!results.length) { showErr(`No results found for "${q}".`); return; }
+ loadAll(results[0], 'search-result');
+ }).catch(() => showErr('Search failed. Please try again.'));
+}
+
+searchBtn.addEventListener('click', doSearch);
+searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
+
+// ── Geolocation ───────────────────────────────────────────────────────────────
+locateBtn.addEventListener('click', () => {
+ if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
+ clearErr();
+ hide(weatherDiv);
+ show(loadingEl);
+
+ navigator.geolocation.getCurrentPosition(async pos => {
+ const { latitude: lat, longitude: lon } = pos.coords;
+ try {
+ const city = await buildDetectedCity(lat, lon);
+ await loadAll(city, 'gps-button');
+ } catch {
+ showErr('Could not fetch weather for your location.');
+ } finally {
+ hide(loadingEl);
+ }
+ }, () => {
+ hide(loadingEl);
+ showErr('Location access denied.');
+ });
+});
+
+unitsBtn.addEventListener('click', () => {
+ state.units = state.units === 'us' ? 'metric' : 'us';
+ localStorage.setItem(STORAGE_KEYS.units, state.units);
+ updateUnitsButton();
+ if (state.currentWeather && state.currentCity) {
+ renderWeather(state.currentWeather, state.currentCity);
+ renderAirQuality(state.currentAir);
+ // Update maps overlays if they're visible
+ if (state.showWind && mapsOverview.map) {
+ clearWindArrows();
+ renderWindArrows(state.currentCity);
+ }
+ }
+});
+
+if (unitsBtnSettings) {
+ unitsBtnSettings.addEventListener('click', () => unitsBtn.click());
+}
+
+shareBtn.addEventListener('click', async () => {
+ if (!state.currentCity) return;
+ try {
+ await navigator.clipboard.writeText(window.location.href);
+ shareBtn.textContent = 'Copied link';
+ setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500);
+ } catch {
+ showErr('Could not copy URL to clipboard.');
+ }
+});
+
+saveCurrentBtn.addEventListener('click', () => {
+ if (!state.currentCity) return;
+ const key = cityKey(state.currentCity);
+ if (state.savedCities.some(c => cityKey(c) === key)) return;
+ state.savedCities.push(state.currentCity);
+ persistSavedCities();
+ renderSavedCities();
+});
+
+notifyBtn.addEventListener('click', async () => {
+ if (!('Notification' in window)) {
+ showErr('Notifications are not supported in this browser.');
+ return;
+ }
+ if (!window.isSecureContext) {
+ showErr('Notifications require HTTPS (or localhost).');
+ updateNotifyButton();
+ return;
+ }
+
+ try {
+ const permission = await Notification.requestPermission();
+ updateNotifyButton();
+ if (permission === 'granted') {
+ // Re-fetch current alerts immediately so permission change can trigger notifications.
+ if (state.currentCity) {
+ const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] }));
+ renderAlerts(alerts);
+ }
+ } else if (permission === 'denied') {
+ showErr('Notifications are blocked in browser settings for this site.');
+ }
+ } catch {
+ showErr('Could not request notification permission.');
+ updateNotifyButton();
+ }
+});
+
+autoDetectToggle.addEventListener('change', () => {
+ state.autoDetect = autoDetectToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect));
+});
+
+lightningToggle.addEventListener('change', () => {
+ state.showLightning = lightningToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
+ if (state.currentCity && mapsOverview.map) {
+ clearLightningMarkers();
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
+ }
+});
+
+windToggle.addEventListener('change', () => {
+ state.showWind = windToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
+ if (state.currentCity && mapsOverview.map) {
+ clearWindArrows();
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
+ }
+});
+
+// Maps tab quick toggles
+const mapsLightningToggle = document.getElementById('maps-lightning-toggle');
+const mapsWindToggle = document.getElementById('maps-wind-toggle');
+
+if (mapsLightningToggle) {
+ mapsLightningToggle.addEventListener('click', () => {
+ state.showLightning = !state.showLightning;
+ localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
+ mapsLightningToggle.classList.toggle('active', state.showLightning);
+ lightningToggle.checked = state.showLightning;
+ if (state.currentCity && mapsOverview.map) {
+ clearLightningMarkers();
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
+ }
+ });
+ // Set initial state
+ mapsLightningToggle.classList.toggle('active', state.showLightning);
+}
+
+if (mapsWindToggle) {
+ mapsWindToggle.addEventListener('click', () => {
+ state.showWind = !state.showWind;
+ localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
+ mapsWindToggle.classList.toggle('active', state.showWind);
+ windToggle.checked = state.showWind;
+ if (state.currentCity && mapsOverview.map) {
+ clearWindArrows();
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
+ }
+ });
+ // Set initial state
+ mapsWindToggle.classList.toggle('active', state.showWind);
+}
+
+if (dailyToggleBtn) {
+ dailyToggleBtn.addEventListener('click', () => {
+ state.showExtendedForecast = !state.showExtendedForecast;
+ if (state.currentWeather) {
+ renderDailyForecast(state.currentWeather.daily);
+ }
+ });
+}
+
+function startAutoRefresh() {
+ clearInterval(state.autoRefreshTimer);
+ state.autoRefreshTimer = setInterval(() => {
+ if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh');
+ }, 10 * 60 * 1000);
+
+ // Refresh shortly after users return to the tab if data is stale.
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState !== 'visible') return;
+ if (!state.currentCity) return;
+ if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) {
+ loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh');
+ }
+ });
+}
+
+// ── Default city / query link load ───────────────────────────────────────────
+(async () => {
+ await cleanupServiceWorkerInterceptors();
+ updateUnitsButton();
+ updateNotifyButton();
+ syncAutoDetectToggle();
+
+ // Sync lightning and wind toggle states
+ lightningToggle.checked = state.showLightning;
+ windToggle.checked = state.showWind;
+
+ renderSavedCities();
+ startAutoRefresh();
+
+ const qs = new URLSearchParams(window.location.search);
+ const qLat = Number(qs.get('lat'));
+ const qLon = Number(qs.get('lon'));
+ const qName = qs.get('name');
+ if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) {
+ const city = {
+ name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`,
+ latitude: qLat,
+ longitude: qLon,
+ timezone: 'auto',
+ country: '',
+ admin1: ''
+ };
+ searchInput.value = city.name;
+ await loadAll(city, 'shared-link');
+ return;
+ }
+
+ if (state.autoDetect) {
+ const autoLocated = await autoLocateAndLoad();
+ if (autoLocated) return;
+ }
+
+ const lastCity = getLastCity();
+ if (lastCity) {
+ searchInput.value = lastCity.name;
+ await loadAll(lastCity, 'last-city');
+ return;
+ }
+
+ try {
+ const data = await geocode('New York');
+ const city = (data.results || [])[0];
+ if (city) {
+ searchInput.value = city.name;
+ await loadAll(city, 'default-city');
+ }
+ } catch {
+ // silent startup fallback
+ }
+})();
diff --git a/.tmp/imgdiff/new/index.html b/.tmp/imgdiff/new/index.html
new file mode 100644
index 0000000..15d3ffb
--- /dev/null
+++ b/.tmp/imgdiff/new/index.html
@@ -0,0 +1,405 @@
+
+
+
+
+
+NeCloud Weather
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🌤️
+
Fetching weather data…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
--°F
+
Feels like --°F
+
+
+
+
+
+
+
+
+ 🌅
+ --
+
+
+
+ 🌇
+ --
+
+
+
+ 🌙
+ --
+ --
+
+
+
+
+
+ Wind & Atmosphere
+
+
+
+
+
+ More Details ›
+
+
+
+
+
+ What to Bring
+
+
+
+
+
+ Radar
+
+
+
+
+
+
+
+
+ Hourly Forecast
+ next 24 hours
+
+
+
+
+
+ 7-Day Forecast
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚡ Lightning Strikes
+
+
+ Recent strike location
+
+
+
+
💨 Wind Flow
+
+
+
Wind direction & speed
+
+
+
+
+
+
+
+
Wind Data
+
+
+ Speed
+ --
+
+
+ Gust
+ --
+
+
+ Direction
+ --
+
+
+
+
+
+
+
+
+
+
+
+ Weather Alerts
+
+
+
+
+
No active alerts for this location.
+
+
+
+
+
+
+
+ Saved locations
+
+
+
+
+
+
+ Auto-detect location on startup
+
+
+
+
+ Data source location
+ --
+
+
+
+ Units
+
+
+
+
+ Lightning strikes on map
+
+
+
+
+ Wind overlay on map
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.tmp/imgdiff/new/server.js b/.tmp/imgdiff/new/server.js
new file mode 100644
index 0000000..2aa7df2
--- /dev/null
+++ b/.tmp/imgdiff/new/server.js
@@ -0,0 +1,286 @@
+const express = require('express');
+const path = require('path');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+const weatherCache = new Map();
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+// Prevent browser favicon requests from showing noisy 404s.
+app.get('/favicon.ico', (_req, res) => {
+ res.status(204).end();
+});
+
+// Geocoding proxy
+app.get('/api/geocode', async (req, res) => {
+ const { q } = req.query;
+ if (!q) return res.status(400).json({ error: 'Missing query' });
+ try {
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
+
+ let openMeteoData = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 9000);
+ try {
+ const r = await fetch(url, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
+ openMeteoData = await r.json();
+ break;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ if (attempt === 2) {
+ console.warn('Open-Meteo geocode failed after retries:', err.message);
+ } else {
+ await new Promise(resolve => setTimeout(resolve, 300 * attempt));
+ }
+ }
+ }
+
+ if (openMeteoData?.results?.length) {
+ return res.json(openMeteoData);
+ }
+
+ // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
+ const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
+ const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/json',
+ },
+ });
+
+ if (!nom.ok) {
+ throw new Error(`Nominatim geocode status ${nom.status}`);
+ }
+
+ const nomData = await nom.json();
+ const results = (nomData || []).map(item => {
+ const address = item.address || {};
+ return {
+ name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
+ latitude: Number(item.lat),
+ longitude: Number(item.lon),
+ country: address.country || '',
+ admin1: address.state || address.region || address.county || '',
+ timezone: 'auto',
+ };
+ }).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
+
+ return res.json({ results });
+ } catch (e) {
+ res.status(500).json({ error: 'Geocoding failed', detail: e.message });
+ }
+});
+
+// Reverse geocoding proxy
+app.get('/api/reverse-geocode', async (req, res) => {
+ const { lat, lon } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+
+ try {
+ const params = new URLSearchParams({
+ format: 'jsonv2',
+ lat: String(lat),
+ lon: String(lon),
+ zoom: '10',
+ addressdetails: '1',
+ });
+
+ const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/json',
+ },
+ });
+
+ if (!r.ok) {
+ throw new Error(`Reverse geocoding failed with status ${r.status}`);
+ }
+
+ const data = await r.json();
+ const address = data.address || {};
+ const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
+
+ res.json({
+ name,
+ admin1: address.state || address.region || address.county || '',
+ country: address.country || '',
+ latitude: Number(lat),
+ longitude: Number(lon),
+ timezone: 'auto',
+ display_name: data.display_name || '',
+ });
+ } catch (e) {
+ res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
+ }
+});
+
+// Weather proxy
+app.get('/api/weather', async (req, res) => {
+ const { lat, lon, tz, units } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ const timezone = tz || 'auto';
+ const useMetric = units === 'metric';
+ const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
+ try {
+ const params = new URLSearchParams({
+ latitude: lat, longitude: lon, timezone,
+ current: [
+ 'temperature_2m','relative_humidity_2m','apparent_temperature',
+ 'is_day','precipitation','weather_code','cloud_cover',
+ 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
+ ].join(','),
+ hourly: [
+ 'temperature_2m','precipitation_probability','weather_code',
+ 'wind_speed_10m','apparent_temperature','rain','snowfall'
+ ].join(','),
+ daily: [
+ 'weather_code','temperature_2m_max','temperature_2m_min',
+ 'precipitation_sum','precipitation_probability_max',
+ 'wind_speed_10m_max','sunrise','sunset','uv_index_max'
+ ].join(','),
+ forecast_days: 16,
+ wind_speed_unit: useMetric ? 'kmh' : 'mph',
+ temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
+ precipitation_unit: useMetric ? 'mm' : 'inch'
+ });
+
+ let lastError = null;
+ const upstreamTimeoutMs = 12000;
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
+ try {
+ const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ const body = await r.text();
+
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
+ }
+
+ const data = JSON.parse(body);
+ weatherCache.set(cacheKey, { data, at: Date.now() });
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
+ console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
+ if (attempt < 3) {
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
+ }
+ }
+ }
+
+ throw lastError || new Error('Weather upstream failed');
+ } catch (e) {
+ const cached = weatherCache.get(cacheKey);
+ if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
+ res.setHeader('X-Weather-Cache', 'stale-fallback');
+ console.warn('Weather proxy using stale cache for', cacheKey);
+ return res.json(cached.data);
+ }
+ console.error('Weather proxy failed:', e.message);
+ res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
+ }
+});
+
+// Open-Meteo Air Quality proxy
+app.get('/api/air-quality', async (req, res) => {
+ const { lat, lon, tz } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ try {
+ const params = new URLSearchParams({
+ latitude: lat,
+ longitude: lon,
+ timezone: tz || 'auto',
+ current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
+ forecast_days: 1,
+ });
+ let lastError = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+ try {
+ const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}`);
+ }
+ const data = await r.json();
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ if (attempt < 2) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+ }
+
+ throw lastError || new Error('Air quality upstream failed');
+ } catch (e) {
+ console.error('Air quality fetch error:', e);
+ res.setHeader('X-Air-Quality-Fallback', 'empty');
+ res.json({ current: {} });
+ }
+});
+
+// NWS alerts proxy (US only)
+app.get('/api/alerts', async (req, res) => {
+ const { lat, lon } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+
+ const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/geo+json',
+ },
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!r.ok) {
+ throw new Error(`NWS API returned ${r.status}`);
+ }
+ const data = await r.json();
+ res.json(data);
+ } catch (e) {
+ console.error('Alerts fetch error:', e.message);
+ res.setHeader('X-Alerts-Fallback', 'empty');
+ res.json({ features: [] });
+ }
+});
+
+// RainViewer timestamps proxy
+app.get('/api/radar/times', async (req, res) => {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 25000);
+
+ const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ if (!r.ok) {
+ throw new Error(`RainViewer API returned ${r.status}`);
+ }
+ const data = await r.json();
+ res.json(data);
+ } catch (e) {
+ console.error('Radar times fetch error:', e.message);
+ res.status(500).json({ error: 'Radar times failed', detail: e.message });
+ }
+});
+
+app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));
diff --git a/.tmp/imgdiff/new/style.css b/.tmp/imgdiff/new/style.css
new file mode 100644
index 0000000..92ea4d9
--- /dev/null
+++ b/.tmp/imgdiff/new/style.css
@@ -0,0 +1,1304 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --ink: #1a1a1a;
+ --ink-mid: #3d3730;
+ --ink-light: #5f5649;
+ --paper: #f5f2ec;
+ --paper-dark: #ece7db;
+ --paper-darker: #ddd6c7;
+ --border: #b9ae9b;
+ --accent: #c25a2a;
+ --cool: #2a6b8a;
+ --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
+ --serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
+ --card-bg: var(--paper-dark);
+ --card-bg-soft: var(--paper-darker);
+ --card-ink: var(--ink);
+ --card-sub: var(--ink-light);
+ --surface-border: 1px solid rgba(255, 255, 255, 0.08);
+ --surface-radius-lg: 16px;
+ --surface-radius-md: 12px;
+ --surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --ink: #ece7dc;
+ --ink-mid: #d2c8b1;
+ --ink-light: #beb399;
+ --paper: #191510;
+ --paper-dark: #201c16;
+ --paper-darker: #2b2620;
+ --border: #3a342a;
+ --accent: #e07848;
+ --cool: #5ab0d8;
+ }
+}
+
+html, body {
+ min-height: 100%;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 16px;
+ line-height: 1.6;
+ overflow-x: hidden;
+}
+
+.app {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+
+a { color: var(--cool); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Masthead ── */
+.masthead {
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: center;
+ justify-items: center;
+ gap: 0.8rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: linear-gradient(180deg, var(--card-bg-soft), var(--paper-dark));
+ color: var(--card-ink);
+ padding: 1rem 1.1rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--surface-shadow);
+}
+
+.masthead h1 {
+ font-family: var(--serif);
+ font-size: 2.35rem;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ color: var(--card-ink);
+ white-space: nowrap;
+ text-align: center;
+}
+
+.tagline {
+ font-size: 0.74rem;
+ letter-spacing: 0.12em;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+/* ── Tabs ── */
+.tabs {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.tab {
+ background: transparent;
+ border: none;
+ border-right: 1px solid var(--border);
+ padding: 0.4rem 1rem;
+ font-family: var(--mono);
+ font-size: 0.84rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-mid);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.tab:last-child { border-right: none; }
+.tab:hover { background: var(--paper-dark); color: var(--ink); }
+.tab.active { background: var(--ink); color: var(--paper); }
+
+/* ── Search ── */
+.search-wrap {
+ position: relative;
+ margin-bottom: 2rem;
+ background: var(--card-bg);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.55rem;
+ box-shadow: var(--surface-shadow);
+}
+
+.search-row { display: flex; }
+
+.search-row input {
+ flex: 1;
+ min-width: 0;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-right: none;
+ padding: 0.6rem 1rem;
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ color: var(--card-ink);
+ outline: none;
+ border-radius: 0;
+ transition: background 0.15s;
+}
+
+.search-row input::placeholder { color: var(--card-sub); }
+.search-row input:focus { background: rgba(0, 0, 0, 0.25); }
+
+.search-row button {
+ background: rgba(0, 0, 0, 0.24);
+ color: var(--card-ink);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ padding: 0.6rem 1.1rem;
+ font-family: var(--mono);
+ font-size: 0.84rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ border-radius: 0;
+ transition: opacity 0.15s;
+ white-space: nowrap;
+}
+
+.search-row button:hover { opacity: 0.72; }
+
+#locate-btn {
+ background: rgba(0, 0, 0, 0.22) !important;
+ color: var(--card-ink) !important;
+ border-left: none !important;
+ font-size: 1.2rem !important;
+ letter-spacing: 0 !important;
+ padding: 0.6rem 0.9rem !important;
+}
+
+#units-btn {
+ background: rgba(0, 0, 0, 0.22) !important;
+ color: var(--card-ink) !important;
+ border-left: none !important;
+ min-width: 76px;
+}
+
+.saved-wrap { margin-top: 0.6rem; }
+.saved-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.62rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-light);
+ margin-bottom: 0.35rem;
+}
+
+.saved-locations {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: var(--ink-mid);
+}
+
+/* ── Toggle Switch ── */
+.switch-toggle {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ width: 50px;
+ height: 28px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.switch-toggle input {
+ display: none;
+}
+
+.toggle-slider {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 1px solid var(--surface-border);
+ border-radius: 14px;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+}
+
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background-color: var(--ink-light);
+ left: 2px;
+ transition: all 0.3s ease;
+}
+
+.switch-toggle input:checked + .toggle-slider {
+ background-color: var(--accent);
+ border-color: var(--accent);
+}
+
+.switch-toggle input:checked + .toggle-slider::before {
+ left: 26px;
+ background-color: #fff;
+}
+
+.switch-toggle input:disabled + .toggle-slider {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.auto-detect-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.45rem;
+ font-size: 0.68rem;
+ color: var(--ink-mid);
+ cursor: pointer;
+ user-select: none;
+}
+
+.auto-detect-toggle input {
+ accent-color: var(--accent);
+}
+
+.toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.45rem;
+ font-size: 0.68rem;
+ color: var(--ink-mid);
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle-label input {
+ accent-color: var(--accent);
+}
+
+.toggle-label span {
+ min-width: 24px;
+}
+
+.saved-item {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ background: var(--paper-dark);
+}
+
+.saved-chip,
+.saved-remove {
+ border: none;
+ background: transparent;
+ color: var(--ink);
+ padding: 0.35rem 0.45rem;
+ font-family: var(--mono);
+ cursor: pointer;
+}
+
+.saved-remove {
+ border-left: 1px solid var(--border);
+ color: var(--ink-light);
+}
+
+/* Suggestions */
+.suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0; right: 0;
+ background: var(--paper-darker);
+ border: 1px solid var(--border);
+ border-top: none;
+ z-index: 200;
+ list-style: none;
+}
+
+.suggestions li {
+ padding: 0.6rem 1rem;
+ cursor: pointer;
+ font-size: 0.82rem;
+ border-bottom: 0.5px solid var(--border);
+ transition: background 0.1s;
+}
+
+.suggestions li:last-child { border-bottom: none; }
+.suggestions li:hover { background: var(--paper-dark); }
+.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; }
+
+/* ── Error / Loading ── */
+.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; }
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 200px);
+ padding: 3rem;
+}
+
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2rem;
+ text-align: center;
+}
+
+.loading-icon {
+ font-size: 6rem;
+ animation: float 2s ease-in-out infinite;
+}
+
+.loading-text {
+ font-size: 1.5rem;
+ font-weight: 500;
+ color: var(--ink);
+ letter-spacing: 0.05em;
+}
+
+.loading-dots {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: center;
+}
+
+.loading-dots span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: bounce 1.4s ease-in-out infinite;
+}
+
+.loading-dots span:nth-child(1) { animation-delay: 0s; }
+.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
+.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-20px); } }
+@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.4; } 50% { transform: translateY(-15px); opacity: 1; } }
+
+.hidden { display: none !important; }
+
+/* ── Tab panels ── */
+.tab-panel { width: 100%; }
+
+/* ── Location header ── */
+.loc-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 1.5rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: var(--card-bg-soft);
+ padding: 0.9rem 1rem;
+}
+
+.loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; }
+.loc-name { color: var(--card-ink); }
+.loc-sub { font-size: 0.8rem; color: var(--card-sub); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; }
+.loc-time { font-size: 0.8rem; color: var(--card-sub); text-align: right; letter-spacing: 0.04em; line-height: 1.5; }
+.loc-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; }
+
+/* ── HERO ── */
+.hero {
+ display: grid;
+ grid-template-columns: 1fr 1.5fr 1fr;
+ align-items: center;
+ gap: 1rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: var(--card-bg);
+ color: var(--card-ink);
+ padding: 1.4rem 1rem;
+ margin-bottom: 1.8rem;
+}
+
+.hero-left {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.4rem;
+}
+
+.hero-icon { font-size: 3.5rem; line-height: 1; }
+.hero-desc { font-size: 0.9rem; font-weight: 500; }
+.hero-sub { font-size: 0.82rem; color: var(--card-sub); margin-top: 0.15rem; }
+
+.hero-center { text-align: center; }
+
+.hero-temp {
+ font-family: var(--serif);
+ font-size: 6.5rem;
+ font-weight: 400;
+ line-height: 1;
+ letter-spacing: -4px;
+}
+
+.deg { font-family: var(--mono); font-size: 2rem; color: var(--card-sub); vertical-align: super; letter-spacing: 0; display: inline; }
+.feels { font-size: 0.86rem; color: var(--card-sub); margin-top: 0.5rem; }
+
+.hero-right { display: flex; justify-content: flex-end; }
+
+.hero-hilo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ padding: 1rem 1.4rem;
+ min-width: 80px;
+}
+
+.hilo-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.2;
+ font-size: 1.3rem;
+ font-weight: 500;
+}
+
+.hilo-arrow { font-size: 0.75rem; color: var(--ink-light); }
+.hilo-label { font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--card-sub); margin-top: 0.15rem; }
+.hilo-divider { width: 100%; height: 1px; background: rgba(255, 255, 255, 0.12); }
+
+/* ── Sun & Moon Timeline ── */
+.sun-section {
+ border: var(--surface-border);
+ background: var(--card-bg);
+ color: var(--card-ink);
+ border-radius: var(--surface-radius-md);
+ padding: 1rem 1.1rem;
+ margin-bottom: 2rem;
+}
+
+.sun-timeline-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.sun-endpoint {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ flex-shrink: 0;
+}
+
+.sun-ep-icon { font-size: 1.6rem; line-height: 1; }
+.sun-ep-time { font-size: 0.8rem; font-weight: 600; color: var(--card-sub); white-space: nowrap; }
+
+.sun-track-wrap { flex: 1; position: relative; }
+
+.sun-track {
+ height: 6px;
+ background: rgba(255, 255, 255, 0.14);
+ border-radius: 3px;
+ position: relative;
+ overflow: visible;
+}
+
+.sun-progress {
+ height: 100%;
+ background: linear-gradient(90deg, #fb923c, #fbbf24);
+ border-radius: 3px;
+ transition: width 0.6s ease;
+}
+
+.sun-dot {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 1.2rem;
+ pointer-events: none;
+ transition: left 0.6s ease;
+}
+
+.moon-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--card-sub);
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+ padding-top: 0.6rem;
+}
+
+.moon-emoji { font-size: 1.2rem; }
+.daylight-info { margin-left: auto; font-size: 0.78rem; font-weight: 500; color: var(--card-sub); }
+
+/* ── Card Group ── */
+.card-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 1px;
+ background: rgba(255, 255, 255, 0.08);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--card-bg);
+ padding: 1rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.stat-card .stat-label {
+ font-size: 0.68rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ font-weight: 700;
+ color: var(--card-sub);
+}
+
+.stat-card .stat-val {
+ font-size: 1.65rem;
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--card-ink);
+}
+
+.stat-unit { font-size: 0.85rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; }
+.stat-sub { font-size: 0.8rem; font-weight: 500; color: var(--card-sub); }
+
+/* ── Details Drawer ── */
+.details-drawer {
+ border: var(--surface-border);
+ margin-bottom: 2rem;
+ background: var(--card-bg);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+}
+
+.drawer-summary {
+ list-style: none;
+ font-size: 0.68rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--card-sub);
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ user-select: none;
+}
+
+.drawer-summary::-webkit-details-marker { display: none; }
+.drawer-summary:focus { outline: none; }
+
+.drawer-chevron {
+ font-size: 1.1rem;
+ transition: transform 0.2s;
+ display: inline-block;
+}
+
+details[open] .drawer-chevron { transform: rotate(90deg); }
+
+.drawer-body { border-top: 1px solid rgba(255, 255, 255, 0.1); }
+.drawer-card-group { margin-bottom: 0; border: none; }
+
+/* ── What to Bring ── */
+.recs-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+}
+
+.rec-item {
+ background: var(--card-bg);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.85rem 0.6rem;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.rec-icon { font-size: 2rem; line-height: 1; }
+.rec-label { font-size: 0.72rem; font-weight: 600; color: var(--card-sub); letter-spacing: 0.04em; line-height: 1.3; }
+
+.alerts-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.75rem;
+ margin-bottom: 1.2rem;
+ background: var(--card-bg);
+}
+
+.alerts-list {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.alert-item {
+ display: grid;
+ gap: 0.2rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.55rem;
+ background: var(--card-bg-soft);
+ font-size: 0.72rem;
+}
+
+.panel-note {
+ font-size: 0.72rem;
+ color: var(--ink-light);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.75rem;
+ background: var(--card-bg);
+}
+
+.settings-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 0.9rem;
+ display: grid;
+ gap: 1rem;
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.65rem 0.75rem;
+}
+
+.settings-label {
+ font-size: 0.72rem;
+ color: var(--ink-mid);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.settings-value {
+ font-size: 0.8rem;
+ color: var(--ink);
+ text-align: right;
+}
+
+.settings-btn {
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ color: var(--ink);
+ padding: 0.45rem 0.7rem;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ cursor: pointer;
+}
+
+.settings-btn:hover { background: var(--card-bg); }
+
+.maps-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.maps-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+
+.maps-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--card-ink);
+}
+
+.maps-quick-toggles {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.overlay-toggle {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ background: var(--card-bg-soft);
+ color: var(--card-sub);
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.overlay-toggle:hover {
+ background: var(--card-bg);
+ border-color: var(--accent);
+}
+
+.overlay-toggle.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+.map-overview {
+ height: 360px;
+ border: var(--surface-border);
+ border-radius: 10px;
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.maps-legend {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ padding: 0.75rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: var(--surface-border);
+ border-radius: 8px;
+}
+
+.legend-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.legend-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--card-ink);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.75rem;
+ color: var(--card-sub);
+}
+
+.legend-item svg {
+ flex-shrink: 0;
+}
+
+.maps-data {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: 1rem;
+}
+
+.maps-card {
+ background: var(--card-bg-soft);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.card-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.maps-value {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--card-ink);
+}
+
+.wind-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 0.75rem;
+}
+
+.wind-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.wind-stat .stat-label {
+ font-size: 0.7rem;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.wind-stat .stat-value {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; }
+
+/* ── Section heads ── */
+.section-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 0.6rem;
+}
+
+.section-label { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; }
+.section-note { font-size: 0.72rem; color: var(--card-sub); font-style: italic; }
+
+.link-btn {
+ background: none;
+ border: none;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--cool);
+ cursor: pointer;
+ padding: 0.35rem 0.65rem;
+ font-style: italic;
+}
+
+.link-btn:hover { text-decoration: underline; }
+
+/* ── Radar maps ── */
+.radar-embed-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+ margin-bottom: 2rem;
+ background: #1a1f2e;
+}
+
+.radar-map {
+ width: 100%;
+ height: 320px;
+}
+
+.radar-map-full {
+ height: calc(100vh - 280px);
+ min-height: 420px;
+}
+
+.radar-full-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: #1a1f2e;
+ margin-bottom: 0.5rem;
+ overflow: hidden;
+}
+
+/* ── Radar controls ── */
+.radar-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.6rem 0.75rem;
+ background: var(--paper-darker);
+ border-top: 1px solid var(--border);
+}
+
+.radar-controls-full { padding: 0.75rem 1rem; }
+
+.radar-btn {
+ background: var(--ink);
+ color: var(--paper);
+ border: none;
+ width: 28px; height: 28px;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.7rem;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: opacity 0.15s;
+}
+
+.radar-btn:hover { opacity: 0.72; }
+.layer-btn { background: var(--accent); }
+
+.radar-timeline {
+ flex: 1;
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ height: 28px;
+}
+
+.tl-tick {
+ flex: 1;
+ height: 100%;
+ background: var(--paper-dark);
+ border: 1px solid var(--border);
+ cursor: pointer;
+ transition: background 0.1s;
+ position: relative;
+}
+
+.tl-tick:hover { background: var(--paper); }
+.tl-tick.active { background: var(--cool); border-color: var(--cool); }
+.tl-tick.forecast { opacity: 0.55; }
+
+.radar-ts {
+ font-size: 0.74rem;
+ color: var(--ink);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ min-width: 80px;
+ text-align: right;
+}
+
+/* Radar legend */
+.radar-legend {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-left: 0.5rem;
+}
+
+.legend-label { font-size: 0.66rem; color: var(--ink-mid); letter-spacing: 0.06em; }
+
+.legend-bar {
+ width: 80px;
+ height: 8px;
+ background: linear-gradient(to right,
+ #00d8ff, #00a0e0, #0070d0,
+ #00c800, #80e000, #ffff00,
+ #ffc000, #ff6000, #ff0000, #c00000
+ );
+ border: 0.5px solid var(--border);
+}
+
+.radar-attribution {
+ font-size: 0.62rem;
+ color: var(--ink-light);
+ padding-top: 0.5rem;
+}
+
+/* ── Hourly scroll ── */
+.hourly-scroll {
+ overflow-x: auto;
+ margin-bottom: 2rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.hourly-track { display: flex; min-width: max-content; }
+
+.hour-cell {
+ min-width: 72px;
+ padding: 0.75rem 0.5rem;
+ text-align: center;
+ border-right: 0.5px solid var(--border);
+ background: var(--card-bg);
+ transition: background 0.12s;
+}
+
+.hour-cell:last-child { border-right: none; }
+.hour-cell:hover { background: var(--paper-dark); }
+.hour-cell.now { background: var(--paper-darker); }
+
+.hour-time { font-size: 0.6rem; color: var(--card-sub); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; }
+.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; }
+.hour-temp { font-size: 0.88rem; font-weight: 500; color: var(--card-ink); }
+.hour-precip { font-size: 0.64rem; color: #1f5f7e; margin-top: 0.2rem; font-weight: 600; }
+
+/* ── Daily forecast ── */
+.daily-list {
+ border: none;
+ display: grid;
+ gap: 0.55rem;
+ margin-bottom: 2rem;
+}
+
+.day-row {
+ display: grid;
+ grid-template-columns: 120px 44px 1fr 80px 60px;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.85rem 1.1rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ transition: background 0.12s;
+}
+
+.day-row:last-child { border-bottom: var(--surface-border); }
+.day-row:hover { background: var(--paper-dark); }
+.day-row.today { background: var(--paper-dark); }
+
+.day-name { font-size: 0.8rem; font-weight: 500; color: var(--card-ink); }
+.day-icon { font-size: 1.5rem; text-align: center; }
+.day-desc { font-size: 0.75rem; color: var(--ink-mid); }
+.day-temps { text-align: right; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--card-ink); }
+.day-lo { color: var(--ink-mid); }
+.day-meta { text-align: right; font-size: 0.62rem; color: var(--ink-mid); line-height: 1.6; }
+
+/* ── Footer ── */
+.footer-note {
+ font-size: 0.72rem;
+ color: var(--ink-light);
+ letter-spacing: 0.04em;
+ border-top: 0.5px solid var(--border);
+ padding-top: 0.75rem;
+}
+
+/* ── PWA: safe area insets (iPhone notch / home bar) ── */
+@supports (padding: env(safe-area-inset-bottom)) {
+ .app {
+ padding-top: max(2rem, calc(1rem + env(safe-area-inset-top)));
+ padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left)));
+ padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right)));
+ padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom)));
+ }
+ .radar-map-full {
+ height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
+ }
+}
+
+/* ── PWA: status bar area when running standalone ── */
+@media (display-mode: standalone) {
+ .masthead { padding-top: max(1rem, calc(0.75rem + env(safe-area-inset-top))); }
+}
+
+/* ── Touch-friendly tap targets (min 44 × 44 px) ── */
+.tab {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.search-row input,
+.search-row button { min-height: 44px; }
+.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; }
+.tl-tick { min-height: 36px; }
+
+/* iOS momentum scrolling */
+.hourly-scroll { -webkit-overflow-scrolling: touch; }
+
+/* ── Leaflet overrides ── */
+.leaflet-container { background: #1a1f2e; }
+.leaflet-tile { transition: opacity 0.24s linear; }
+.leaflet-control-zoom a {
+ background: var(--paper-dark) !important;
+ color: var(--ink) !important;
+ border-color: var(--border) !important;
+}
+
+/* ── Mobile Bottom Navigation ── */
+.bottom-nav {
+ display: none;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100vw;
+ z-index: 300;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(25, 21, 16, 0.96);
+ backdrop-filter: blur(6px);
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.bottom-tab {
+ flex: 1;
+ min-height: 56px;
+ border: none;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ background: transparent;
+ color: #efe8dc;
+ font-family: var(--mono);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.15rem;
+ cursor: pointer;
+}
+
+.bottom-tab:last-child { border-right: none; }
+.bottom-icon { font-size: 1rem; line-height: 1; }
+.bottom-label { font-size: 0.58rem; letter-spacing: 0.05em; text-transform: uppercase; }
+
+.bottom-tab.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+/* ── Responsive ── */
+@media (max-width: 640px) {
+ html, body { font-size: 16px; }
+ .app { padding: max(1.2rem, calc(0.8rem + env(safe-area-inset-top, 0px))) 0.9rem max(5.25rem, calc(4.5rem + env(safe-area-inset-bottom, 0px))); }
+ .masthead {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ gap: 0.35rem;
+ padding: 0.75rem 0.9rem;
+ }
+ .masthead h1 { font-size: 2.2rem; line-height: 1.08; }
+ .tagline { display: none; }
+ .tabs { display: none; }
+ .bottom-nav { display: flex; }
+
+ #tab-radar-full {
+ padding-bottom: max(6.4rem, calc(5.7rem + env(safe-area-inset-bottom, 0px)));
+ }
+
+ .radar-controls-full {
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr);
+ grid-template-areas:
+ 'play layer ts'
+ 'timeline timeline timeline';
+ gap: 0.45rem 0.5rem;
+ padding: 0.55rem 0.65rem;
+ align-items: center;
+ }
+
+ .radar-controls-full .play-btn { grid-area: play; }
+ .radar-controls-full .layer-btn { grid-area: layer; }
+ .radar-controls-full .radar-ts {
+ grid-area: ts;
+ min-width: 0;
+ text-align: right;
+ font-size: 0.66rem;
+ }
+
+ .radar-controls-full .radar-timeline {
+ grid-area: timeline;
+ width: 100%;
+ height: 24px;
+ }
+
+ .radar-controls-full .radar-legend { display: none; }
+ .radar-attribution {
+ overflow-wrap: anywhere;
+ padding-bottom: 0.3rem;
+ }
+
+ .tab { min-width: 88px; }
+ .search-wrap { padding: 0.45rem; }
+ .search-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
+ gap: 0;
+ }
+ .search-row input {
+ font-size: 0.84rem;
+ padding: 0.58rem 0.68rem;
+ }
+ .search-row button {
+ font-size: 0.64rem;
+ padding: 0.58rem 0.62rem;
+ letter-spacing: 0.04em;
+ }
+ #locate-btn { font-size: 1rem !important; padding: 0.58rem 0.55rem !important; }
+ #units-btn { min-width: 56px; }
+
+ .loc-header {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 0.45rem;
+ }
+ .loc-name { font-size: 2rem; }
+ .loc-sub { font-size: 0.88rem; }
+ .loc-actions { align-items: center; margin-top: 0; }
+ .loc-time { text-align: center; font-size: 0.88rem; }
+
+ .hero { grid-template-columns: 1fr; text-align: center; }
+ .hero-left { align-items: center; }
+ .hero-right { justify-content: center; }
+ .hero-temp { font-size: 5.4rem; }
+ .hero-desc { font-size: 1.55rem; }
+ .hero-sub { font-size: 0.92rem; }
+ .feels { font-size: 0.95rem; }
+ .hilo-item { font-size: 1.35rem; }
+ .hilo-label { font-size: 0.78rem; }
+
+ .section-label { font-size: 0.76rem; font-weight: 800; }
+ .section-note { font-size: 0.78rem; color: var(--ink-mid); }
+ .stat-card .stat-label { font-size: 0.75rem; font-weight: 700; }
+ .stat-card .stat-val { font-size: 1.8rem; font-weight: 600; }
+ .stat-unit { font-size: 0.95rem; font-weight: 600; }
+ .stat-sub { font-size: 0.95rem; font-weight: 600; }
+
+ /* Sun & moon section */
+ .sun-ep-icon { font-size: 1.8rem; }
+ .sun-ep-time { font-size: 0.9rem; font-weight: 600; }
+ .moon-row { font-size: 0.95rem; font-weight: 500; }
+ .moon-emoji { font-size: 1.4rem; }
+ .daylight-info { font-size: 0.88rem; font-weight: 500; }
+
+ /* What to bring */
+ .rec-item { padding: 0.95rem 0.68rem; }
+ .rec-icon { font-size: 2.2rem; }
+ .rec-label { font-size: 0.82rem; font-weight: 600; }
+
+ /* Hourly readability */
+ .hour-cell { min-width: 88px; padding: 0.8rem 0.55rem; }
+ .hour-time { font-size: 0.74rem; }
+ .hour-icon { font-size: 1.6rem; }
+ .hour-temp { font-size: 1.08rem; }
+ .hour-precip { font-size: 0.78rem; }
+
+ /* 7-day readability */
+ .day-row { padding: 0.95rem 0.95rem; }
+ .day-name { font-size: 0.98rem; }
+ .day-icon { font-size: 1.35rem; }
+ .day-desc { font-size: 0.9rem; }
+ .day-temps { font-size: 0.95rem; }
+
+ /* Bottom nav readability */
+ .bottom-tab { min-height: 62px; gap: 0.2rem; }
+ .bottom-icon { font-size: 1.2rem; }
+ .bottom-label { font-size: 0.68rem; letter-spacing: 0.04em; }
+
+ .hero-hilo { flex-direction: row; gap: 1.5rem; padding: 0.75rem 1.2rem; }
+ .hilo-divider { width: 1px; height: 2rem; }
+ .card-group { grid-template-columns: repeat(2, 1fr); }
+ .recs-grid { grid-template-columns: repeat(3, 1fr); }
+ .day-row { grid-template-columns: 80px 36px 1fr 70px; }
+ .day-meta { display: none; }
+ .radar-map { height: 240px; }
+ .legend-bar { width: 50px; }
+ .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
+ .settings-value { text-align: left; }
+ .map-overview { height: 280px; }
+ .maps-data { grid-template-columns: 1fr; }
+ .maps-legend { grid-template-columns: 1fr; }
+ .wind-stats { grid-template-columns: 1fr 1fr; }
+}
diff --git a/.tmp/imgdiff/new/sw.js b/.tmp/imgdiff/new/sw.js
new file mode 100644
index 0000000..664dffd
--- /dev/null
+++ b/.tmp/imgdiff/new/sw.js
@@ -0,0 +1,19 @@
+'use strict';
+
+// Disable offline caching so UI updates are always immediate.
+self.addEventListener('install', event => {
+ event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil((async () => {
+ const keys = await caches.keys();
+ await Promise.all(keys.map(k => caches.delete(k)));
+ await self.clients.claim();
+ await self.registration.unregister();
+ })());
+});
+
+self.addEventListener('fetch', () => {
+ // Intentionally no fetch interception.
+});
\ No newline at end of file
diff --git a/.tmp/imgdiff/old/app.js b/.tmp/imgdiff/old/app.js
new file mode 100644
index 0000000..ab383c1
--- /dev/null
+++ b/.tmp/imgdiff/old/app.js
@@ -0,0 +1,1569 @@
+'use strict';
+
+// ── WMO codes ────────────────────────────────────────────────────────────────
+const WMO = {
+ 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'},
+ 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'},
+ 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'},
+ 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'},
+ 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'},
+ 57:{desc:'Heavy freezing drizzle',icon:'🌨️'},
+ 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'},
+ 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'},
+ 67:{desc:'Heavy freezing rain',icon:'🌨️'},
+ 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'},
+ 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'},
+ 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'},
+ 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'},
+ 86:{desc:'Heavy snow showers',icon:'❄️'},
+ 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'},
+ 99:{desc:'Severe thunderstorm',icon:'⛈️'},
+};
+
+function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; }
+
+const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
+const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+const STORAGE_KEYS = {
+ lastCity: 'weather.lastCity',
+ savedCities: 'weather.savedCities',
+ units: 'weather.units',
+ autoDetect: 'weather.autoDetect',
+ showLightning: 'weather.showLightning',
+ showWind: 'weather.showWind',
+ defaultCity: 'weather.defaultCity',
+ highContrast: 'weather.highContrast',
+ radarOpacity: 'weather.radarOpacity',
+ lightningAgeMin: 'weather.lightningAgeMin',
+};
+
+function parseSavedCities(raw) {
+ try {
+ const parsed = JSON.parse(raw || '[]');
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map((entry) => {
+ if (entry && entry.city && typeof entry.city === 'object') {
+ return {
+ city: entry.city,
+ addedAt: entry.addedAt || Date.now(),
+ lastViewedAt: entry.lastViewedAt || null,
+ isDefault: Boolean(entry.isDefault),
+ };
+ }
+ return {
+ city: entry,
+ addedAt: Date.now(),
+ lastViewedAt: null,
+ isDefault: false,
+ };
+ }).filter(e => e.city && e.city.latitude != null && e.city.longitude != null);
+ } catch {
+ return [];
+ }
+}
+
+const state = {
+ units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
+ autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
+ showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
+ showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false',
+ highContrast: localStorage.getItem(STORAGE_KEYS.highContrast) === 'true',
+ radarOpacity: Number(localStorage.getItem(STORAGE_KEYS.radarOpacity) || 65),
+ lightningAgeMin: Number(localStorage.getItem(STORAGE_KEYS.lightningAgeMin) || 15),
+ currentCity: null,
+ currentWeather: null,
+ currentAir: null,
+ currentSource: 'startup',
+ savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)),
+ alertIdsSeen: new Set(),
+ autoRefreshTimer: null,
+ lastRefreshAt: 0,
+ lightningLayer: null,
+ windArrows: [],
+ dailyExpanded: false,
+ pushSupported: false,
+ pushEnabled: false,
+};
+
+const persistedDefaultKey = localStorage.getItem(STORAGE_KEYS.defaultCity);
+if (persistedDefaultKey && state.savedCities.length && !state.savedCities.some(e => e.isDefault)) {
+ state.savedCities = state.savedCities.map(e => ({ ...e, isDefault: cityKey(e.city) === persistedDefaultKey }));
+}
+
+// ── DOM ──────────────────────────────────────────────────────────────────────
+const searchInput = document.getElementById('search-input');
+const searchBtn = document.getElementById('search-btn');
+const locateBtn = document.getElementById('locate-btn');
+const suggestEl = document.getElementById('suggestions');
+const errorMsg = document.getElementById('error-msg');
+const loadingEl = document.getElementById('loading');
+const weatherDiv = document.getElementById('weather');
+const unitsBtn = document.getElementById('units-btn');
+const unitsBtnSettings = document.getElementById('units-btn-settings');
+const shareBtn = document.getElementById('share-btn');
+const saveCurrentBtn = document.getElementById('save-current-btn');
+const savedLocationsEl = document.getElementById('saved-locations');
+const notifyBtn = document.getElementById('notify-btn');
+const autoDetectToggle = document.getElementById('auto-detect-toggle');
+const alertsEmptyEl = document.getElementById('alerts-empty');
+const settingsLocationEl = document.getElementById('settings-location');
+const mapsLocationEl = document.getElementById('maps-location');
+const mapsWindEl = document.getElementById('maps-wind');
+const lightningToggle = document.getElementById('lightning-toggle');
+const windToggle = document.getElementById('wind-toggle');
+const lightningStatus = document.getElementById('lightning-status');
+const windStatus = document.getElementById('wind-status');
+const dailyForecastLabel = document.getElementById('daily-forecast-label');
+const dailyForecastToggle = document.getElementById('daily-forecast-toggle');
+const pushTestBtn = document.getElementById('push-test-btn');
+const hourlySummaryEl = document.getElementById('hourly-summary');
+const defaultCityEl = document.getElementById('default-city');
+const contrastToggle = document.getElementById('contrast-toggle');
+const contrastStatus = document.getElementById('contrast-status');
+const radarOpacityEl = document.getElementById('radar-opacity');
+const lightningAgeEl = document.getElementById('lightning-age');
+const mapsFullscreenBtn = document.getElementById('maps-fullscreen-btn');
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+const show = el => el.classList.remove('hidden');
+const hide = el => el.classList.add('hidden');
+const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); };
+const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); };
+
+function compassDir(deg) {
+ return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8];
+}
+
+function formatHour(iso) {
+ const h = new Date(iso).getHours();
+ return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
+}
+
+function formatDayShort(iso) {
+ const d = new Date(iso + 'T00:00:00');
+ const today = new Date();
+ const tomorrow = new Date(today);
+ tomorrow.setDate(today.getDate() + 1);
+ if (d.toDateString() === today.toDateString()) return 'Today';
+ if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
+ return DAYS[d.getDay()];
+}
+
+function formatClock(iso) {
+ return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function tempDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ return Math.round(v);
+}
+
+function speedDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ return Math.round(v);
+}
+
+function precipDisplay(v) {
+ if (v == null || Number.isNaN(v)) return '--';
+ return Number(v).toFixed(1);
+}
+
+function visDisplay(vMeters) {
+ if (vMeters == null || Number.isNaN(vMeters)) return '--';
+ const miles = vMeters / 1609;
+ if (state.units === 'us') return miles.toFixed(1);
+ return (miles * 1.60934).toFixed(1);
+}
+
+function tempUnit() { return state.units === 'us' ? 'F' : 'C'; }
+function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; }
+function visUnit() { return state.units === 'us' ? 'mi' : 'km'; }
+
+function weatherThemeFromCode(code) {
+ const n = Number(code);
+ if (Number.isNaN(n)) return 'cloud';
+ if (n === 0 || n === 1) return 'clear';
+ if (n === 2 || n === 3) return 'cloud';
+ if (n === 45 || n === 48) return 'fog';
+ if ((n >= 51 && n <= 67) || (n >= 80 && n <= 82)) return 'rain';
+ if (n >= 71 && n <= 77) return 'snow';
+ if (n >= 85 && n <= 86) return 'snow';
+ if (n >= 95) return 'storm';
+ return 'cloud';
+}
+
+function applyWeatherBackground(code, isDay) {
+ const theme = weatherThemeFromCode(code);
+ const body = document.body;
+ const themes = ['clear', 'cloud', 'rain', 'snow', 'storm', 'fog'];
+ themes.forEach(t => body.classList.remove(`weather-${t}`));
+ body.classList.add(`weather-${theme}`);
+ body.classList.toggle('weather-night', Number(isDay) === 0);
+}
+
+function updateUnitsButton() {
+ const label = state.units === 'us' ? 'US' : 'Metric';
+ unitsBtn.textContent = label;
+ if (unitsBtnSettings) unitsBtnSettings.textContent = label;
+}
+
+function syncAutoDetectToggle() {
+ autoDetectToggle.checked = state.autoDetect;
+}
+
+function updateNotifyButton() {
+ if (!('Notification' in window)) {
+ notifyBtn.textContent = 'Notifications unsupported';
+ notifyBtn.disabled = true;
+ if (pushTestBtn) pushTestBtn.disabled = true;
+ return;
+ }
+ if (!window.isSecureContext) {
+ notifyBtn.textContent = 'Needs HTTPS';
+ notifyBtn.disabled = true;
+ if (pushTestBtn) pushTestBtn.disabled = true;
+ return;
+ }
+
+ notifyBtn.disabled = false;
+ if (Notification.permission === 'granted') {
+ notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push';
+ } else if (Notification.permission === 'denied') {
+ notifyBtn.textContent = 'Notifications blocked';
+ } else {
+ notifyBtn.textContent = 'Enable notifications';
+ }
+
+ if (pushTestBtn) {
+ pushTestBtn.disabled = !(Notification.permission === 'granted' && state.pushEnabled);
+ }
+}
+
+function applyHighContrast() {
+ document.body.classList.toggle('high-contrast', state.highContrast);
+ if (contrastStatus) contrastStatus.textContent = state.highContrast ? 'On' : 'Off';
+}
+
+function urlBase64ToUint8Array(base64String) {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const rawData = atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+ for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
+ return outputArray;
+}
+
+async function ensurePushSubscription() {
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
+
+ const keyResp = await fetch('/api/push/public-key').then(r => r.json()).catch(() => null);
+ const publicKey = keyResp?.publicKey;
+ if (!publicKey) return false;
+
+ const reg = await navigator.serviceWorker.ready;
+ let sub = await reg.pushManager.getSubscription();
+ if (!sub) {
+ sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(publicKey),
+ });
+ }
+
+ await fetch('/api/push/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ subscription: sub,
+ location: state.currentCity ? {
+ latitude: state.currentCity.latitude,
+ longitude: state.currentCity.longitude,
+ } : null,
+ }),
+ });
+
+ state.pushEnabled = true;
+ updateNotifyButton();
+ return true;
+}
+
+async function sendTestPush() {
+ const resp = await fetch('/api/push/test', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: 'Weather push test',
+ body: state.currentCity ? `Alerts enabled for ${cityLabel(state.currentCity) || 'your location'}` : 'Push notifications are active.',
+ url: window.location.pathname,
+ }),
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ throw new Error(err.error || 'Push test failed');
+ }
+}
+
+function moonPhase(dateLike) {
+ const date = new Date(dateLike);
+ const synodic = 29.53058867;
+ const knownNewMoon = new Date('2000-01-06T18:14:00Z');
+ const days = (date - knownNewMoon) / 86400000;
+ const phase = ((days % synodic) + synodic) % synodic;
+ const index = Math.floor((phase / synodic) * 8) % 8;
+ return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index];
+}
+
+function precipType(code, tempF, rain, snowfall) {
+ if ((snowfall ?? 0) > 0) return 'Snow';
+ if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet';
+ if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain';
+ return 'Dry';
+}
+
+function apparentLabel(tempF, apparentF) {
+ if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill';
+ if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index';
+ return 'Feels like';
+}
+
+function cityKey(city) {
+ return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`;
+}
+
+function persistSavedCities() {
+ localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities));
+ const defaultEntry = state.savedCities.find(e => e.isDefault);
+ localStorage.setItem(STORAGE_KEYS.defaultCity, defaultEntry ? cityKey(defaultEntry.city) : '');
+}
+
+function persistLastCity(city) {
+ localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city));
+}
+
+function getLastCity() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); }
+ catch { return null; }
+}
+
+function cityLabel(city) {
+ return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', ');
+}
+
+function formatRelativeTime(ts) {
+ if (!ts) return 'Never opened';
+ const mins = Math.max(1, Math.round((Date.now() - ts) / 60000));
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.round(mins / 60);
+ if (hrs < 48) return `${hrs}h ago`;
+ const days = Math.round(hrs / 24);
+ return `${days}d ago`;
+}
+
+function getDefaultSavedEntry() {
+ return state.savedCities.find(e => e.isDefault) || null;
+}
+
+function updateDefaultCityLabel() {
+ if (!defaultCityEl) return;
+ const def = getDefaultSavedEntry();
+ defaultCityEl.textContent = def?.city?.name || 'None';
+}
+
+function setDefaultCityByKey(key) {
+ state.savedCities = state.savedCities.map(e => ({
+ ...e,
+ isDefault: cityKey(e.city) === key,
+ }));
+ persistSavedCities();
+ updateDefaultCityLabel();
+ renderSavedCities();
+}
+
+function moveSavedCity(index, direction) {
+ const next = index + direction;
+ if (next < 0 || next >= state.savedCities.length) return;
+ const arr = [...state.savedCities];
+ [arr[index], arr[next]] = [arr[next], arr[index]];
+ state.savedCities = arr;
+ persistSavedCities();
+ renderSavedCities();
+}
+
+function updateShareUrl(city) {
+ if (!city) return;
+ const params = new URLSearchParams(window.location.search);
+ params.set('lat', city.latitude);
+ params.set('lon', city.longitude);
+ params.set('name', city.name || 'Location');
+ history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
+}
+
+function updateSourceLocation(city) {
+ const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--');
+ if (settingsLocationEl) settingsLocationEl.textContent = label;
+ if (mapsLocationEl) mapsLocationEl.textContent = label;
+}
+
+function renderSavedCities() {
+ savedLocationsEl.innerHTML = '';
+ if (!state.savedCities.length) {
+ savedLocationsEl.textContent = 'None yet';
+ updateDefaultCityLabel();
+ return;
+ }
+
+ state.savedCities.forEach((entry, index) => {
+ const city = entry.city;
+ const wrap = document.createElement('div');
+ wrap.className = 'saved-item';
+
+ const chip = document.createElement('button');
+ chip.className = 'saved-chip';
+ chip.type = 'button';
+ chip.textContent = city.name;
+ chip.title = `${cityLabel(city)} • ${formatRelativeTime(entry.lastViewedAt)}`;
+ chip.addEventListener('click', () => {
+ entry.lastViewedAt = Date.now();
+ persistSavedCities();
+ loadAll(city, 'saved-location');
+ });
+
+ const defaultBtn = document.createElement('button');
+ defaultBtn.className = 'saved-default';
+ defaultBtn.type = 'button';
+ defaultBtn.title = entry.isDefault ? 'Default city' : 'Set as default city';
+ defaultBtn.textContent = entry.isDefault ? '★' : '☆';
+ defaultBtn.addEventListener('click', () => setDefaultCityByKey(cityKey(city)));
+
+ const upBtn = document.createElement('button');
+ upBtn.className = 'saved-move';
+ upBtn.type = 'button';
+ upBtn.title = 'Move up';
+ upBtn.textContent = '↑';
+ upBtn.disabled = index === 0;
+ upBtn.addEventListener('click', () => moveSavedCity(index, -1));
+
+ const downBtn = document.createElement('button');
+ downBtn.className = 'saved-move';
+ downBtn.type = 'button';
+ downBtn.title = 'Move down';
+ downBtn.textContent = '↓';
+ downBtn.disabled = index === state.savedCities.length - 1;
+ downBtn.addEventListener('click', () => moveSavedCity(index, 1));
+
+ const remove = document.createElement('button');
+ remove.className = 'saved-remove';
+ remove.type = 'button';
+ remove.textContent = 'x';
+ remove.addEventListener('click', (e) => {
+ e.stopPropagation();
+ state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city));
+ persistSavedCities();
+ renderSavedCities();
+ });
+
+ const meta = document.createElement('span');
+ meta.className = 'saved-meta';
+ meta.textContent = formatRelativeTime(entry.lastViewedAt);
+
+ wrap.appendChild(defaultBtn);
+ wrap.appendChild(chip);
+ wrap.appendChild(upBtn);
+ wrap.appendChild(downBtn);
+ wrap.appendChild(remove);
+ wrap.appendChild(meta);
+ savedLocationsEl.appendChild(wrap);
+ });
+
+ updateDefaultCityLabel();
+}
+
+// ── Tab switching ─────────────────────────────────────────────────────────────
+document.querySelectorAll('.tab').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.tab-panel').forEach(p => hide(p));
+ btn.classList.add('active');
+ show(document.getElementById('tab-' + btn.dataset.tab));
+ if (btn.dataset.tab === 'radar-full') {
+ setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50);
+ }
+ if (btn.dataset.tab === 'maps') {
+ setTimeout(refreshMapsOverview, 80);
+ } else {
+ setMapsFullscreen(false);
+ }
+ });
+});
+
+document.getElementById('open-radar-tab').addEventListener('click', () => {
+ document.querySelector('.tab[data-tab="radar-full"]').click();
+});
+
+// ── APIs ─────────────────────────────────────────────────────────────────────
+async function geocode(q) {
+ const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
+ if (!r.ok) throw new Error('Geocoding failed');
+ return r.json();
+}
+
+async function reverseGeocode(lat, lon) {
+ const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
+ if (!r.ok) throw new Error('Reverse geocoding failed');
+ return r.json();
+}
+
+async function fetchWeather(lat, lon, tz) {
+ const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`);
+ if (!r.ok) throw new Error('Weather fetch failed');
+ return r.json();
+}
+
+async function fetchAirQuality(lat, lon, tz) {
+ const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`);
+ if (!r.ok) throw new Error('Air quality fetch failed');
+ return r.json();
+}
+
+async function fetchAlerts(lat, lon) {
+ const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`);
+ if (!r.ok) throw new Error('Alerts fetch failed');
+ return r.json();
+}
+
+async function fetchLightning(lat, lon) {
+ const ageMin = Number(state.lightningAgeMin || 15);
+ const r = await fetch(`/api/lightning?lat=${lat}&lon=${lon}&age=${ageMin}`);
+ if (!r.ok) throw new Error('Lightning fetch failed');
+ return r.json();
+}
+
+function detectedCityFallback(lat, lon) {
+ return {
+ name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
+ latitude: lat,
+ longitude: lon,
+ country: '',
+ admin1: '',
+ timezone: 'auto',
+ };
+}
+
+function resolveDetectedName(place, fallback) {
+ const raw = (place?.name || '').trim();
+ const genericName = !raw || /current\s*location|detected\s*location/i.test(raw);
+ if (!genericName) return raw;
+
+ const displayRoot = (place?.display_name || '').split(',')[0]?.trim();
+ if (displayRoot) return displayRoot;
+
+ return fallback.name;
+}
+
+async function buildDetectedCity(lat, lon) {
+ const fallback = detectedCityFallback(lat, lon);
+
+ try {
+ const place = await reverseGeocode(lat, lon);
+ return {
+ ...fallback,
+ ...place,
+ name: resolveDetectedName(place, fallback),
+ latitude: lat,
+ longitude: lon,
+ timezone: place.timezone || 'auto',
+ };
+ } catch {
+ return fallback;
+ }
+}
+
+// ── Radar engine ──────────────────────────────────────────────────────────────
+function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
+ const map = L.map(mapEl, {
+ center: [39.5, -98.35],
+ zoom: 4,
+ zoomControl: true,
+ attributionControl: false,
+ });
+
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ maxZoom: 19,
+ }).addTo(map);
+
+ let radarFrames = [];
+ let satelliteFrames = [];
+ let mode = 'radar';
+ let currentIdx = 0;
+ let layers = { radar: {}, satellite: {} };
+ let playing = false;
+ let animTimer = null;
+ let locationMarker = null;
+
+ function activeFrames() {
+ return mode === 'radar' ? radarFrames : satelliteFrames;
+ }
+
+ function tsLabel(unixSec) {
+ const d = new Date(unixSec * 1000);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +
+ ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate();
+ }
+
+ function frameTileUrl(frame) {
+ if (mode === 'satellite') {
+ return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`;
+ }
+ return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`;
+ }
+
+ function showFrame(idx) {
+ const frames = activeFrames();
+ currentIdx = idx;
+ const frame = frames[idx];
+ if (!frame) return;
+
+ if (!layers[mode][frame.time]) {
+ layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 });
+ }
+
+ Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
+ Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
+ layers[mode][frame.time].addTo(map);
+
+ tsEl.textContent = tsLabel(frame.time);
+ document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => {
+ el.classList.toggle('active', i === idx);
+ });
+ }
+
+ function buildTimeline() {
+ timelineEl.innerHTML = '';
+ activeFrames().forEach((f, i) => {
+ const tick = document.createElement('div');
+ tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : '');
+ tick.title = tsLabel(f.time);
+ tick.addEventListener('click', () => { stopPlay(); showFrame(i); });
+ timelineEl.appendChild(tick);
+ });
+ }
+
+ function step() {
+ const frames = activeFrames();
+ if (!frames.length) return;
+ const next = (currentIdx + 1) % frames.length;
+ showFrame(next);
+ if (next === 0) {
+ stopPlay();
+ setTimeout(startPlay, 800);
+ }
+ }
+
+ function startPlay() {
+ playing = true;
+ playBtn.textContent = '⏸';
+ animTimer = setInterval(step, 500);
+ }
+
+ function stopPlay() {
+ playing = false;
+ playBtn.textContent = '▶';
+ clearInterval(animTimer);
+ }
+
+ playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay());
+
+ if (layerBtn) {
+ layerBtn.addEventListener('click', () => {
+ if (!(radarFrames.length && satelliteFrames.length)) return;
+ mode = mode === 'radar' ? 'satellite' : 'radar';
+ layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
+ buildTimeline();
+ const idx = Math.min(currentIdx, activeFrames().length - 1);
+ showFrame(Math.max(idx, 0));
+ });
+ }
+
+ async function loadFrames() {
+ try {
+ const r = await fetch('/api/radar/times');
+ const data = await r.json();
+
+ radarFrames = [];
+ satelliteFrames = [];
+ (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
+ (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
+ (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
+
+ if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
+ if (!radarFrames.length && !satelliteFrames.length) return;
+
+ if (layerBtn) {
+ layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
+ layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
+ }
+
+ buildTimeline();
+ const frames = activeFrames();
+ showFrame(frames.length - 1);
+ } catch (e) {
+ console.error('Radar load failed', e);
+ }
+ }
+
+ function panTo(lat, lon, zoom) {
+ map.setView([lat, lon], zoom || map.getZoom());
+ if (locationMarker) map.removeLayer(locationMarker);
+ locationMarker = L.circleMarker([lat, lon], {
+ radius: 6,
+ fillColor: '#e07848',
+ fillOpacity: 1,
+ color: '#fff',
+ weight: 2,
+ }).addTo(map);
+ }
+
+ loadFrames();
+ return { map, panTo, startPlay, stopPlay };
+}
+
+const radarEmbed = createRadar(
+ document.getElementById('radar-embed'),
+ document.getElementById('embed-timeline'),
+ document.getElementById('embed-play'),
+ document.getElementById('embed-timestamp'),
+ document.getElementById('embed-layer')
+);
+
+const radarFull = createRadar(
+ document.getElementById('radar-full'),
+ document.getElementById('full-timeline'),
+ document.getElementById('full-play'),
+ document.getElementById('full-timestamp'),
+ document.getElementById('full-layer')
+);
+
+function createOverviewMap(mapEl) {
+ const map = L.map(mapEl, {
+ center: [39.5, -98.35],
+ zoom: 4,
+ zoomControl: true,
+ attributionControl: false,
+ });
+
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 18,
+ }).addTo(map);
+
+ const radarLayer = L.tileLayer('https://tilecache.rainviewer.com/v2/radar/nowcast_0/256/{z}/{x}/{y}/2/1_1.png', {
+ maxZoom: 15,
+ opacity: Math.max(0.25, Math.min(0.9, state.radarOpacity / 100)),
+ }).addTo(map);
+
+ let lastRadarFramePath = null;
+
+ async function refreshRadarOverlay() {
+ try {
+ const r = await fetch('/api/radar/times');
+ if (!r.ok) return;
+ const data = await r.json();
+ const frames = [
+ ...(data?.radar?.past || []),
+ ...(data?.radar?.nowcast || []),
+ ];
+ const latest = frames[frames.length - 1];
+ const path = latest?.path;
+ if (!path || path === lastRadarFramePath) return;
+ lastRadarFramePath = path;
+ radarLayer.setUrl(`https://tilecache.rainviewer.com${path}/256/{z}/{x}/{y}/2/1_1.png`);
+ } catch (e) {
+ console.warn('Maps radar overlay refresh failed', e);
+ }
+ }
+
+ refreshRadarOverlay();
+ setInterval(refreshRadarOverlay, 5 * 60 * 1000);
+
+ let marker = null;
+
+ function panTo(lat, lon, zoom) {
+ map.setView([lat, lon], zoom || map.getZoom());
+ if (marker) map.removeLayer(marker);
+ marker = L.circleMarker([lat, lon], {
+ radius: 7,
+ fillColor: '#e07848',
+ fillOpacity: 1,
+ color: '#fff',
+ weight: 2,
+ }).addTo(map);
+ }
+
+ function setRadarOpacity(opacityPct) {
+ const normalized = Math.max(0.25, Math.min(0.9, Number(opacityPct) / 100));
+ radarLayer.setOpacity(normalized);
+ }
+
+ return { map, panTo, setRadarOpacity, refreshRadarOverlay };
+}
+
+function clearLightningLayer() {
+ if (!mapsOverview.map) return;
+ if (state.lightningLayer) {
+ mapsOverview.map.removeLayer(state.lightningLayer);
+ state.lightningLayer = null;
+ }
+}
+
+function clearWindArrows() {
+ if (!mapsOverview.map) return;
+ state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow));
+ state.windArrows = [];
+}
+
+function refreshMapsOverview() {
+ if (!mapsOverview.map) return;
+
+ mapsOverview.map.invalidateSize();
+ mapsOverview.refreshRadarOverlay();
+
+ if (!state.currentCity) return;
+
+ mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
+
+ clearLightningLayer();
+ clearWindArrows();
+
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
+}
+
+function circleCenter(coords) {
+ let sumLat = 0;
+ let sumLon = 0;
+ let count = 0;
+ coords.forEach(([lon, lat]) => {
+ sumLat += lat;
+ sumLon += lon;
+ count += 1;
+ });
+ if (!count) return null;
+ return [sumLat / count, sumLon / count];
+}
+
+async function renderLightningStrikes(city) {
+ if (!mapsOverview.map || !state.showLightning) return;
+ clearLightningLayer();
+
+ try {
+ const data = await fetchLightning(city.latitude, city.longitude);
+ const features = data?.features || [];
+ if (!features.length) return;
+
+ const layers = [];
+ features.forEach((feature) => {
+ const geom = feature?.geometry;
+ const event = feature?.properties?.event || 'Thunderstorm activity';
+ const severity = feature?.properties?.severity || 'Unknown severity';
+ if (!geom) return;
+
+ const polygonSets = [];
+ if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) {
+ polygonSets.push(geom.coordinates[0]);
+ }
+ if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates)) {
+ geom.coordinates.forEach(poly => {
+ if (Array.isArray(poly?.[0])) polygonSets.push(poly[0]);
+ });
+ }
+
+ polygonSets.forEach((polyCoords, idx) => {
+ const ring = polyCoords.map(([lon, lat]) => [lat, lon]);
+ const poly = L.polygon(ring, {
+ color: '#ff8b1a',
+ weight: 1.5,
+ opacity: 0.8,
+ fillColor: '#ffd34d',
+ fillOpacity: 0.18,
+ }).bindPopup(`${event} • ${severity}`);
+ layers.push(poly);
+
+ const center = circleCenter(polyCoords);
+ if (center) {
+ const strike = L.circleMarker(center, {
+ radius: 5,
+ fillColor: '#ffe066',
+ fillOpacity: 0.95,
+ color: '#ff6f00',
+ weight: 2,
+ }).bindPopup(`${event} area ${idx + 1}`);
+ layers.push(strike);
+ }
+ });
+ });
+
+ if (!layers.length) return;
+ state.lightningLayer = L.layerGroup(layers).addTo(mapsOverview.map);
+ } catch (e) {
+ console.warn('Lightning overlay unavailable', e);
+ }
+}
+
+function renderWindArrows(city) {
+ if (!mapsOverview.map || !state.showWind || !state.currentWeather) return;
+ clearWindArrows();
+
+ // Get current wind data
+ const current = state.currentWeather.current;
+ if (!current || !current.wind_speed_10m) return;
+
+ const windSpeed = current.wind_speed_10m;
+ const windDir = current.wind_direction_10m || 0;
+
+ // Draw a wind barb pointing in the direction of wind movement
+ const lat = parseFloat(city.latitude);
+ const lon = parseFloat(city.longitude);
+ const radius = 0.3;
+
+ // Calculate arrow end point based on wind direction
+ const radians = (windDir * Math.PI) / 180;
+ const endLat = lat + radius * Math.cos(radians);
+ const endLon = lon + radius * Math.sin(radians);
+
+ // Draw arrow line
+ const arrow = L.polyline(
+ [[lat, lon], [endLat, endLon]],
+ {
+ color: '#4a90e2',
+ weight: 2,
+ opacity: 0.7,
+ }
+ ).bindPopup(`Wind: ${speedDisplay(windSpeed)} ${speedUnit()}`);
+
+ arrow.addTo(mapsOverview.map);
+ state.windArrows.push(arrow);
+
+ // Draw arrow head as a small circle at the end
+ const arrowHead = L.circleMarker([endLat, endLon], {
+ radius: 3,
+ fillColor: '#4a90e2',
+ fillOpacity: 0.7,
+ color: '#fff',
+ weight: 1,
+ });
+
+ arrowHead.addTo(mapsOverview.map);
+ state.windArrows.push(arrowHead);
+}
+
+const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
+
+function setMapsFullscreen(active) {
+ const wrap = document.querySelector('#tab-maps .maps-wrap');
+ if (!wrap) return;
+ wrap.classList.toggle('is-fullscreen', active);
+ document.body.classList.toggle('map-fullscreen-lock', active);
+ if (mapsFullscreenBtn) {
+ mapsFullscreenBtn.textContent = active ? 'Exit fullscreen' : 'Fullscreen map';
+ }
+ setTimeout(() => {
+ if (mapsOverview?.map) mapsOverview.map.invalidateSize();
+ }, 100);
+}
+
+if (mapsFullscreenBtn) {
+ mapsFullscreenBtn.addEventListener('click', async () => {
+ const wrap = document.querySelector('#tab-maps .maps-wrap');
+ if (!wrap) return;
+
+ if (document.fullscreenElement === wrap) {
+ await document.exitFullscreen().catch(() => {});
+ setMapsFullscreen(false);
+ return;
+ }
+
+ if (wrap.requestFullscreen) {
+ try {
+ await wrap.requestFullscreen();
+ setMapsFullscreen(true);
+ return;
+ } catch {
+ // Some mobile browsers reject Fullscreen API; fallback below.
+ }
+ }
+
+ setMapsFullscreen(!wrap.classList.contains('is-fullscreen'));
+ });
+}
+
+document.addEventListener('fullscreenchange', () => {
+ const wrap = document.querySelector('#tab-maps .maps-wrap');
+ if (!wrap) return;
+ setMapsFullscreen(document.fullscreenElement === wrap);
+});
+
+// ── Rendering ────────────────────────────────────────────────────────────────
+function renderDailyForecast(daily) {
+ const list = document.getElementById('daily-list');
+ const totalDays = daily?.time?.length || 0;
+ const defaultDays = Math.min(7, totalDays);
+ const visibleDays = state.dailyExpanded ? totalDays : defaultDays;
+
+ list.innerHTML = '';
+ for (let i = 0; i < visibleDays; i++) {
+ const dw = wmo(daily.weather_code[i]);
+ const row = document.createElement('div');
+ row.className = 'day-row' + (i === 0 ? ' today' : '');
+ row.innerHTML = `
+ ${formatDayShort(daily.time[i])}
+ ${dw.icon}
+ ${dw.desc}
+ ${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+ ${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `;
+ list.appendChild(row);
+ }
+
+ if (dailyForecastLabel) {
+ dailyForecastLabel.textContent = `${visibleDays}-Day Forecast`;
+ }
+
+ if (dailyForecastToggle) {
+ if (totalDays > defaultDays) {
+ show(dailyForecastToggle);
+ dailyForecastToggle.textContent = state.dailyExpanded
+ ? 'Show 7-day view'
+ : `Show full ${totalDays}-day forecast`;
+ } else {
+ hide(dailyForecastToggle);
+ }
+ }
+}
+
+function summarizeHourly(hourly, startIdx) {
+ if (!hourly?.time?.length || !hourly?.precipitation_probability?.length) {
+ return 'Hourly details unavailable.';
+ }
+
+ let rainIdx = -1;
+ for (let i = startIdx; i < Math.min(startIdx + 24, hourly.time.length); i++) {
+ const pp = Number(hourly.precipitation_probability[i] || 0);
+ const rain = Number(hourly.rain?.[i] || 0);
+ if (pp >= 45 || rain > 0.1) {
+ rainIdx = i;
+ break;
+ }
+ }
+
+ if (rainIdx === -1) {
+ return 'No notable rain expected in the next 24 hours.';
+ }
+ return `Rain likely around ${formatHour(hourly.time[rainIdx])}.`;
+}
+
+function renderWeather(data, city) {
+ const c = data.current;
+ const h = data.hourly;
+ const d = data.daily;
+
+ applyWeatherBackground(c.weather_code, c.is_day);
+
+ document.getElementById('loc-name').textContent = city.name;
+ document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', ');
+
+ const now = new Date();
+ document.getElementById('loc-time').innerHTML =
+ `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` +
+ now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+ const w = wmo(c.weather_code);
+ document.getElementById('cur-icon').textContent = w.icon;
+ document.getElementById('cur-desc').textContent = w.desc;
+ document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m);
+ document.querySelector('.deg').textContent = `°${tempUnit()}`;
+ document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`;
+ document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]);
+ document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]);
+ document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation';
+ document.getElementById('cur-hum').textContent = c.relative_humidity_2m;
+ document.getElementById('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`;
+ document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
+ document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
+ document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
+ document.getElementById('cur-cloud').textContent = c.cloud_cover;
+ document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit();
+ document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit();
+
+ document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]);
+ document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
+ document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
+
+ const nowTs = Date.now();
+ let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs);
+ if (startIdx < 0) startIdx = 0;
+
+ const track = document.getElementById('hourly-track');
+ track.innerHTML = '';
+ for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) {
+ const hw = wmo(h.weather_code[i]);
+ const pp = h.precipitation_probability[i];
+ const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]);
+ const cell = document.createElement('div');
+ cell.className = 'hour-cell' + (i === startIdx ? ' now' : '');
+ cell.innerHTML = `
+ ${i === startIdx ? 'Now' : formatHour(h.time[i])}
+ ${hw.icon}
+ ${tempDisplay(h.temperature_2m[i])}°
+ Feels ${tempDisplay(h.apparent_temperature?.[i])}°
+ ${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `;
+ track.appendChild(cell);
+ }
+
+ if (hourlySummaryEl) {
+ hourlySummaryEl.textContent = summarizeHourly(h, startIdx);
+ }
+
+ renderDailyForecast(d);
+
+ document.getElementById('updated-at').textContent =
+ new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+ radarEmbed.panTo(city.latitude, city.longitude, 6);
+ radarFull.panTo(city.latitude, city.longitude, 6);
+ mapsOverview.panTo(city.latitude, city.longitude, 6);
+ if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
+ updateSourceLocation(city);
+ updateShareUrl(city);
+ persistLastCity(city);
+ refreshMapsOverview();
+
+ show(weatherDiv);
+}
+
+function renderAirQuality(data) {
+ const cur = data?.current || {};
+ const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen']
+ .map(k => cur[k] || 0)
+ .reduce((max, v) => Math.max(max, v), 0);
+
+ document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--';
+ document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--';
+ document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--';
+}
+
+function renderAlerts(data) {
+ const wrap = document.getElementById('alerts-wrap');
+ const list = document.getElementById('alerts-list');
+ const features = data?.features || [];
+ list.innerHTML = '';
+
+ if (!features.length) {
+ hide(wrap);
+ if (alertsEmptyEl) show(alertsEmptyEl);
+ return;
+ }
+
+ if (alertsEmptyEl) hide(alertsEmptyEl);
+
+ features.slice(0, 5).forEach(f => {
+ const p = f.properties || {};
+ const item = document.createElement('div');
+ item.className = 'alert-item';
+ item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`;
+ list.appendChild(item);
+ });
+ show(wrap);
+
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
+ features.forEach(f => {
+ const id = f.id || f.properties?.id;
+ if (!id || state.alertIdsSeen.has(id)) return;
+ state.alertIdsSeen.add(id);
+ const p = f.properties || {};
+ new Notification(`Weather alert: ${p.event || 'Severe weather'}`, {
+ body: p.headline || p.description || 'An alert was issued for your selected location.',
+ });
+ });
+ }
+}
+
+async function loadAll(city, source) {
+ state.currentCity = city;
+ if (source) state.currentSource = source;
+
+ const saved = state.savedCities.find(e => cityKey(e.city) === cityKey(city));
+ if (saved) {
+ saved.lastViewedAt = Date.now();
+ persistSavedCities();
+ renderSavedCities();
+ }
+
+ clearErr();
+ hide(suggestEl);
+ hide(weatherDiv);
+ show(loadingEl);
+
+ try {
+ const [weather, air, alerts] = await Promise.all([
+ fetchWeather(city.latitude, city.longitude, city.timezone),
+ fetchAirQuality(city.latitude, city.longitude, city.timezone),
+ fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })),
+ ]);
+
+ state.currentWeather = weather;
+ state.currentAir = air;
+ state.lastRefreshAt = Date.now();
+ renderWeather(weather, city);
+ renderAirQuality(air);
+ renderAlerts(alerts);
+
+ // Render map overlays
+ if (state.showLightning) renderLightningStrikes(city);
+ if (state.showWind) renderWindArrows(city);
+ } catch (e) {
+ showErr('Failed to load weather data. Please try again.');
+ console.error(e);
+ } finally {
+ hide(loadingEl);
+ }
+}
+
+function getPosition() {
+ return new Promise((resolve, reject) => {
+ if (!navigator.geolocation) {
+ reject(new Error('Geolocation not supported'));
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
+ enableHighAccuracy: false,
+ timeout: 8000,
+ maximumAge: 10 * 60 * 1000,
+ });
+ });
+}
+
+async function autoLocateAndLoad() {
+ try {
+ const pos = await getPosition();
+ const { latitude: lat, longitude: lon } = pos.coords;
+ const city = await buildDetectedCity(lat, lon);
+ searchInput.value = city.name;
+ await loadAll(city, 'auto-detect');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ── Search autocomplete ───────────────────────────────────────────────────────
+let debounceTimer = null;
+
+searchInput.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ const q = searchInput.value.trim();
+ if (q.length < 2) { hide(suggestEl); return; }
+
+ debounceTimer = setTimeout(async () => {
+ try {
+ const data = await geocode(q);
+ const results = data.results || [];
+ suggestEl.innerHTML = '';
+ if (!results.length) { hide(suggestEl); return; }
+
+ results.forEach(r => {
+ const li = document.createElement('li');
+ li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`;
+ li.addEventListener('click', () => {
+ searchInput.value = r.name;
+ hide(suggestEl);
+ loadAll(r, 'search-result');
+ });
+ suggestEl.appendChild(li);
+ });
+ show(suggestEl);
+ } catch {
+ hide(suggestEl);
+ }
+ }, 300);
+});
+
+document.addEventListener('click', e => {
+ if (!e.target.closest('.search-wrap')) hide(suggestEl);
+});
+
+function doSearch() {
+ const q = searchInput.value.trim();
+ if (!q) { showErr('Please enter a city name.'); return; }
+ clearErr();
+ hide(suggestEl);
+
+ geocode(q).then(data => {
+ const results = data.results || [];
+ if (!results.length) { showErr(`No results found for "${q}".`); return; }
+ loadAll(results[0], 'search-result');
+ }).catch(() => showErr('Search failed. Please try again.'));
+}
+
+searchBtn.addEventListener('click', doSearch);
+searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
+
+// ── Geolocation ───────────────────────────────────────────────────────────────
+locateBtn.addEventListener('click', () => {
+ if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
+ clearErr();
+ hide(weatherDiv);
+ show(loadingEl);
+
+ navigator.geolocation.getCurrentPosition(async pos => {
+ const { latitude: lat, longitude: lon } = pos.coords;
+ try {
+ const city = await buildDetectedCity(lat, lon);
+ await loadAll(city, 'gps-button');
+ } catch {
+ showErr('Could not fetch weather for your location.');
+ } finally {
+ hide(loadingEl);
+ }
+ }, () => {
+ hide(loadingEl);
+ showErr('Location access denied.');
+ });
+});
+
+unitsBtn.addEventListener('click', () => {
+ state.units = state.units === 'us' ? 'metric' : 'us';
+ localStorage.setItem(STORAGE_KEYS.units, state.units);
+ updateUnitsButton();
+ if (state.currentWeather && state.currentCity) {
+ renderWeather(state.currentWeather, state.currentCity);
+ renderAirQuality(state.currentAir);
+ }
+});
+
+if (unitsBtnSettings) {
+ unitsBtnSettings.addEventListener('click', () => unitsBtn.click());
+}
+
+shareBtn.addEventListener('click', async () => {
+ if (!state.currentCity) return;
+ try {
+ await navigator.clipboard.writeText(window.location.href);
+ shareBtn.textContent = 'Copied link';
+ setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500);
+ } catch {
+ showErr('Could not copy URL to clipboard.');
+ }
+});
+
+saveCurrentBtn.addEventListener('click', () => {
+ if (!state.currentCity) return;
+ const key = cityKey(state.currentCity);
+ if (state.savedCities.some(c => cityKey(c.city) === key)) return;
+ state.savedCities.push({
+ city: state.currentCity,
+ addedAt: Date.now(),
+ lastViewedAt: Date.now(),
+ isDefault: state.savedCities.length === 0,
+ });
+ persistSavedCities();
+ renderSavedCities();
+});
+
+notifyBtn.addEventListener('click', async () => {
+ if (!('Notification' in window)) {
+ showErr('Notifications are not supported in this browser.');
+ return;
+ }
+ if (!window.isSecureContext) {
+ showErr('Notifications require HTTPS (or localhost).');
+ updateNotifyButton();
+ return;
+ }
+
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission === 'granted') {
+ const subscribed = await ensurePushSubscription();
+ if (!subscribed) {
+ showErr('Push needs VAPID keys configured on server.');
+ }
+ if (state.currentCity) {
+ const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] }));
+ renderAlerts(alerts);
+ }
+ } else if (permission === 'denied') {
+ showErr('Notifications are blocked in browser settings for this site.');
+ }
+ } catch {
+ showErr('Could not request notification permission.');
+ } finally {
+ updateNotifyButton();
+ }
+});
+
+autoDetectToggle.addEventListener('change', () => {
+ state.autoDetect = autoDetectToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect));
+});
+
+lightningToggle.addEventListener('change', () => {
+ state.showLightning = lightningToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
+ lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
+ if (state.currentCity && mapsOverview.map) {
+ clearLightningLayer();
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
+ }
+});
+
+windToggle.addEventListener('change', () => {
+ state.showWind = windToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
+ windStatus.textContent = state.showWind ? 'On' : 'Off';
+ if (state.currentCity && mapsOverview.map) {
+ clearWindArrows();
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
+ }
+});
+
+if (dailyForecastToggle) {
+ dailyForecastToggle.addEventListener('click', () => {
+ if (!state.currentWeather?.daily) return;
+ state.dailyExpanded = !state.dailyExpanded;
+ renderDailyForecast(state.currentWeather.daily);
+ });
+}
+
+if (contrastToggle) {
+ contrastToggle.addEventListener('change', () => {
+ state.highContrast = contrastToggle.checked;
+ localStorage.setItem(STORAGE_KEYS.highContrast, String(state.highContrast));
+ applyHighContrast();
+ });
+}
+
+if (radarOpacityEl) {
+ radarOpacityEl.addEventListener('input', () => {
+ state.radarOpacity = Number(radarOpacityEl.value || 65);
+ localStorage.setItem(STORAGE_KEYS.radarOpacity, String(state.radarOpacity));
+ mapsOverview.setRadarOpacity(state.radarOpacity);
+ });
+}
+
+if (lightningAgeEl) {
+ lightningAgeEl.addEventListener('change', () => {
+ state.lightningAgeMin = Number(lightningAgeEl.value || 15);
+ localStorage.setItem(STORAGE_KEYS.lightningAgeMin, String(state.lightningAgeMin));
+ if (state.showLightning && state.currentCity) {
+ renderLightningStrikes(state.currentCity);
+ }
+ });
+}
+
+if (pushTestBtn) {
+ pushTestBtn.addEventListener('click', async () => {
+ try {
+ await sendTestPush();
+ pushTestBtn.textContent = 'Push sent';
+ setTimeout(() => { pushTestBtn.textContent = 'Send test push'; }, 1500);
+ } catch (e) {
+ showErr(e.message || 'Push test failed.');
+ }
+ });
+}
+
+function startAutoRefresh() {
+ clearInterval(state.autoRefreshTimer);
+ state.autoRefreshTimer = setInterval(() => {
+ if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh');
+ }, 10 * 60 * 1000);
+
+ // Refresh shortly after users return to the tab if data is stale.
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState !== 'visible') return;
+ if (!state.currentCity) return;
+ if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) {
+ loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh');
+ }
+ });
+}
+
+// ── Default city / query link load ───────────────────────────────────────────
+(async () => {
+ updateUnitsButton();
+ updateNotifyButton();
+ syncAutoDetectToggle();
+
+ // Sync lightning/wind and UI polish toggles.
+ lightningToggle.checked = state.showLightning;
+ lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
+ windToggle.checked = state.showWind;
+ windStatus.textContent = state.showWind ? 'On' : 'Off';
+ if (contrastToggle) contrastToggle.checked = state.highContrast;
+ if (lightningAgeEl) lightningAgeEl.value = String(state.lightningAgeMin || 15);
+ if (radarOpacityEl) radarOpacityEl.value = String(state.radarOpacity || 65);
+ applyHighContrast();
+ mapsOverview.setRadarOpacity(state.radarOpacity);
+
+ renderSavedCities();
+ startAutoRefresh();
+
+ // Restore push state if already subscribed.
+ if ('serviceWorker' in navigator && 'PushManager' in window) {
+ try {
+ const reg = await navigator.serviceWorker.ready;
+ const existingSub = await reg.pushManager.getSubscription();
+ state.pushEnabled = Boolean(existingSub);
+ } catch {
+ state.pushEnabled = false;
+ }
+ updateNotifyButton();
+ }
+
+ const qs = new URLSearchParams(window.location.search);
+ const qLat = Number(qs.get('lat'));
+ const qLon = Number(qs.get('lon'));
+ const qName = qs.get('name');
+ if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) {
+ const city = {
+ name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`,
+ latitude: qLat,
+ longitude: qLon,
+ timezone: 'auto',
+ country: '',
+ admin1: ''
+ };
+ searchInput.value = city.name;
+ await loadAll(city, 'shared-link');
+ return;
+ }
+
+ if (state.autoDetect) {
+ const autoLocated = await autoLocateAndLoad();
+ if (autoLocated) return;
+ }
+
+ const defaultSaved = getDefaultSavedEntry();
+ if (defaultSaved?.city) {
+ searchInput.value = defaultSaved.city.name;
+ await loadAll(defaultSaved.city, 'default-saved-city');
+ return;
+ }
+
+ const lastCity = getLastCity();
+ if (lastCity) {
+ searchInput.value = lastCity.name;
+ await loadAll(lastCity, 'last-city');
+ return;
+ }
+
+ try {
+ const data = await geocode('New York');
+ const city = (data.results || [])[0];
+ if (city) {
+ searchInput.value = city.name;
+ await loadAll(city, 'default-city');
+ }
+ } catch {
+ // silent startup fallback
+ }
+})();
diff --git a/.tmp/imgdiff/old/index.html b/.tmp/imgdiff/old/index.html
new file mode 100644
index 0000000..9d9070d
--- /dev/null
+++ b/.tmp/imgdiff/old/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+NeCloud Weather
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fetching weather data…
+
+
+
+
+
+
+
+
+
+
+
+
--°F
+
Feels like --°F
+
↑--° · ↓--°
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Radar
+
+
+
+
+
+
+
+
+ Hourly Forecast
+ next 24 hours
+
+
--
+
+
+
+
+ 7-Day Forecast
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maps
+
+
+
+
+
+
+
+
+
+
+
+
+
Toggle lightning strikes and wind overlay in Settings. Lightning overlay uses active thunderstorm alert geometry.
+
+
+
+
+
+
+
+ Weather Alerts
+
+
+
+
+
+
+
+
No active alerts for this location.
+
+
+
+
+
+
+
+ Saved locations
+
+
+
Use star for default, arrows to reorder.
+
+
+
+
+ Auto-detect location on startup
+
+
+
+
+ Data source location
+ --
+
+
+
+ Default city
+ None
+
+
+
+ Units
+
+
+
+
+ Lightning strikes on map
+
+
+
+
+ Wind overlay on map
+
+
+
+
+ High contrast mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.tmp/imgdiff/old/server.js b/.tmp/imgdiff/old/server.js
new file mode 100644
index 0000000..177207e
--- /dev/null
+++ b/.tmp/imgdiff/old/server.js
@@ -0,0 +1,474 @@
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const webpush = require('web-push');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+app.use(express.json({ limit: '256kb' }));
+
+// Lightweight request logging for observability.
+app.use((req, res, next) => {
+ const started = Date.now();
+ res.on('finish', () => {
+ const ms = Date.now() - started;
+ console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
+ });
+ next();
+});
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) {
+ let lastErr = null;
+ for (let i = 0; i < attempts; i++) {
+ try {
+ const r = await fetch(url, options);
+ if (!r.ok) {
+ const text = await r.text().catch(() => '');
+ throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
+ }
+ return await r.json();
+ } catch (e) {
+ lastErr = e;
+ if (i < attempts - 1) {
+ await sleep(initialDelayMs * Math.pow(2, i));
+ }
+ }
+ }
+ throw lastErr || new Error('Unknown upstream fetch error');
+}
+
+// --------------- Persistent data storage ---------------
+const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
+const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
+const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
+
+try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
+
+function loadSubscriptions() {
+ try {
+ const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
+ const map = new Map();
+ for (const entry of arr) {
+ if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
+ }
+ return map;
+ } catch (_) { return new Map(); }
+}
+
+function saveSubscriptions() {
+ try {
+ fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
+ } catch (e) { console.error('Failed to save subscriptions:', e.message); }
+}
+
+const subscriptions = loadSubscriptions();
+if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
+
+function loadSentAlertIds() {
+ try {
+ return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
+ } catch (_) { return new Map(); }
+}
+
+function saveSentAlertIds() {
+ try {
+ fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
+ } catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
+}
+
+const sentAlertIds = loadSentAlertIds();
+// --------------------------------------------------------
+
+function getVapidConfig() {
+ const publicKey = process.env.VAPID_PUBLIC_KEY || '';
+ const privateKey = process.env.VAPID_PRIVATE_KEY || '';
+ const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us';
+ return { publicKey, privateKey, subject };
+}
+
+function pushConfigured() {
+ const cfg = getVapidConfig();
+ return Boolean(cfg.publicKey && cfg.privateKey);
+}
+
+function ensurePushConfigured() {
+ const cfg = getVapidConfig();
+ if (!cfg.publicKey || !cfg.privateKey) {
+ return false;
+ }
+ webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey);
+ return true;
+}
+
+// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
+app.use('/', (req, res, next) => {
+ if (req.path === '/' || req.path.endsWith('.html')) {
+ res.setHeader('Cache-Control', 'no-store');
+ } else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') {
+ // Short-lived cache — browser revalidates every 60 s but still gets a 304 fast
+ res.setHeader('Cache-Control', 'no-cache');
+ }
+ next();
+});
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.get('/healthz', (_req, res) => {
+ res.json({
+ ok: true,
+ uptimeSec: Math.round(process.uptime()),
+ pushConfigured: pushConfigured(),
+ subscriptionCount: subscriptions.size,
+ now: new Date().toISOString(),
+ });
+});
+
+// Geocoding proxy
+app.get('/api/geocode', async (req, res) => {
+ const { q } = req.query;
+ if (!q) return res.status(400).json({ error: 'Missing query' });
+ try {
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
+ const data = await fetchJsonWithRetry(url);
+ res.json(data);
+ } catch (e) {
+ res.status(500).json({ error: 'Geocoding failed', detail: e.message });
+ }
+});
+
+// Reverse geocoding proxy
+app.get('/api/reverse-geocode', async (req, res) => {
+ const { lat, lon } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+
+ try {
+ // Prefer Open-Meteo reverse geocoding for stable city/region labels.
+ const omParams = new URLSearchParams({
+ latitude: String(lat),
+ longitude: String(lon),
+ count: '1',
+ language: 'en',
+ format: 'json',
+ });
+
+ const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
+ if (omData) {
+ const top = omData?.results?.[0];
+ if (top) {
+ return res.json({
+ name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
+ admin1: top.admin1 || top.admin2 || '',
+ country: top.country || '',
+ latitude: Number(lat),
+ longitude: Number(lon),
+ timezone: top.timezone || 'auto',
+ display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
+ });
+ }
+ }
+
+ const params = new URLSearchParams({
+ format: 'jsonv2',
+ lat: String(lat),
+ lon: String(lon),
+ zoom: '10',
+ addressdetails: '1',
+ });
+
+ const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/json',
+ },
+ });
+
+ const address = data.address || {};
+ const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
+
+ res.json({
+ name,
+ admin1: address.state || address.region || address.county || '',
+ country: address.country || '',
+ latitude: Number(lat),
+ longitude: Number(lon),
+ timezone: 'auto',
+ display_name: data.display_name || '',
+ });
+ } catch (e) {
+ res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
+ }
+});
+
+// Weather proxy
+app.get('/api/weather', async (req, res) => {
+ const { lat, lon, tz, units } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ const timezone = tz || 'auto';
+ const useMetric = units === 'metric';
+ try {
+ const params = new URLSearchParams({
+ latitude: lat, longitude: lon, timezone,
+ current: [
+ 'temperature_2m','relative_humidity_2m','apparent_temperature',
+ 'is_day','precipitation','weather_code','cloud_cover',
+ 'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
+ ].join(','),
+ hourly: [
+ 'temperature_2m','precipitation_probability','weather_code',
+ 'wind_speed_10m','apparent_temperature','rain','snowfall'
+ ].join(','),
+ daily: [
+ 'weather_code','temperature_2m_max','temperature_2m_min',
+ 'precipitation_sum','precipitation_probability_max',
+ 'wind_speed_10m_max','sunrise','sunset','uv_index_max'
+ ].join(','),
+ forecast_days: 16,
+ wind_speed_unit: useMetric ? 'kmh' : 'mph',
+ temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
+ precipitation_unit: useMetric ? 'mm' : 'inch'
+ });
+ const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
+ res.json(data);
+ } catch (e) {
+ res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
+ }
+});
+
+// Open-Meteo Air Quality proxy
+app.get('/api/air-quality', async (req, res) => {
+ const { lat, lon, tz } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ try {
+ const params = new URLSearchParams({
+ latitude: lat,
+ longitude: lon,
+ timezone: tz || 'auto',
+ current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
+ forecast_days: 1,
+ });
+ const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
+ res.json(data);
+ } catch (e) {
+ res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
+ }
+});
+
+// NWS alerts proxy (US only)
+app.get('/api/alerts', async (req, res) => {
+ const { lat, lon } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ try {
+ const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/geo+json',
+ },
+ });
+ res.json(data);
+ } catch (e) {
+ res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
+ }
+});
+
+// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
+app.get('/api/lightning', async (req, res) => {
+ const { lat, lon, age } = req.query;
+ if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ const ageMinutes = Number(age || 15);
+
+ try {
+ const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/geo+json',
+ },
+ });
+
+ const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
+ const features = (data?.features || []).filter(f => {
+ const p = f?.properties || {};
+ const event = String(p.event || '').toLowerCase();
+ const relevant = event.includes('thunderstorm') || event.includes('lightning');
+ if (!relevant) return false;
+ const ts = Date.parse(p.sent || p.onset || p.effective || '');
+ if (Number.isNaN(ts)) return true;
+ return ts >= cutoff;
+ });
+
+ res.json({
+ source: 'weather.gov alerts',
+ count: features.length,
+ features,
+ ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
+ });
+ } catch (e) {
+ res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
+ }
+});
+
+app.get('/api/push/public-key', (_req, res) => {
+ const { publicKey } = getVapidConfig();
+ res.json({
+ enabled: pushConfigured(),
+ publicKey: publicKey || null,
+ });
+});
+
+app.post('/api/push/subscribe', (req, res) => {
+ const { subscription, location } = req.body || {};
+ if (!subscription?.endpoint) {
+ return res.status(400).json({ error: 'Missing subscription endpoint' });
+ }
+ subscriptions.set(subscription.endpoint, {
+ subscription,
+ location: location || null,
+ createdAt: Date.now(),
+ });
+ saveSubscriptions();
+ res.json({ ok: true, count: subscriptions.size });
+});
+
+app.post('/api/push/unsubscribe', (req, res) => {
+ const { endpoint } = req.body || {};
+ if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
+ subscriptions.delete(endpoint);
+ saveSubscriptions();
+ res.json({ ok: true, count: subscriptions.size });
+});
+
+app.post('/api/push/test', async (req, res) => {
+ if (!ensurePushConfigured()) {
+ return res.status(400).json({
+ error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.',
+ });
+ }
+
+ const payload = JSON.stringify({
+ title: req.body?.title || 'Weather test notification',
+ body: req.body?.body || 'Push notifications are working.',
+ url: req.body?.url || '/',
+ ts: Date.now(),
+ });
+
+ let sent = 0;
+ let removed = 0;
+ const errors = [];
+
+ const entries = Array.from(subscriptions.values());
+ for (const entry of entries) {
+ try {
+ await webpush.sendNotification(entry.subscription, payload);
+ sent++;
+ } catch (e) {
+ const statusCode = e?.statusCode;
+ if (statusCode === 404 || statusCode === 410) {
+ subscriptions.delete(entry.subscription.endpoint);
+ removed++;
+ saveSubscriptions();
+ } else {
+ errors.push(String(e.message || e));
+ }
+ }
+ }
+
+ res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
+});
+
+// --------------- Background NWS alert poller ---------------
+// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
+// Sends push notifications to all subscribers for any new active NWS alerts
+// at their saved location. Deduplicates by alert ID (persisted to disk).
+
+function cleanSentAlertIds() {
+ const cutoff = Date.now() - 48 * 60 * 60 * 1000;
+ for (const [id, ts] of sentAlertIds) {
+ if (ts < cutoff) sentAlertIds.delete(id);
+ }
+}
+
+async function alertPoller() {
+ if (!pushConfigured() || !ensurePushConfigured()) return;
+ if (subscriptions.size === 0) return;
+
+ cleanSentAlertIds();
+
+ // Group subscribers by rounded lat/lon so we don't spam the NWS API.
+ const byLocation = new Map();
+ for (const entry of subscriptions.values()) {
+ const loc = entry.location;
+ if (!loc?.latitude || !loc?.longitude) continue;
+ const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
+ if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
+ byLocation.get(key).entries.push(entry);
+ }
+
+ let savedIds = false;
+ for (const { loc, entries } of byLocation.values()) {
+ try {
+ const data = await fetchJsonWithRetry(
+ `https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
+ { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
+ ).catch(() => null);
+ if (!data?.features?.length) continue;
+
+ for (const feature of data.features) {
+ const id = feature?.properties?.id;
+ if (!id || sentAlertIds.has(id)) continue;
+
+ const props = feature.properties || {};
+ const title = props.event || 'Weather Alert';
+ const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
+ const headline = props.headline || props.description?.slice(0, 120) || title;
+ const payload = JSON.stringify({
+ title,
+ body: `${area}: ${headline}`,
+ url: '/',
+ ts: Date.now(),
+ });
+
+ let anySuccess = false;
+ for (const entry of entries) {
+ try {
+ await webpush.sendNotification(entry.subscription, payload);
+ anySuccess = true;
+ } catch (e) {
+ if (e?.statusCode === 404 || e?.statusCode === 410) {
+ subscriptions.delete(entry.subscription.endpoint);
+ saveSubscriptions();
+ }
+ }
+ }
+
+ if (anySuccess) {
+ console.log(`Alert push sent: [${id}] ${title} → ${area}`);
+ sentAlertIds.set(id, Date.now());
+ savedIds = true;
+ }
+ }
+ } catch (e) {
+ console.error('Alert poller error:', e.message);
+ }
+ }
+
+ if (savedIds) saveSentAlertIds();
+}
+
+const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
+setInterval(alertPoller, POLL_INTERVAL_MS);
+// -----------------------------------------------------------
+
+// RainViewer timestamps proxy
+app.get('/api/radar/times', async (req, res) => {
+ try {
+ const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
+ res.json(data);
+ } catch (e) {
+ res.status(500).json({ error: 'Radar times failed', detail: e.message });
+ }
+});
+
+app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));
diff --git a/.tmp/imgdiff/old/style.css b/.tmp/imgdiff/old/style.css
new file mode 100644
index 0000000..71c96f7
--- /dev/null
+++ b/.tmp/imgdiff/old/style.css
@@ -0,0 +1,1031 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --ink: #102539;
+ --ink-soft: #476a82;
+ --ink-muted: #6f90a4;
+ --paper: #e7f2f8;
+ --paper-deep: #d6e7f1;
+ --surface: rgba(255, 255, 255, 0.9);
+ --surface-soft: rgba(255, 255, 255, 0.74);
+ --line: rgba(96, 138, 166, 0.35);
+ --brand: #f0722f;
+ --brand-strong: #d75f1f;
+ --info: #1d87bf;
+ --ok: #1f9c79;
+ --danger: #c84b2f;
+ --radius-xl: 22px;
+ --radius-lg: 16px;
+ --radius-md: 12px;
+ --shadow-lg: 0 14px 36px rgba(11, 35, 52, 0.16);
+ --shadow-md: 0 8px 22px rgba(11, 35, 52, 0.12);
+ --ui: 'Manrope', 'Avenir Next', 'Segoe UI', sans-serif;
+ --display: 'Playfair Display', Georgia, serif;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --ink: #ecf6fd;
+ --ink-soft: #bed1dd;
+ --ink-muted: #92aebf;
+ --paper: #0b1721;
+ --paper-deep: #122433;
+ --surface: rgba(26, 46, 61, 0.88);
+ --surface-soft: rgba(28, 49, 65, 0.7);
+ --line: rgba(118, 152, 176, 0.34);
+ --brand: #ff8d4d;
+ --brand-strong: #ef7330;
+ --info: #63c2ef;
+ --ok: #38c89f;
+ --danger: #f77257;
+ --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.45);
+ --shadow-md: 0 9px 22px rgba(0, 0, 0, 0.35);
+ }
+}
+
+html, body {
+ min-height: 100%;
+ font-family: var(--ui);
+ font-size: 18px;
+ line-height: 1.45;
+ color: var(--ink);
+ background: var(--paper);
+ overflow-x: hidden;
+}
+
+body {
+ background:
+ radial-gradient(circle at 8% -6%, rgba(255, 255, 255, 0.86), transparent 46%),
+ radial-gradient(circle at 100% 0%, rgba(111, 184, 222, 0.25), transparent 35%),
+ linear-gradient(180deg, var(--paper) 0%, var(--paper-deep) 100%);
+ transition: background 0.6s ease;
+}
+
+body.weather-clear {
+ background:
+ radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.95), transparent 44%),
+ radial-gradient(circle at 95% 5%, rgba(255, 214, 131, 0.4), transparent 30%),
+ linear-gradient(180deg, #dff2ff 0%, #cbe6f7 45%, #b6d9ee 100%);
+}
+
+body.weather-cloud {
+ background:
+ radial-gradient(circle at 10% -5%, rgba(255, 255, 255, 0.75), transparent 46%),
+ radial-gradient(circle at 85% 0%, rgba(175, 203, 220, 0.35), transparent 35%),
+ linear-gradient(180deg, #d7e8f1 0%, #c6dbe7 55%, #b5cfde 100%);
+}
+
+body.weather-rain {
+ background:
+ radial-gradient(circle at 20% -8%, rgba(196, 220, 235, 0.45), transparent 45%),
+ radial-gradient(circle at 100% 0%, rgba(109, 162, 191, 0.32), transparent 35%),
+ linear-gradient(180deg, #a8c1d2 0%, #8eaabd 55%, #748fa5 100%);
+}
+
+body.weather-snow {
+ background:
+ radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.9), transparent 45%),
+ radial-gradient(circle at 92% 0%, rgba(225, 241, 252, 0.8), transparent 36%),
+ linear-gradient(180deg, #ebf4fa 0%, #dfeef8 55%, #d2e4f1 100%);
+}
+
+body.weather-storm {
+ background:
+ radial-gradient(circle at 30% -10%, rgba(140, 160, 181, 0.36), transparent 44%),
+ radial-gradient(circle at 100% 5%, rgba(80, 111, 142, 0.45), transparent 34%),
+ linear-gradient(180deg, #6f8598 0%, #576d83 55%, #42566c 100%);
+}
+
+body.weather-fog {
+ background:
+ radial-gradient(circle at 15% 0%, rgba(241, 246, 249, 0.9), transparent 44%),
+ radial-gradient(circle at 92% 0%, rgba(214, 224, 231, 0.6), transparent 36%),
+ linear-gradient(180deg, #d9e3e9 0%, #cdd9e0 55%, #c0ced7 100%);
+}
+
+body.weather-night {
+ background:
+ radial-gradient(circle at 15% -10%, rgba(123, 145, 173, 0.2), transparent 44%),
+ radial-gradient(circle at 100% 0%, rgba(74, 104, 131, 0.28), transparent 34%),
+ linear-gradient(180deg, #0b1722 0%, #102131 55%, #13283b 100%);
+}
+
+a { color: var(--info); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.hidden { display: none !important; }
+
+.app {
+ width: 100%;
+ max-width: 100%;
+ padding: calc(0.7rem + env(safe-area-inset-top, 0px)) 0.75rem calc(6.9rem + env(safe-area-inset-bottom, 0px));
+}
+
+/* Header */
+.masthead {
+ position: sticky;
+ top: env(safe-area-inset-top, 0px);
+ z-index: 220;
+ padding: 0.55rem 0.55rem 0.45rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ background: linear-gradient(180deg, var(--surface) 0%, var(--surface-soft) 100%);
+ backdrop-filter: blur(9px);
+ box-shadow: var(--shadow-md);
+ margin-bottom: 0.6rem;
+}
+
+.masthead h1 {
+ font-family: var(--display);
+ font-size: 1.7rem;
+ letter-spacing: -0.3px;
+ margin-bottom: 0.35rem;
+ color: #111;
+ text-shadow:
+ 0 1px 2px rgba(255,255,255,0.7),
+ 0 0 8px rgba(255,255,255,0.5);
+}
+
+.tagline {
+ display: block;
+ margin-top: 0.35rem;
+ font-size: 0.76rem;
+ letter-spacing: 0.04em;
+ color: rgba(0,0,0,0.65);
+ text-shadow: 0 1px 2px rgba(255,255,255,0.6);
+}
+
+/* Bottom nav */
+.tabs {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100vw;
+ max-width: 100vw;
+ z-index: 300;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 0.22rem;
+ padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
+ background: rgba(9, 28, 40, 0.92);
+ backdrop-filter: blur(12px);
+ border-top: 1px solid rgba(121, 165, 191, 0.36);
+}
+
+@supports (bottom: env(safe-area-inset-bottom)) {
+ .tabs {
+ bottom: 0;
+ padding-bottom: calc(0.45rem + env(safe-area-inset-bottom));
+ }
+}
+
+.tab {
+ border: none;
+ border-radius: 12px;
+ min-width: 0;
+ min-height: 58px;
+ padding: 0.32rem 0.15rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.24rem;
+ background: rgba(255, 255, 255, 0.04);
+ color: #d8eaf4;
+ cursor: pointer;
+ font-family: var(--ui);
+}
+
+.tab-icon {
+ font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
+ font-size: 1.18rem;
+ line-height: 1;
+}
+
+.tab-text {
+ font-size: 0.67rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ font-weight: 600;
+}
+
+.tab.active {
+ color: #fff;
+ background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
+}
+
+/* Search */
+.search-wrap {
+ margin-bottom: 0.4rem;
+}
+
+.search-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: var(--surface);
+ box-shadow: var(--shadow-md);
+}
+
+button, input, select { font-family: var(--ui); }
+
+.search-row input,
+.search-row button,
+.maps-control select,
+.settings-btn {
+ font-size: 16px; /* prevent iOS focus zoom */
+}
+
+.search-row input {
+ flex: 1;
+ min-width: 0;
+ min-height: 46px;
+ border: none;
+ background: transparent;
+ color: var(--ink);
+ padding: 0 0.75rem;
+}
+
+.search-row button {
+ flex: 0 0 auto;
+ min-height: 46px;
+ border: none;
+ border-left: 1px solid var(--line);
+ cursor: pointer;
+ padding: 0 0.65rem;
+}
+
+#search-btn {
+ background: linear-gradient(180deg, #15445f 0%, #11344c 100%);
+ color: #f2f8fc;
+ min-width: 52px;
+ width: 52px;
+ font-size: 0;
+ letter-spacing: 0;
+ text-transform: none;
+ padding: 0;
+}
+
+#search-btn::before {
+ content: '🔎';
+ font-size: 1rem;
+ line-height: 1;
+}
+
+#locate-btn,
+#units-btn {
+ background: var(--surface) !important;
+ color: var(--ink) !important;
+ min-width: 48px;
+}
+
+#locate-btn {
+ font-size: 1.05rem !important;
+}
+
+@media (max-width: 390px) {
+ #search-btn,
+ #locate-btn,
+ #units-btn {
+ min-width: 46px;
+ width: 46px;
+ padding: 0 0.5rem;
+ }
+}
+
+.suggestions {
+ list-style: none;
+ margin-top: 0.3rem;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ box-shadow: var(--shadow-lg);
+}
+
+.suggestions li {
+ padding: 0.72rem 0.8rem;
+ border-bottom: 1px solid var(--line);
+ font-size: 0.88rem;
+ cursor: pointer;
+}
+
+.suggestions li:last-child { border-bottom: none; }
+.suggestions li:hover { background: rgba(29, 135, 191, 0.09); }
+.sug-country { color: var(--ink-muted); font-size: 0.8rem; margin-left: 0.35rem; }
+
+.error {
+ margin-top: 0.35rem;
+ color: var(--danger);
+ font-size: 0.84rem;
+}
+
+/* Generic cards */
+.tab-panel {
+ width: 100%;
+ min-height: calc(100dvh - 11.6rem);
+}
+
+.loc-header,
+.radar-embed-wrap,
+.radar-full-wrap,
+.maps-wrap,
+.alerts-wrap,
+.panel-note,
+.settings-wrap,
+.daily-list,
+.hourly-scroll,
+.stats-bar,
+.astro-grid,
+.aq-grid {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xl);
+ background: var(--surface-soft);
+ box-shadow: var(--shadow-md);
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+ padding: 1rem 0.35rem;
+}
+
+.loading-dot {
+ width: 11px;
+ height: 11px;
+ border-radius: 50%;
+ background: var(--brand);
+ animation: pulse 1.1s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.4; transform: scale(0.9); }
+ 50% { opacity: 1; transform: scale(1.08); }
+}
+
+.loc-header {
+ padding: 0.9rem;
+ margin-bottom: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.loc-name {
+ font-family: var(--display);
+ font-size: 1.5rem;
+ line-height: 1.1;
+}
+
+.loc-sub,
+.loc-time {
+ font-size: 0.8rem;
+ color: var(--ink-muted);
+}
+
+.loc-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.32rem;
+}
+
+.link-btn {
+ border: none;
+ background: none;
+ color: var(--info);
+ cursor: pointer;
+ font-size: 0.84rem;
+}
+
+.section-head {
+ display: flex;
+ flex-direction: column;
+ gap: 0.22rem;
+ margin: 0.35rem 0.45rem 0.5rem;
+}
+
+.section-label {
+ font-size: 0.74rem;
+ color: var(--ink-soft);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 700;
+}
+
+.section-note {
+ color: var(--ink-muted);
+ font-size: 0.79rem;
+}
+
+.section-actions {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.62rem;
+}
+
+/* Current */
+.current-grid {
+ display: grid;
+ gap: 0.6rem;
+ margin-bottom: 0.75rem;
+}
+
+.current-grid > * {
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: var(--surface);
+ padding: 1rem 0.85rem;
+ box-shadow: var(--shadow-md);
+}
+
+.temp-big {
+ font-family: var(--display);
+ font-size: 3.75rem;
+ line-height: 0.92;
+ letter-spacing: -1.5px;
+}
+
+.deg {
+ font-size: 1.35rem;
+ color: var(--ink-soft);
+ vertical-align: super;
+}
+
+.feels,
+.hi-lo,
+.cond-sub {
+ font-size: 0.84rem;
+ color: var(--ink-soft);
+}
+
+.current-cond { display: flex; flex-direction: column; justify-content: center; }
+.cond-icon { font-size: 2.2rem; margin-bottom: 0.15rem; }
+.cond-desc { font-size: 1.08rem; font-weight: 700; }
+
+/* Stat blocks */
+.stats-bar,
+.astro-grid,
+.aq-grid {
+ display: grid;
+ gap: 0.52rem;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ margin-bottom: 0.78rem;
+}
+
+.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+
+.stat,
+.astro-card,
+.aq-card,
+.maps-card,
+.settings-row,
+.alert-item {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ box-shadow: var(--shadow-md);
+}
+
+.stat { padding: 0.78rem 0.58rem; text-align: center; }
+
+.stat-label {
+ font-size: 0.64rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-muted);
+ margin-bottom: 0.2rem;
+ font-weight: 700;
+}
+
+.stat-val { font-size: 1.02rem; font-weight: 700; }
+.unit { font-size: 0.72rem; color: var(--ink-soft); }
+
+.astro-card,
+.aq-card,
+.maps-card { padding: 0.74rem 0.7rem; }
+.astro-val,
+.aq-val,
+.maps-value { font-size: 0.98rem; font-weight: 700; }
+
+/* Radar & maps */
+.radar-embed-wrap,
+.radar-full-wrap,
+.maps-wrap,
+.alerts-wrap,
+.settings-wrap {
+ padding: 0.7rem;
+ margin-bottom: 0.85rem;
+}
+
+.radar-map,
+.map-overview {
+ width: 100%;
+ height: 235px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line);
+ overflow: hidden;
+}
+
+.radar-map-full {
+ height: calc(100dvh - 20rem);
+ min-height: 310px;
+}
+
+.radar-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+ margin-top: 0.62rem;
+}
+
+.radar-btn {
+ width: 46px;
+ height: 46px;
+ border: none;
+ border-radius: 12px;
+ background: #0f3248;
+ color: #f5fbfe;
+ cursor: pointer;
+ font-size: 0.86rem;
+}
+
+.layer-btn { background: var(--brand); }
+
+.radar-timeline {
+ order: 3;
+ flex-basis: 100%;
+ height: 32px;
+ display: flex;
+ gap: 2px;
+}
+
+.tl-tick {
+ flex: 1;
+ border-radius: 7px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.52);
+ cursor: pointer;
+}
+
+.tl-tick.active { background: var(--info); border-color: var(--info); }
+.tl-tick.forecast { opacity: 0.6; }
+
+.radar-ts { font-size: 0.75rem; color: var(--ink-soft); }
+.radar-legend { display: inline-flex; align-items: center; gap: 0.35rem; }
+.legend-label { font-size: 0.64rem; color: var(--ink-muted); }
+.legend-bar {
+ width: 84px;
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: linear-gradient(to right, #00d8ff, #00a0e0, #0070d0, #00c800, #ffff00, #ff6000, #ff0000);
+}
+
+.radar-attribution,
+.footer-note {
+ font-size: 0.74rem;
+ color: var(--ink-muted);
+ margin-bottom: 0.8rem;
+}
+
+/* Hourly */
+.hourly-summary {
+ margin: 0.22rem 0.38rem 0.45rem;
+ font-size: 0.8rem;
+ color: var(--ink-soft);
+}
+
+.hourly-scroll {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ margin-bottom: 0.85rem;
+ padding: 0.42rem;
+}
+
+.hourly-track { display: flex; min-width: max-content; }
+
+.hour-cell {
+ min-width: 90px;
+ padding: 0.72rem 0.45rem;
+ text-align: center;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ margin-right: 0.3rem;
+}
+
+.hour-cell.now {
+ background: linear-gradient(180deg, rgba(29, 135, 191, 0.18) 0%, var(--surface) 100%);
+}
+
+.hour-time { font-size: 0.7rem; color: var(--ink-muted); margin-bottom: 0.28rem; }
+.hour-icon { font-size: 1.35rem; margin-bottom: 0.18rem; }
+.hour-temp { font-size: 1rem; font-weight: 700; }
+.hour-feels,
+.hour-precip { font-size: 0.68rem; color: var(--ink-soft); }
+
+/* Daily */
+.daily-list {
+ padding: 0.4rem;
+ margin-bottom: 0.9rem;
+}
+
+.day-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-areas:
+ 'name temps'
+ 'desc desc';
+ gap: 0.28rem 0.55rem;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ padding: 0.72rem;
+ margin-bottom: 0.34rem;
+}
+
+.day-row:last-child { margin-bottom: 0; }
+.day-row.today { outline: 1px solid rgba(29, 135, 191, 0.45); }
+.day-name { grid-area: name; font-size: 0.9rem; font-weight: 700; }
+.day-icon { display: none; }
+.day-desc {
+ grid-area: desc;
+ font-size: 0.78rem;
+ color: var(--ink-soft);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.day-temps { grid-area: temps; font-size: 0.88rem; font-weight: 700; }
+.day-lo { color: var(--ink-muted); }
+.day-meta { display: none; }
+
+/* Lists and settings */
+.panel-note {
+ padding: 0.75rem;
+ font-size: 0.82rem;
+ color: var(--ink-soft);
+}
+
+.alerts-list,
+.saved-locations,
+.maps-controls,
+.maps-meta {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.alert-item {
+ padding: 0.72rem;
+ display: grid;
+ gap: 0.22rem;
+ font-size: 0.82rem;
+}
+
+.maps-control {
+ display: grid;
+ gap: 0.3rem;
+ font-size: 0.68rem;
+ color: var(--ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.maps-control input,
+.maps-control select,
+.settings-btn {
+ min-height: 44px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--surface);
+ color: var(--ink);
+ padding: 0.35rem 0.55rem;
+}
+
+#maps-fullscreen-btn {
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+body.map-fullscreen-lock {
+ overflow: hidden;
+}
+
+.maps-wrap.is-fullscreen {
+ position: fixed;
+ inset: 0;
+ z-index: 520;
+ margin: 0;
+ border-radius: 0;
+ border: none;
+ background: var(--paper);
+ overflow-y: auto;
+ padding: calc(0.65rem + env(safe-area-inset-top, 0px)) 0.65rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
+}
+
+.maps-wrap.is-fullscreen .map-overview {
+ height: calc(100dvh - 210px);
+ min-height: 320px;
+}
+
+.wind-legend {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.42rem;
+ margin-top: 0.52rem;
+ font-size: 0.71rem;
+ color: var(--ink-muted);
+}
+
+.wind-legend-scale {
+ width: 126px;
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df);
+}
+
+.saved-wrap { margin-top: 0.1rem; }
+
+.saved-head {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.3rem;
+ margin-bottom: 0.32rem;
+ color: var(--ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ font-size: 0.67rem;
+}
+
+.saved-help {
+ margin-bottom: 0.38rem;
+ font-size: 0.74rem;
+ color: var(--ink-muted);
+}
+
+.saved-item {
+ display: grid;
+ grid-template-columns: 36px 1fr 36px 36px 36px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.saved-chip,
+.saved-remove,
+.saved-default,
+.saved-move {
+ min-height: 44px;
+ border: none;
+ background: transparent;
+ color: var(--ink);
+ cursor: pointer;
+}
+
+.saved-chip {
+ padding: 0 0.58rem;
+ text-align: left;
+ border-left: 1px solid var(--line);
+ border-right: 1px solid var(--line);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.saved-default { color: var(--brand); }
+.saved-move { border-right: 1px solid var(--line); }
+.saved-remove { border-right: 1px solid var(--line); color: var(--ink-muted); }
+.saved-move:disabled { opacity: 0.35; cursor: not-allowed; }
+.saved-meta { display: none; }
+
+.settings-wrap { display: grid; gap: 0.62rem; }
+
+.settings-row {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.45rem;
+ padding: 0.74rem;
+}
+
+.settings-label {
+ font-size: 0.72rem;
+ color: var(--ink-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 700;
+}
+
+.settings-value { font-size: 0.88rem; color: var(--ink); }
+
+.auto-detect-toggle,
+.toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.48rem;
+ color: var(--ink-soft);
+ font-size: 0.9rem;
+}
+
+.auto-detect-toggle input,
+.toggle-label input { accent-color: var(--brand); }
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+
+/* Leaflet */
+.leaflet-container { background: #1a2430; }
+.leaflet-control-zoom a {
+ background: var(--surface) !important;
+ color: var(--ink) !important;
+ border-color: var(--line) !important;
+}
+
+/* High contrast */
+body.high-contrast {
+ --ink: #0a0a0a;
+ --ink-soft: #1f1f1f;
+ --ink-muted: #3b3b3b;
+ --paper: #fff;
+ --paper-deep: #f2f2f2;
+ --surface: #fff;
+ --surface-soft: #fff;
+ --line: #111;
+ --brand: #a93a00;
+ --brand-strong: #8d2f00;
+ --info: #005a82;
+}
+
+/* Desktop second */
+@media (min-width: 940px) {
+ html, body { font-size: 16px; }
+
+ .app {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 1.35rem 1.3rem calc(6.9rem + env(safe-area-inset-bottom, 0px));
+ }
+
+ .masthead {
+ position: static;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding: 0;
+ border: none;
+ box-shadow: none;
+ background: transparent;
+ backdrop-filter: none;
+ }
+
+ .masthead h1 { margin: 0; font-size: 2rem; }
+
+ .tabs {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100vw;
+ max-width: 100vw;
+ z-index: 300;
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 0.22rem;
+ padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
+ background: rgba(9, 28, 40, 0.92);
+ backdrop-filter: blur(12px);
+ border-top: 1px solid rgba(121, 165, 191, 0.36);
+ border-radius: 0;
+ }
+
+ .tab {
+ min-height: 58px;
+ flex-direction: column;
+ border-radius: 12px;
+ border-right: none;
+ padding: 0.32rem 0.15rem;
+ background: rgba(255, 255, 255, 0.04);
+ color: #d8eaf4;
+ gap: 0.24rem;
+ }
+
+ .tab-icon {
+ font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
+ font-size: 1.18rem;
+ line-height: 1;
+ }
+
+ .tab-text {
+ font-size: 0.67rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ font-weight: 600;
+ }
+
+ .tab.active {
+ color: #fff;
+ background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
+ }
+
+ .tagline { margin: 0; }
+
+ .search-row input { min-height: 48px; }
+ #search-btn { min-width: 140px; }
+
+ .loc-header {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+ }
+
+ .loc-actions { align-items: flex-end; }
+
+ .current-grid { grid-template-columns: 1fr 1fr; }
+ .stats-bar { grid-template-columns: repeat(6, minmax(0, 1fr)); }
+
+ .astro-grid,
+ .aq-grid,
+ .maps-meta,
+ .maps-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+
+ .maps-controls { grid-template-columns: 1fr 1fr; }
+ .alerts-list { grid-template-columns: 1fr 1fr; }
+
+ .day-row {
+ grid-template-columns: 120px 44px 1fr 84px 64px;
+ grid-template-areas: none;
+ align-items: center;
+ gap: 0.8rem;
+ }
+
+ .day-name { grid-area: auto; }
+ .day-icon { display: block; font-size: 1.36rem; text-align: center; }
+ .day-desc { grid-area: auto; white-space: normal; }
+ .day-temps { grid-area: auto; text-align: right; }
+ .day-meta {
+ display: block;
+ text-align: right;
+ font-size: 0.67rem;
+ color: var(--ink-muted);
+ line-height: 1.35;
+ }
+
+ .saved-item { grid-template-columns: 36px 1fr 36px 36px 36px auto; }
+ .saved-meta {
+ display: block;
+ padding: 0 0.58rem;
+ font-size: 0.68rem;
+ color: var(--ink-muted);
+ }
+
+ .settings-row {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .section-head {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ .radar-timeline {
+ order: 0;
+ flex-basis: auto;
+ flex: 1;
+ min-width: 180px;
+ }
+
+ .radar-ts { margin-left: auto; }
+
+ .radar-map-full {
+ height: calc(100vh - 300px);
+ min-height: 440px;
+ }
+
+ .tab-panel { min-height: auto; }
+}
diff --git a/.tmp/imgdiff/old/sw.js b/.tmp/imgdiff/old/sw.js
new file mode 100644
index 0000000..22c715d
--- /dev/null
+++ b/.tmp/imgdiff/old/sw.js
@@ -0,0 +1,123 @@
+'use strict';
+
+const CACHE = 'weather-v4';
+
+// App shell — cached on install for offline use
+const PRECACHE = [
+ '/',
+ '/app.js?v=4',
+ '/style.css?v=4',
+ '/manifest.json',
+ '/icons/icon.svg'
+];
+
+self.addEventListener('install', event => {
+ event.waitUntil(
+ caches.open(CACHE)
+ .then(cache => cache.addAll(PRECACHE))
+ .then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', event => {
+ event.waitUntil(
+ caches.keys()
+ .then(keys => Promise.all(
+ keys.filter(k => k !== CACHE).map(k => caches.delete(k))
+ ))
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', event => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET, API calls, and map/radar tile requests
+ if (
+ request.method !== 'GET' ||
+ url.pathname.startsWith('/api/') ||
+ url.hostname.includes('rainviewer') ||
+ url.hostname.includes('openstreetmap') ||
+ url.hostname.includes('cartodb') ||
+ url.hostname.includes('basemaps')
+ ) {
+ return;
+ }
+
+ const isAppShellRequest =
+ request.mode === 'navigate' ||
+ request.destination === 'document' ||
+ request.destination === 'script' ||
+ request.destination === 'style';
+
+ // Prefer fresh app shell assets so UI updates are visible immediately.
+ if (isAppShellRequest) {
+ event.respondWith(
+ fetch(request)
+ .then(response => {
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE).then(cache => cache.put(request, clone));
+ }
+ return response;
+ })
+ .catch(() => caches.match(request).then(cached => cached || caches.match('/')))
+ );
+ return;
+ }
+
+ event.respondWith(
+ caches.match(request).then(cached => {
+ if (cached) return cached;
+ return fetch(request).then(response => {
+ // Only cache successful same-origin and trusted CDN responses
+ if (response.ok && (
+ url.origin === self.location.origin ||
+ url.hostname.includes('googleapis') ||
+ url.hostname.includes('gstatic') ||
+ url.hostname.includes('unpkg.com')
+ )) {
+ const clone = response.clone();
+ caches.open(CACHE).then(cache => cache.put(request, clone));
+ }
+ return response;
+ });
+ })
+ );
+});
+
+self.addEventListener('push', event => {
+ let payload = { title: 'Weather alert', body: 'New weather update available.', url: '/' };
+ try {
+ const data = event.data ? event.data.json() : null;
+ if (data) payload = { ...payload, ...data };
+ } catch {
+ // Ignore malformed payload and use defaults.
+ }
+
+ event.waitUntil(
+ self.registration.showNotification(payload.title, {
+ body: payload.body,
+ icon: '/icons/icon.svg',
+ badge: '/icons/icon.svg',
+ data: { url: payload.url || '/' },
+ })
+ );
+});
+
+self.addEventListener('notificationclick', event => {
+ event.notification.close();
+ const target = event.notification?.data?.url || '/';
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
+ const existing = windowClients.find(c => c.url.includes(self.location.origin));
+ if (existing) {
+ existing.focus();
+ existing.navigate(target);
+ return;
+ }
+ return clients.openWindow(target);
+ })
+ );
+});
diff --git a/.tmp/imgdiff/server.diff b/.tmp/imgdiff/server.diff
new file mode 100644
index 0000000..02d1571
Binary files /dev/null and b/.tmp/imgdiff/server.diff differ
diff --git a/.tmp/imgdiff/style.diff b/.tmp/imgdiff/style.diff
new file mode 100644
index 0000000..1236296
Binary files /dev/null and b/.tmp/imgdiff/style.diff differ
diff --git a/.tmp/imgdiff/sw.diff b/.tmp/imgdiff/sw.diff
new file mode 100644
index 0000000..b64e17a
Binary files /dev/null and b/.tmp/imgdiff/sw.diff differ
diff --git a/app/public/app.js b/app/public/app.js
index 873c03d..74e7b7c 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -31,6 +31,16 @@ const STORAGE_KEYS = {
autoDetect: 'weather.autoDetect',
showLightning: 'weather.showLightning',
showWind: 'weather.showWind',
+ showRadarLayer: 'weather.map.radar',
+ showSatelliteLayer: 'weather.map.satellite',
+ showLightningLayer: 'weather.map.lightning',
+ showWindLayer: 'weather.map.wind',
+ showAqiLayer: 'weather.map.aqi',
+ showAlertsLayer: 'weather.map.alerts',
+ showTempMarker: 'weather.map.temp',
+ showSunMoonMarkers: 'weather.map.sunMoon',
+ unifiedMapBaseLayer: 'weather.map.baseLayer',
+ unifiedMapRadarMode: 'weather.map.radarMode',
};
const state = {
@@ -48,7 +58,23 @@ const state = {
lastRefreshAt: 0,
lightningMarkers: [],
windArrows: [],
+ aqiMarkers: [],
+ alertMarkers: [],
+ tempMarker: null,
+ sunMoonMarkers: [],
showExtendedForecast: false,
+ unifiedMapLayers: {
+ radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false',
+ satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false',
+ lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false',
+ wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false',
+ aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false',
+ alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false',
+ temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false',
+ sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false',
+ },
+ unifiedMapBaseLayer: localStorage.getItem(STORAGE_KEYS.unifiedMapBaseLayer) || 'dark',
+ unifiedMapRadarMode: localStorage.getItem(STORAGE_KEYS.unifiedMapRadarMode) || 'radar',
};
// ── DOM ──────────────────────────────────────────────────────────────────────
@@ -315,6 +341,7 @@ function renderSavedCities() {
}
// ── Tab switching ─────────────────────────────────────────────────────────────
+
function activateTab(tabName) {
document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => hide(p));
@@ -322,24 +349,23 @@ function activateTab(tabName) {
show(document.getElementById('tab-' + tabName));
if (tabName === 'conditions') {
- setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60);
+ setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60);
}
- if (tabName === 'radar-full') {
- setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50);
- }
- if (tabName === 'maps') {
- setTimeout(refreshMapsOverview, 80);
+ if (tabName === 'unified-map') {
+ setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 50);
}
}
-
document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});
-document.getElementById('open-radar-tab').addEventListener('click', () => {
- activateTab('radar-full');
-});
+const openMapTabBtn = document.getElementById('open-map-tab');
+if (openMapTabBtn) {
+ openMapTabBtn.addEventListener('click', () => {
+ activateTab('unified-map');
+ });
+}
// ── APIs ─────────────────────────────────────────────────────────────────────
async function geocode(q) {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
diff --git a/app/public/index.html b/app/public/index.html
index d45ef07..e40a5b4 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -3,17 +3,15 @@
-Weather
+NeCloud Weather
-
+
-
-
@@ -21,11 +19,10 @@
-
-
-
-
--°F
+
+
+
+
+
--°F
Feels like --°F
-
↑--° · ↓--°
-
-
-
-
-
-
-
-
-
+
+
+
+
+ 🌅
+ --
+
+
+
+ 🌇
+ --
+
+
+
+ 🌙
+ --
+ --
+
-
-
-
-
+
+
+ Wind & Atmosphere
+
+
-
-
-
-
+
+
+ More Details ›
+
+
+
+
+
+ What to Bring
+
Radar
-
+
@@ -128,7 +216,8 @@
- 7-Day Forecast
+ 7-Day Forecast
+
@@ -140,40 +229,97 @@
-
-
-
-
-
-
-
-
-
--
+
+
-
-
-
-
-
-
-
- Lightning strikes on map
-
-
-
-
- Wind overlay on map
-
-
+
+