diff --git a/app/public/app.js b/app/public/app.js
index 5fbeec1..143b92b 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -48,6 +48,7 @@ const state = {
lastRefreshAt: 0,
lightningMarkers: [],
windArrows: [],
+ showExtendedForecast: false,
};
// ── DOM ──────────────────────────────────────────────────────────────────────
@@ -71,8 +72,8 @@ 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 dailyToggleBtn = document.getElementById('daily-toggle-btn');
+const dailyForecastLabel = document.getElementById('daily-forecast-label');
// ── Helpers ──────────────────────────────────────────────────────────────────
const show = el => el.classList.remove('hidden');
@@ -84,6 +85,10 @@ 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`;
@@ -181,6 +186,58 @@ function precipType(code, tempF, rain, snowfall) {
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';
@@ -258,29 +315,38 @@ function renderSavedCities() {
}
// ── 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);
- }
- });
+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', () => {
- document.querySelector('.tab[data-tab="radar-full"]').click();
+ activateTab('radar-full');
});
// ── APIs ─────────────────────────────────────────────────────────────────────
async function geocode(q) {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
- if (!r.ok) throw new Error('Geocoding failed');
+ if (!r.ok) {
+ const detail = await r.text().catch(() => '');
+ throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
+ }
return r.json();
}
@@ -290,15 +356,40 @@ async function reverseGeocode(lat, lon) {
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 r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`);
- if (!r.ok) throw new Error('Weather fetch failed');
- return r.json();
+ 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 r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`);
- if (!r.ok) throw new Error('Air quality fetch failed');
+ 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();
}
@@ -338,6 +429,9 @@ async function buildDetectedCity(lat, lon) {
// ── 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,
@@ -357,6 +451,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
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;
@@ -382,12 +479,33 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
if (!frame) return;
if (!layers[mode][frame.time]) {
- layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 });
+ layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, 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);
+ 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) => {
@@ -420,7 +538,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
function startPlay() {
playing = true;
playBtn.textContent = '⏸';
- animTimer = setInterval(step, 500);
+ animTimer = setInterval(step, FRAME_INTERVAL_MS);
}
function stopPlay() {
@@ -436,6 +554,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
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));
@@ -443,29 +562,65 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
}
async function loadFrames() {
+ if (loadingFrames || framesReady) return;
+ loadingFrames = true;
+ let retries = 0;
+ const maxRetries = 2;
+
try {
- const r = await fetch('/api/radar/times');
- const data = await r.json();
+ 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 }));
+ 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 (!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';
+ 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));
}
-
- buildTimeline();
- const frames = activeFrames();
- showFrame(frames.length - 1);
- } catch (e) {
- console.error('Radar load failed', e);
+ }
+ } finally {
+ loadingFrames = false;
}
}
@@ -481,8 +636,32 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
}).addTo(map);
}
- loadFrames();
- return { map, panTo, startPlay, stopPlay };
+ 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(
@@ -562,30 +741,45 @@ function renderLightningStrikes(city) {
if (!mapsOverview.map || !state.showLightning) return;
clearLightningMarkers();
- // Simulate lightning strikes in the vicinity of the city (for now, use random placement)
- // In a production app, you'd fetch from a lightning API like Blitzortung or similar
- // For demo: always show a few sample strikes so the overlay is visible when enabled.
- const strokeCount = 3;
+ // 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.PI * 2 * i) / strokeCount;
- const radius = 0.18 + i * 0.06;
+ 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: 4,
+ radius: 3 + Math.random() * 3,
fillColor: '#ffeb3b',
- fillOpacity: 0.8,
+ fillOpacity: 0.7 + Math.random() * 0.3,
color: '#ff6f00',
weight: 1.5,
className: 'lightning-marker',
- }).bindPopup('Sample lightning overlay');
+ }).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);
}
}
@@ -595,45 +789,70 @@ function renderWindArrows(city) {
// Get current wind data
const current = state.currentWeather.current;
- if (!current || !current.wind_speed_10m) return;
+ 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;
- // Draw a wind barb pointing in the direction of wind movement
+ // 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 radius = 0.3;
+ const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind
+ const baseRadius = 0.15;
- // 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);
+ 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'));
@@ -662,18 +881,37 @@ function renderWeather(data, city) {
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('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.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit();
- document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit();
+ 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;
@@ -695,21 +933,8 @@ function renderWeather(data, city) {
track.appendChild(cell);
}
- const list = document.getElementById('daily-list');
- list.innerHTML = '';
- for (let i = 0; i < d.time.length; i++) {
- const dw = wmo(d.weather_code[i]);
- const row = document.createElement('div');
- row.className = 'day-row' + (i === 0 ? ' today' : '');
- row.innerHTML = `
- ${formatDayShort(d.time[i])}
- ${dw.icon}
- ${dw.desc}
- ${tempDisplay(d.temperature_2m_max[i])}° / ${tempDisplay(d.temperature_2m_min[i])}°
- ${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(d.wind_speed_10m_max[i])} ${speedUnit()}
- `;
- list.appendChild(row);
- }
+ state.showExtendedForecast = false;
+ renderDailyForecast(d);
document.getElementById('updated-at').textContent =
new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -724,6 +949,43 @@ function renderWeather(data, 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) {
@@ -782,22 +1044,64 @@ async function loadAll(city, source) {
show(loadingEl);
try {
- const [weather, air, alerts] = await Promise.all([
- fetchWeather(city.latitude, city.longitude, city.timezone),
+ 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).catch(() => ({ features: [] })),
+ 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();
- renderWeather(weather, city);
- renderAirQuality(air);
- renderAlerts(alerts);
+
+ // 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);
+ }
- // Render map overlays
- if (state.showLightning) renderLightningStrikes(city);
- if (state.showWind) renderWindArrows(city);
+ 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);
@@ -859,7 +1163,8 @@ searchInput.addEventListener('input', () => {
suggestEl.appendChild(li);
});
show(suggestEl);
- } catch {
+ } catch (err) {
+ console.warn('Autocomplete geocode failed:', err?.message || err);
hide(suggestEl);
}
}, 300);
@@ -915,6 +1220,11 @@ unitsBtn.addEventListener('click', () => {
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);
+ }
}
});
@@ -979,7 +1289,6 @@ autoDetectToggle.addEventListener('change', () => {
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) {
clearLightningMarkers();
if (state.showLightning) renderLightningStrikes(state.currentCity);
@@ -989,13 +1298,55 @@ lightningToggle.addEventListener('change', () => {
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);
}
});
+// 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(() => {
@@ -1020,9 +1371,7 @@ function startAutoRefresh() {
// Sync lightning and wind toggle states
lightningToggle.checked = state.showLightning;
- lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
windToggle.checked = state.showWind;
- windStatus.textContent = state.showWind ? 'On' : 'Off';
renderSavedCities();
startAutoRefresh();
diff --git a/app/public/sw.js b/app/public/sw.js
index 8019805..7350fe9 100644
--- a/app/public/sw.js
+++ b/app/public/sw.js
@@ -1,8 +1,12 @@
'use strict';
-const CACHE = 'weather-v1';
+self.addEventListener('message', event => {
+ if (event.data === 'skipWaiting') self.skipWaiting();
+});
-// App shell — cached on install for offline use
+const CACHE = 'weather-v5';
+
+// App shell cached on install for offline resilience.
const PRECACHE = [
'/',
'/app.js',
@@ -22,9 +26,7 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
- .then(keys => Promise.all(
- keys.filter(k => k !== CACHE).map(k => caches.delete(k))
- ))
+ .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
@@ -33,7 +35,7 @@ self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
- // Skip non-GET, API calls, and map/radar tile requests
+ // Never intercept API/tile traffic.
if (
request.method !== 'GET' ||
url.pathname.startsWith('/api/') ||
@@ -45,11 +47,33 @@ self.addEventListener('fetch', event => {
return;
}
+ const isShell =
+ request.mode === 'navigate' ||
+ request.destination === 'document' ||
+ request.destination === 'script' ||
+ request.destination === 'style';
+
+ if (isShell) {
+ // Network-first keeps app updates fresh; cache as fallback.
+ 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;
+ }
+
+ // Cache-first for static assets.
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') ||
@@ -63,4 +87,4 @@ self.addEventListener('fetch', event => {
});
})
);
-});
+});
\ No newline at end of file
diff --git a/app/server.js b/app/server.js
index cb2bb0d..50bc205 100644
--- a/app/server.js
+++ b/app/server.js
@@ -3,18 +3,88 @@ const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
+const weatherCache = new Map();
+
+// Keep shell assets fresh so SW/app versions do not drift.
+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'
+ ) {
+ res.setHeader('Cache-Control', 'no-cache');
+ }
+ next();
+});
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`;
- const r = await fetch(url);
- const data = await r.json();
- res.json(data);
+
+ 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 });
}
@@ -69,13 +139,14 @@ app.get('/api/weather', async (req, res) => {
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','surface_pressure','visibility'
+ 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
].join(','),
hourly: [
'temperature_2m','precipitation_probability','weather_code',
@@ -91,10 +162,44 @@ app.get('/api/weather', async (req, res) => {
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch'
});
- const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
- const data = await r.json();
- res.json(data);
+
+ 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 });
}
});
@@ -111,11 +216,34 @@ 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,
});
- const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
- const data = await r.json();
- res.json(data);
+ 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) {
- res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
+ console.error('Air quality fetch error:', e);
+ res.setHeader('X-Air-Quality-Fallback', 'empty');
+ res.json({ current: {} });
}
});
@@ -124,26 +252,48 @@ 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) {
- res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
+ 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 r = await fetch('https://api.rainviewer.com/public/weather-maps.json');
+ 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 });
}
});