diff --git a/app/public/app.js b/app/public/app.js
index a3cc624..63421a3 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -425,7 +425,6 @@ async function fetchWeather(lat, lon, tz) {
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();
@@ -641,13 +640,6 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
maxNativeZoom: f.maxNativeZoom,
isForecast: false,
}));
- (data.radar?.nowcast || []).forEach(f => radarFrames.push({
- time: f.time,
- path: f.path,
- url: f.url,
- maxNativeZoom: f.maxNativeZoom,
- isForecast: true,
- }));
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({
time: f.time,
path: f.path,
@@ -1076,7 +1068,7 @@ function renderWeather(data, city) {
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('feels-line').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';
@@ -1668,7 +1660,12 @@ searchInput.addEventListener('input', () => {
results.forEach(r => {
const li = document.createElement('li');
- li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`;
+ const nameText = document.createTextNode(r.name);
+ const countrySpan = document.createElement('span');
+ countrySpan.className = 'sug-country';
+ countrySpan.textContent = `${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`;
+ li.appendChild(nameText);
+ li.appendChild(countrySpan);
li.addEventListener('click', () => {
searchInput.value = r.name;
hide(suggestEl);
diff --git a/app/public/index.html b/app/public/index.html
index e8378e2..50b771d 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -77,7 +77,7 @@
--°F
-
Feels like --°F
+
Feels like --°F
diff --git a/app/public/style.css b/app/public/style.css
index 299f3cb..017d81c 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -19,6 +19,7 @@
--surface-border: 1px solid rgba(255, 255, 255, 0.08);
--surface-radius-lg: 16px;
--surface-radius-md: 12px;
+ --surface-radius-sm: 8px;
--surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
}
@@ -338,6 +339,10 @@ a:hover { text-decoration: underline; }
color: var(--ink-mid);
cursor: pointer;
transition: background 0.15s, color 0.15s;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
.tab:last-child { border-right: none; }
@@ -498,25 +503,6 @@ a:hover { text-decoration: underline; }
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);
@@ -1581,8 +1567,6 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
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;
@@ -1813,12 +1797,6 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
}
/* ── 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; }
diff --git a/app/server.js b/app/server.js
index 4a09a86..041626f 100644
--- a/app/server.js
+++ b/app/server.js
@@ -4,6 +4,42 @@ const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const weatherCache = new Map();
+const WEATHER_CACHE_MAX_ENTRIES = 500;
+
+function setCache(key, data) {
+ if (weatherCache.size >= WEATHER_CACHE_MAX_ENTRIES) {
+ const oldestKey = weatherCache.keys().next().value;
+ weatherCache.delete(oldestKey);
+ }
+ weatherCache.set(key, { data, at: Date.now() });
+}
+
+// Fetch with retry/timeout, shared by all upstream proxy calls.
+async function fetchWithRetry(url, { attempts = 2, timeoutMs = 12000, retryDelayMs = 500, fetchOptions } = {}) {
+ let lastError = null;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const r = await fetch(url, { ...fetchOptions, signal: controller.signal });
+ clearTimeout(timeoutId);
+ if (!r.ok) throw new Error(`Upstream status ${r.status}`);
+ return r;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ if (attempt < attempts) {
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
+ }
+ }
+ }
+ throw lastError;
+}
+
+function isValidCoord(value, min, max) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= min && n <= max;
+}
// Keep shell assets fresh so SW/app versions do not drift.
app.use('/', (req, res, next) => {
@@ -35,23 +71,11 @@ app.get('/api/geocode', async (req, res) => {
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));
- }
- }
+ try {
+ const r = await fetchWithRetry(url, { attempts: 2, timeoutMs: 9000, retryDelayMs: 300 });
+ openMeteoData = await r.json();
+ } catch (err) {
+ console.warn('Open-Meteo geocode failed after retries:', err.message);
}
if (openMeteoData?.results?.length) {
@@ -60,17 +84,17 @@ app.get('/api/geocode', async (req, res) => {
// 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',
+ const nom = await fetchWithRetry(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
+ attempts: 1,
+ timeoutMs: 9000,
+ fetchOptions: {
+ 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 || {};
@@ -86,7 +110,8 @@ app.get('/api/geocode', async (req, res) => {
return res.json({ results });
} catch (e) {
- res.status(500).json({ error: 'Geocoding failed', detail: e.message });
+ console.error('Geocoding error:', e.message);
+ res.status(500).json({ error: 'Geocoding failed' });
}
});
@@ -94,6 +119,9 @@ app.get('/api/geocode', async (req, res) => {
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' });
+ if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
+ return res.status(400).json({ error: 'Invalid lat/lon' });
+ }
try {
const params = new URLSearchParams({
@@ -104,17 +132,17 @@ app.get('/api/reverse-geocode', async (req, res) => {
addressdetails: '1',
});
- const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
- headers: {
- 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
- Accept: 'application/json',
+ const r = await fetchWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
+ attempts: 1,
+ timeoutMs: 9000,
+ fetchOptions: {
+ 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';
@@ -129,7 +157,8 @@ app.get('/api/reverse-geocode', async (req, res) => {
display_name: data.display_name || '',
});
} catch (e) {
- res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
+ console.error('Reverse geocoding error:', e.message);
+ res.status(500).json({ error: 'Reverse geocoding failed' });
}
});
@@ -137,6 +166,9 @@ app.get('/api/reverse-geocode', async (req, res) => {
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' });
+ if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
+ return res.status(400).json({ error: 'Invalid lat/lon' });
+ }
const timezone = tz || 'auto';
const useMetric = units === 'metric';
const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
@@ -163,35 +195,10 @@ app.get('/api/weather', async (req, res) => {
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');
+ const r = await fetchWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`, { attempts: 3, timeoutMs: 12000, retryDelayMs: 500 });
+ const data = await r.json();
+ setCache(cacheKey, data);
+ return res.json(data);
} catch (e) {
const cached = weatherCache.get(cacheKey);
if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
@@ -200,7 +207,7 @@ app.get('/api/weather', async (req, res) => {
return res.json(cached.data);
}
console.error('Weather proxy failed:', e.message);
- res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
+ res.status(502).json({ error: 'Weather fetch failed' });
}
});
@@ -208,6 +215,9 @@ app.get('/api/weather', async (req, res) => {
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' });
+ if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
+ return res.status(400).json({ error: 'Invalid lat/lon' });
+ }
try {
const params = new URLSearchParams({
latitude: lat,
@@ -216,30 +226,9 @@ app.get('/api/air-quality', async (req, res) => {
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');
+ const r = await fetchWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { attempts: 2, timeoutMs: 12000, retryDelayMs: 500 });
+ const data = await r.json();
+ return res.json(data);
} catch (e) {
console.error('Air quality fetch error:', e);
res.setHeader('X-Air-Quality-Fallback', 'empty');
@@ -251,23 +240,20 @@ app.get('/api/air-quality', async (req, res) => {
app.get('/api/alerts', async (req, res) => {
const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
+ if (!isValidCoord(lat, -90, 90) || !isValidCoord(lon, -180, 180)) {
+ return res.status(400).json({ error: 'Invalid 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',
+ const r = await fetchWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ attempts: 1,
+ timeoutMs: 12000,
+ fetchOptions: {
+ 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) {
@@ -308,10 +294,9 @@ app.get('/api/radar/times', async (req, res) => {
});
res.json({
- provider: 'iem-radar-rainviewer-sat',
+ provider: 'iem-radar-sat',
radar: {
past: radarPast,
- nowcast: [],
},
satellite: {
infrared: satelliteInfrared,
@@ -319,7 +304,7 @@ app.get('/api/radar/times', async (req, res) => {
});
} catch (e) {
console.error('Radar config error:', e.message);
- res.status(500).json({ error: 'Radar times failed', detail: e.message });
+ res.status(500).json({ error: 'Radar times failed' });
}
});