Add real lightning overlay, push notifications, map controls, hourly summary, favorites enhancements, API resilience, UI polish

This commit is contained in:
Nate committed 2026-03-16 13:10:34 -04:00
1 parent bd3e1e9605
commit 52c96225b9
7 files changed
+905 -74

No files matched your search

+144 -1
View File
@@ -8,7 +8,8 @@
"name": "weather-app", "name": "weather-app",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"express": "^4.18.2" "express": "^4.18.2",
"web-push": "^3.6.7"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -27,12 +28,39 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -57,6 +85,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -173,6 +207,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -397,6 +440,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -417,6 +469,42 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -444,6 +532,27 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -513,6 +622,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -825,6 +949,25 @@
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
} }
} }
} }
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "node --watch server.js" "dev": "node --watch server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2" "express": "^4.18.2",
"web-push": "^3.6.7"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+403 -46
View File
@@ -31,26 +31,65 @@ const STORAGE_KEYS = {
autoDetect: 'weather.autoDetect', autoDetect: 'weather.autoDetect',
showLightning: 'weather.showLightning', showLightning: 'weather.showLightning',
showWind: 'weather.showWind', 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 = { const state = {
units: localStorage.getItem(STORAGE_KEYS.units) || 'us', units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false', autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false', showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== '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, currentCity: null,
currentWeather: null, currentWeather: null,
currentAir: null, currentAir: null,
currentSource: 'startup', currentSource: 'startup',
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'), savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)),
alertIdsSeen: new Set(), alertIdsSeen: new Set(),
autoRefreshTimer: null, autoRefreshTimer: null,
lastRefreshAt: 0, lastRefreshAt: 0,
lightningMarkers: [], lightningLayer: null,
windArrows: [], windArrows: [],
dailyExpanded: false, 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 ────────────────────────────────────────────────────────────────────── // ── DOM ──────────────────────────────────────────────────────────────────────
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn'); const searchBtn = document.getElementById('search-btn');
@@ -76,6 +115,13 @@ const lightningStatus = document.getElementById('lightning-status');
const windStatus = document.getElementById('wind-status'); const windStatus = document.getElementById('wind-status');
const dailyForecastLabel = document.getElementById('daily-forecast-label'); const dailyForecastLabel = document.getElementById('daily-forecast-label');
const dailyForecastToggle = document.getElementById('daily-forecast-toggle'); 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');
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const show = el => el.classList.remove('hidden'); const show = el => el.classList.remove('hidden');
@@ -108,20 +154,17 @@ function formatClock(iso) {
function tempDisplay(v) { function tempDisplay(v) {
if (v == null || Number.isNaN(v)) return '--'; if (v == null || Number.isNaN(v)) return '--';
const n = state.units === 'us' ? v : ((v - 32) * 5) / 9; return Math.round(v);
return Math.round(n);
} }
function speedDisplay(v) { function speedDisplay(v) {
if (v == null || Number.isNaN(v)) return '--'; if (v == null || Number.isNaN(v)) return '--';
const n = state.units === 'us' ? v : v * 1.60934; return Math.round(v);
return Math.round(n);
} }
function precipDisplay(v) { function precipDisplay(v) {
if (v == null || Number.isNaN(v)) return '--'; if (v == null || Number.isNaN(v)) return '--';
const n = state.units === 'us' ? v : v * 25.4; return Number(v).toFixed(1);
return n.toFixed(1);
} }
function visDisplay(vMeters) { function visDisplay(vMeters) {
@@ -149,22 +192,91 @@ function updateNotifyButton() {
if (!('Notification' in window)) { if (!('Notification' in window)) {
notifyBtn.textContent = 'Notifications unsupported'; notifyBtn.textContent = 'Notifications unsupported';
notifyBtn.disabled = true; notifyBtn.disabled = true;
if (pushTestBtn) pushTestBtn.disabled = true;
return; return;
} }
if (!window.isSecureContext) { if (!window.isSecureContext) {
notifyBtn.textContent = 'Needs HTTPS'; notifyBtn.textContent = 'Needs HTTPS';
notifyBtn.disabled = true; notifyBtn.disabled = true;
if (pushTestBtn) pushTestBtn.disabled = true;
return; return;
} }
notifyBtn.disabled = false; notifyBtn.disabled = false;
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
notifyBtn.textContent = 'Notifications enabled'; notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push';
} else if (Notification.permission === 'denied') { } else if (Notification.permission === 'denied') {
notifyBtn.textContent = 'Notifications blocked'; notifyBtn.textContent = 'Notifications blocked';
} else { } else {
notifyBtn.textContent = 'Enable notifications'; 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) { function moonPhase(dateLike) {
@@ -196,6 +308,8 @@ function cityKey(city) {
function persistSavedCities() { function persistSavedCities() {
localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); 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) { function persistLastCity(city) {
@@ -211,6 +325,46 @@ function cityLabel(city) {
return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); 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) { function updateShareUrl(city) {
if (!city) return; if (!city) return;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -230,10 +384,12 @@ function renderSavedCities() {
savedLocationsEl.innerHTML = ''; savedLocationsEl.innerHTML = '';
if (!state.savedCities.length) { if (!state.savedCities.length) {
savedLocationsEl.textContent = 'None yet'; savedLocationsEl.textContent = 'None yet';
updateDefaultCityLabel();
return; return;
} }
state.savedCities.forEach(city => { state.savedCities.forEach((entry, index) => {
const city = entry.city;
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'saved-item'; wrap.className = 'saved-item';
@@ -241,7 +397,35 @@ function renderSavedCities() {
chip.className = 'saved-chip'; chip.className = 'saved-chip';
chip.type = 'button'; chip.type = 'button';
chip.textContent = city.name; chip.textContent = city.name;
chip.addEventListener('click', () => loadAll(city, 'saved-location')); 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'); const remove = document.createElement('button');
remove.className = 'saved-remove'; remove.className = 'saved-remove';
@@ -249,15 +433,25 @@ function renderSavedCities() {
remove.textContent = 'x'; remove.textContent = 'x';
remove.addEventListener('click', (e) => { remove.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city)); state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city));
persistSavedCities(); persistSavedCities();
renderSavedCities(); renderSavedCities();
}); });
const meta = document.createElement('span');
meta.className = 'saved-meta';
meta.textContent = formatRelativeTime(entry.lastViewedAt);
wrap.appendChild(defaultBtn);
wrap.appendChild(chip); wrap.appendChild(chip);
wrap.appendChild(upBtn);
wrap.appendChild(downBtn);
wrap.appendChild(remove); wrap.appendChild(remove);
wrap.appendChild(meta);
savedLocationsEl.appendChild(wrap); savedLocationsEl.appendChild(wrap);
}); });
updateDefaultCityLabel();
} }
// ── Tab switching ───────────────────────────────────────────────────────────── // ── Tab switching ─────────────────────────────────────────────────────────────
@@ -294,7 +488,7 @@ async function reverseGeocode(lat, lon) {
} }
async function fetchWeather(lat, lon, tz) { async function fetchWeather(lat, lon, tz) {
const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`); 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'); if (!r.ok) throw new Error('Weather fetch failed');
return r.json(); return r.json();
} }
@@ -311,6 +505,13 @@ async function fetchAlerts(lat, lon) {
return r.json(); 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) { function detectedCityFallback(lat, lon) {
return { return {
name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`, name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
@@ -528,6 +729,11 @@ function createOverviewMap(mapEl) {
maxZoom: 18, maxZoom: 18,
}).addTo(map); }).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.15, Math.min(0.9, state.radarOpacity / 100)),
}).addTo(map);
let marker = null; let marker = null;
function panTo(lat, lon, zoom) { function panTo(lat, lon, zoom) {
@@ -542,13 +748,20 @@ function createOverviewMap(mapEl) {
}).addTo(map); }).addTo(map);
} }
return { map, panTo }; function setRadarOpacity(opacityPct) {
const normalized = Math.max(0.15, Math.min(0.9, Number(opacityPct) / 100));
radarLayer.setOpacity(normalized);
} }
function clearLightningMarkers() { return { map, panTo, setRadarOpacity };
}
function clearLightningLayer() {
if (!mapsOverview.map) return; if (!mapsOverview.map) return;
state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); if (state.lightningLayer) {
state.lightningMarkers = []; mapsOverview.map.removeLayer(state.lightningLayer);
state.lightningLayer = null;
}
} }
function clearWindArrows() { function clearWindArrows() {
@@ -566,41 +779,81 @@ function refreshMapsOverview() {
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
clearLightningMarkers(); clearLightningLayer();
clearWindArrows(); clearWindArrows();
if (state.showLightning) renderLightningStrikes(state.currentCity); if (state.showLightning) renderLightningStrikes(state.currentCity);
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
} }
function renderLightningStrikes(city) { 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; if (!mapsOverview.map || !state.showLightning) return;
clearLightningMarkers(); clearLightningLayer();
// Simulate lightning strikes in the vicinity of the city (for now, use random placement) try {
// In a production app, you'd fetch from a lightning API like Blitzortung or similar const data = await fetchLightning(city.latitude, city.longitude);
// For demo: always show a few sample strikes so the overlay is visible when enabled. const features = data?.features || [];
const strokeCount = 3; if (!features.length) return;
for (let i = 0; i < strokeCount; i++) { const layers = [];
const angle = (Math.PI * 2 * i) / strokeCount; features.forEach((feature) => {
const radius = 0.18 + i * 0.06; const geom = feature?.geometry;
const latOffset = Math.cos(angle) * radius; const event = feature?.properties?.event || 'Thunderstorm activity';
const lonOffset = Math.sin(angle) * radius; const severity = feature?.properties?.severity || 'Unknown severity';
const lat = parseFloat(city.latitude) + latOffset; if (!geom) return;
const lon = parseFloat(city.longitude) + lonOffset;
const marker = L.circleMarker([lat, lon], { const polygonSets = [];
radius: 4, if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) {
fillColor: '#ffeb3b', polygonSets.push(geom.coordinates[0]);
fillOpacity: 0.8, }
color: '#ff6f00', 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, weight: 1.5,
className: 'lightning-marker', opacity: 0.8,
}).bindPopup('Sample lightning overlay'); fillColor: '#ffd34d',
fillOpacity: 0.18,
}).bindPopup(`${event} • ${severity}`);
layers.push(poly);
marker.addTo(mapsOverview.map); const center = circleCenter(polyCoords);
state.lightningMarkers.push(marker); 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);
} }
} }
@@ -691,6 +944,27 @@ function renderDailyForecast(daily) {
} }
} }
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) { function renderWeather(data, city) {
const c = data.current; const c = data.current;
const h = data.hourly; const h = data.hourly;
@@ -742,11 +1016,16 @@ function renderWeather(data, city) {
<div class="hour-time">${i === startIdx ? 'Now' : formatHour(h.time[i])}</div> <div class="hour-time">${i === startIdx ? 'Now' : formatHour(h.time[i])}</div>
<div class="hour-icon">${hw.icon}</div> <div class="hour-icon">${hw.icon}</div>
<div class="hour-temp">${tempDisplay(h.temperature_2m[i])}°</div> <div class="hour-temp">${tempDisplay(h.temperature_2m[i])}°</div>
<div class="hour-feels">Feels ${tempDisplay(h.apparent_temperature?.[i])}°</div>
<div class="hour-precip">${pp > 0 ? `${pp}% ${ptype}` : ptype}</div> <div class="hour-precip">${pp > 0 ? `${pp}% ${ptype}` : ptype}</div>
`; `;
track.appendChild(cell); track.appendChild(cell);
} }
if (hourlySummaryEl) {
hourlySummaryEl.textContent = summarizeHourly(h, startIdx);
}
renderDailyForecast(d); renderDailyForecast(d);
document.getElementById('updated-at').textContent = document.getElementById('updated-at').textContent =
@@ -814,6 +1093,14 @@ function renderAlerts(data) {
async function loadAll(city, source) { async function loadAll(city, source) {
state.currentCity = city; state.currentCity = city;
if (source) state.currentSource = source; 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(); clearErr();
hide(suggestEl); hide(suggestEl);
hide(weatherDiv); hide(weatherDiv);
@@ -974,8 +1261,13 @@ shareBtn.addEventListener('click', async () => {
saveCurrentBtn.addEventListener('click', () => { saveCurrentBtn.addEventListener('click', () => {
if (!state.currentCity) return; if (!state.currentCity) return;
const key = cityKey(state.currentCity); const key = cityKey(state.currentCity);
if (state.savedCities.some(c => cityKey(c) === key)) return; if (state.savedCities.some(c => cityKey(c.city) === key)) return;
state.savedCities.push(state.currentCity); state.savedCities.push({
city: state.currentCity,
addedAt: Date.now(),
lastViewedAt: Date.now(),
isDefault: state.savedCities.length === 0,
});
persistSavedCities(); persistSavedCities();
renderSavedCities(); renderSavedCities();
}); });
@@ -993,9 +1285,11 @@ notifyBtn.addEventListener('click', async () => {
try { try {
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
updateNotifyButton();
if (permission === 'granted') { if (permission === 'granted') {
// Re-fetch current alerts immediately so permission change can trigger notifications. const subscribed = await ensurePushSubscription();
if (!subscribed) {
showErr('Push needs VAPID keys configured on server.');
}
if (state.currentCity) { if (state.currentCity) {
const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] })); const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] }));
renderAlerts(alerts); renderAlerts(alerts);
@@ -1005,6 +1299,7 @@ notifyBtn.addEventListener('click', async () => {
} }
} catch { } catch {
showErr('Could not request notification permission.'); showErr('Could not request notification permission.');
} finally {
updateNotifyButton(); updateNotifyButton();
} }
}); });
@@ -1019,7 +1314,7 @@ lightningToggle.addEventListener('change', () => {
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning)); localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
if (state.currentCity && mapsOverview.map) { if (state.currentCity && mapsOverview.map) {
clearLightningMarkers(); clearLightningLayer();
if (state.showLightning) renderLightningStrikes(state.currentCity); if (state.showLightning) renderLightningStrikes(state.currentCity);
} }
}); });
@@ -1042,6 +1337,44 @@ if (dailyForecastToggle) {
}); });
} }
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() { function startAutoRefresh() {
clearInterval(state.autoRefreshTimer); clearInterval(state.autoRefreshTimer);
state.autoRefreshTimer = setInterval(() => { state.autoRefreshTimer = setInterval(() => {
@@ -1064,15 +1397,32 @@ function startAutoRefresh() {
updateNotifyButton(); updateNotifyButton();
syncAutoDetectToggle(); syncAutoDetectToggle();
// Sync lightning and wind toggle states // Sync lightning/wind and UI polish toggles.
lightningToggle.checked = state.showLightning; lightningToggle.checked = state.showLightning;
lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
windToggle.checked = state.showWind; windToggle.checked = state.showWind;
windStatus.textContent = state.showWind ? 'On' : 'Off'; 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(); renderSavedCities();
startAutoRefresh(); 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 qs = new URLSearchParams(window.location.search);
const qLat = Number(qs.get('lat')); const qLat = Number(qs.get('lat'));
const qLon = Number(qs.get('lon')); const qLon = Number(qs.get('lon'));
@@ -1096,6 +1446,13 @@ function startAutoRefresh() {
if (autoLocated) return; 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(); const lastCity = getLastCity();
if (lastCity) { if (lastCity) {
searchInput.value = lastCity.name; searchInput.value = lastCity.name;
+37 -1
View File
@@ -122,6 +122,7 @@
<span class="section-label">Hourly Forecast</span> <span class="section-label">Hourly Forecast</span>
<span class="section-note">next 24 hours</span> <span class="section-note">next 24 hours</span>
</div> </div>
<p id="hourly-summary" class="hourly-summary">--</p>
<div id="hourly-scroll" class="hourly-scroll"> <div id="hourly-scroll" class="hourly-scroll">
<div id="hourly-track" class="hourly-track"></div> <div id="hourly-track" class="hourly-track"></div>
</div> </div>
@@ -166,6 +167,20 @@
<div id="tab-maps" class="tab-panel hidden"> <div id="tab-maps" class="tab-panel hidden">
<div class="maps-wrap"> <div class="maps-wrap">
<div id="map-overview" class="radar-map map-overview"></div> <div id="map-overview" class="radar-map map-overview"></div>
<div class="maps-controls">
<label class="maps-control">
<span>Radar opacity</span>
<input id="radar-opacity" type="range" min="15" max="90" step="5" value="65">
</label>
<label class="maps-control">
<span>Lightning age</span>
<select id="lightning-age">
<option value="5">Last 5m</option>
<option value="15" selected>Last 15m</option>
<option value="30">Last 30m</option>
</select>
</label>
</div>
<div class="maps-meta"> <div class="maps-meta">
<div class="maps-card"> <div class="maps-card">
<div class="stat-label">Data Source Location</div> <div class="stat-label">Data Source Location</div>
@@ -176,7 +191,11 @@
<div id="maps-wind" class="maps-value">--</div> <div id="maps-wind" class="maps-value">--</div>
</div> </div>
</div> </div>
<p class="panel-note">Toggle lightning strikes and wind overlay in Settings.</p> <div class="wind-legend" aria-hidden="true">
<span>Wind legend</span>
<div class="wind-legend-scale"><i></i></div>
</div>
<p class="panel-note">Toggle lightning strikes and wind overlay in Settings. Lightning overlay uses active thunderstorm alert geometry.</p>
</div> </div>
</div> </div>
@@ -185,7 +204,10 @@
<div id="alerts-wrap" class="alerts-wrap hidden"> <div id="alerts-wrap" class="alerts-wrap hidden">
<div class="section-head"> <div class="section-head">
<span class="section-label">Weather Alerts</span> <span class="section-label">Weather Alerts</span>
<span>
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button> <button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
<button id="push-test-btn" class="link-btn" type="button">Send test push</button>
</span>
</div> </div>
<div id="alerts-list" class="alerts-list"></div> <div id="alerts-list" class="alerts-list"></div>
</div> </div>
@@ -200,6 +222,7 @@
<span>Saved locations</span> <span>Saved locations</span>
<button id="save-current-btn" class="link-btn" type="button">Save current</button> <button id="save-current-btn" class="link-btn" type="button">Save current</button>
</div> </div>
<p class="saved-help">Use star for default, arrows to reorder.</p>
<div id="saved-locations" class="saved-locations"></div> <div id="saved-locations" class="saved-locations"></div>
</div> </div>
@@ -216,6 +239,11 @@
<span id="settings-location" class="settings-value">--</span> <span id="settings-location" class="settings-value">--</span>
</div> </div>
<div class="settings-row">
<span class="settings-label">Default city</span>
<span id="default-city" class="settings-value">None</span>
</div>
<div class="settings-row"> <div class="settings-row">
<span class="settings-label">Units</span> <span class="settings-label">Units</span>
<button id="units-btn-settings" class="settings-btn" type="button">US</button> <button id="units-btn-settings" class="settings-btn" type="button">US</button>
@@ -236,6 +264,14 @@
<span id="wind-status">Off</span> <span id="wind-status">Off</span>
</label> </label>
</div> </div>
<div class="settings-row">
<span class="settings-label">High contrast mode</span>
<label class="toggle-label" for="contrast-toggle">
<input id="contrast-toggle" type="checkbox">
<span id="contrast-status">Off</span>
</label>
</div>
</div> </div>
</div> </div>
+104 -5
View File
@@ -168,13 +168,18 @@ a:hover { text-decoration: underline; }
} }
.saved-locations { .saved-locations {
display: flex; display: grid;
flex-wrap: wrap;
gap: 0.35rem; gap: 0.35rem;
font-size: 0.72rem; font-size: 0.72rem;
color: var(--ink-mid); color: var(--ink-mid);
} }
.saved-help {
font-size: 0.62rem;
color: var(--ink-light);
margin-bottom: 0.45rem;
}
.auto-detect-toggle { .auto-detect-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -210,13 +215,17 @@ a:hover { text-decoration: underline; }
} }
.saved-item { .saved-item {
display: inline-flex; display: grid;
grid-template-columns: 28px 1fr 28px 28px 28px auto;
align-items: center;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--paper-dark); background: var(--paper-dark);
} }
.saved-chip, .saved-chip,
.saved-remove { .saved-remove,
.saved-default,
.saved-move {
border: none; border: none;
background: transparent; background: transparent;
color: var(--ink); color: var(--ink);
@@ -225,8 +234,25 @@ a:hover { text-decoration: underline; }
cursor: pointer; cursor: pointer;
} }
.saved-remove { .saved-chip {
text-align: left;
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.saved-default { color: var(--accent); }
.saved-move { border-right: 1px solid var(--border); }
.saved-move:disabled { opacity: 0.35; cursor: not-allowed; }
.saved-remove {
border-right: 1px solid var(--border);
color: var(--ink-light);
}
.saved-meta {
padding: 0 0.55rem;
font-size: 0.6rem;
color: var(--ink-light); color: var(--ink-light);
} }
@@ -440,6 +466,33 @@ a:hover { text-decoration: underline; }
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.maps-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-top: 0.75rem;
}
.maps-control {
display: grid;
gap: 0.35rem;
font-size: 0.66rem;
color: var(--ink-mid);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.maps-control input,
.maps-control select {
width: 100%;
border: 1px solid var(--border);
background: var(--paper);
color: var(--ink);
font-family: var(--mono);
font-size: 0.72rem;
padding: 0.35rem 0.45rem;
}
.maps-meta { .maps-meta {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -459,6 +512,22 @@ a:hover { text-decoration: underline; }
color: var(--ink); color: var(--ink);
} }
.wind-legend {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.62rem;
color: var(--ink-light);
margin-top: 0.6rem;
}
.wind-legend-scale {
width: 120px;
height: 8px;
border: 1px solid var(--border);
background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df);
}
.stat { background: var(--paper-dark); padding: 0.9rem 0.75rem; text-align: center; } .stat { background: var(--paper-dark); padding: 0.9rem 0.75rem; text-align: center; }
.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } .stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; }
.stat-val { font-size: 1rem; font-weight: 500; } .stat-val { font-size: 1rem; font-weight: 500; }
@@ -607,6 +676,12 @@ a:hover { text-decoration: underline; }
.hourly-track { display: flex; min-width: max-content; } .hourly-track { display: flex; min-width: max-content; }
.hourly-summary {
font-size: 0.68rem;
color: var(--ink-mid);
margin-bottom: 0.45rem;
}
.hour-cell { .hour-cell {
min-width: 72px; min-width: 72px;
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
@@ -623,6 +698,7 @@ a:hover { text-decoration: underline; }
.hour-time { font-size: 0.6rem; color: var(--ink-light); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; } .hour-time { font-size: 0.6rem; color: var(--ink-light); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; }
.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; } .hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; }
.hour-temp { font-size: 0.88rem; font-weight: 500; } .hour-temp { font-size: 0.88rem; font-weight: 500; }
.hour-feels { font-size: 0.56rem; color: var(--ink-mid); margin-top: 0.15rem; }
.hour-precip { font-size: 0.6rem; color: var(--cool); margin-top: 0.2rem; } .hour-precip { font-size: 0.6rem; color: var(--cool); margin-top: 0.2rem; }
/* ── Daily forecast ── */ /* ── Daily forecast ── */
@@ -707,6 +783,26 @@ a:hover { text-decoration: underline; }
border-color: var(--border) !important; border-color: var(--border) !important;
} }
/* UI polish: strong contrast toggle */
body.high-contrast {
--ink: #0a0a0a;
--ink-mid: #1f1f1f;
--ink-light: #3b3b3b;
--paper: #ffffff;
--paper-dark: #f2f2f2;
--paper-darker: #e8e8e8;
--border: #111111;
--accent: #a43600;
--cool: #005f8a;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 640px) { @media (max-width: 640px) {
.app { padding: 1.25rem 1rem max(3rem, calc(1.5rem + env(safe-area-inset-bottom, 0px))); } .app { padding: 1.25rem 1rem max(3rem, calc(1.5rem + env(safe-area-inset-bottom, 0px))); }
@@ -729,5 +825,8 @@ a:hover { text-decoration: underline; }
.settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.settings-value { text-align: left; } .settings-value { text-align: left; }
.map-overview { height: 280px; } .map-overview { height: 280px; }
.maps-controls,
.maps-meta { grid-template-columns: 1fr; } .maps-meta { grid-template-columns: 1fr; }
.saved-item { grid-template-columns: 28px 1fr 28px 28px 28px; }
.saved-meta { display: none; }
} }
+36 -1
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
const CACHE = 'weather-v1'; const CACHE = 'weather-v2';
// App shell — cached on install for offline use // App shell — cached on install for offline use
const PRECACHE = [ const PRECACHE = [
@@ -64,3 +64,38 @@ self.addEventListener('fetch', event => {
}) })
); );
}); });
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);
})
);
});
+179 -19
View File
@@ -1,19 +1,88 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const webpush = require('web-push');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; 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');
}
const subscriptions = new Map();
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;
}
app.use(express.static(path.join(__dirname, 'public'))); 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 // Geocoding proxy
app.get('/api/geocode', async (req, res) => { app.get('/api/geocode', async (req, res) => {
const { q } = req.query; const { q } = req.query;
if (!q) return res.status(400).json({ error: 'Missing query' }); if (!q) return res.status(400).json({ error: 'Missing query' });
try { try {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; 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 fetchJsonWithRetry(url);
const data = await r.json();
res.json(data); res.json(data);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Geocoding failed', detail: e.message }); res.status(500).json({ error: 'Geocoding failed', detail: e.message });
@@ -35,9 +104,8 @@ app.get('/api/reverse-geocode', async (req, res) => {
format: 'json', format: 'json',
}); });
const omResp = await fetch(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`); const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
if (omResp.ok) { if (omData) {
const omData = await omResp.json();
const top = omData?.results?.[0]; const top = omData?.results?.[0];
if (top) { if (top) {
return res.json({ return res.json({
@@ -60,18 +128,13 @@ app.get('/api/reverse-geocode', async (req, res) => {
addressdetails: '1', addressdetails: '1',
}); });
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)', 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json', 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 address = data.address || {};
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
@@ -117,8 +180,7 @@ app.get('/api/weather', async (req, res) => {
temperature_unit: useMetric ? 'celsius' : 'fahrenheit', temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch' precipitation_unit: useMetric ? 'mm' : 'inch'
}); });
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
const data = await r.json();
res.json(data); res.json(data);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
@@ -137,8 +199,7 @@ 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(','), current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
forecast_days: 1, forecast_days: 1,
}); });
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
const data = await r.json();
res.json(data); res.json(data);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Air quality fetch failed', detail: e.message }); res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
@@ -150,24 +211,123 @@ app.get('/api/alerts', async (req, res) => {
const { lat, lon } = req.query; const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try { try {
const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
headers: { headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)', 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/geo+json', Accept: 'application/geo+json',
}, },
}); });
const data = await r.json();
res.json(data); res.json(data);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Alerts fetch failed', detail: e.message }); 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(),
});
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);
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++;
} else {
errors.push(String(e.message || e));
}
}
}
res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
});
// RainViewer timestamps proxy // RainViewer timestamps proxy
app.get('/api/radar/times', async (req, res) => { app.get('/api/radar/times', async (req, res) => {
try { try {
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json'); const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
const data = await r.json();
res.json(data); res.json(data);
} catch (e) { } catch (e) {
res.status(500).json({ error: 'Radar times failed', detail: e.message }); res.status(500).json({ error: 'Radar times failed', detail: e.message });