Add real lightning overlay, push notifications, map controls, hourly summary, favorites enhancements, API resilience, UI polish
This commit is contained in:
1 parent
bd3e1e9605
commit
52c96225b9
7 files changed
+909
-78
No files matched your search
Generated
+144
-1
@@ -8,7 +8,8 @@
|
||||
"name": "weather-app",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
"express": "^4.18.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -27,12 +28,39 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"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": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -57,6 +85,12 @@
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -173,6 +207,15 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -397,6 +440,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -417,6 +469,42 @@
|
||||
"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": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -444,6 +532,27 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -513,6 +622,21 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -825,6 +949,25 @@
|
||||
"engines": {
|
||||
"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
@@ -8,7 +8,8 @@
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
"express": "^4.18.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
+406
-49
@@ -31,26 +31,65 @@ const STORAGE_KEYS = {
|
||||
autoDetect: 'weather.autoDetect',
|
||||
showLightning: 'weather.showLightning',
|
||||
showWind: 'weather.showWind',
|
||||
defaultCity: 'weather.defaultCity',
|
||||
highContrast: 'weather.highContrast',
|
||||
radarOpacity: 'weather.radarOpacity',
|
||||
lightningAgeMin: 'weather.lightningAgeMin',
|
||||
};
|
||||
|
||||
function parseSavedCities(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map((entry) => {
|
||||
if (entry && entry.city && typeof entry.city === 'object') {
|
||||
return {
|
||||
city: entry.city,
|
||||
addedAt: entry.addedAt || Date.now(),
|
||||
lastViewedAt: entry.lastViewedAt || null,
|
||||
isDefault: Boolean(entry.isDefault),
|
||||
};
|
||||
}
|
||||
return {
|
||||
city: entry,
|
||||
addedAt: Date.now(),
|
||||
lastViewedAt: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}).filter(e => e.city && e.city.latitude != null && e.city.longitude != null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
|
||||
autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
|
||||
showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
|
||||
showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false',
|
||||
highContrast: localStorage.getItem(STORAGE_KEYS.highContrast) === 'true',
|
||||
radarOpacity: Number(localStorage.getItem(STORAGE_KEYS.radarOpacity) || 65),
|
||||
lightningAgeMin: Number(localStorage.getItem(STORAGE_KEYS.lightningAgeMin) || 15),
|
||||
currentCity: null,
|
||||
currentWeather: null,
|
||||
currentAir: null,
|
||||
currentSource: 'startup',
|
||||
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
|
||||
savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)),
|
||||
alertIdsSeen: new Set(),
|
||||
autoRefreshTimer: null,
|
||||
lastRefreshAt: 0,
|
||||
lightningMarkers: [],
|
||||
lightningLayer: null,
|
||||
windArrows: [],
|
||||
dailyExpanded: false,
|
||||
pushSupported: false,
|
||||
pushEnabled: false,
|
||||
};
|
||||
|
||||
const persistedDefaultKey = localStorage.getItem(STORAGE_KEYS.defaultCity);
|
||||
if (persistedDefaultKey && state.savedCities.length && !state.savedCities.some(e => e.isDefault)) {
|
||||
state.savedCities = state.savedCities.map(e => ({ ...e, isDefault: cityKey(e.city) === persistedDefaultKey }));
|
||||
}
|
||||
|
||||
// ── DOM ──────────────────────────────────────────────────────────────────────
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchBtn = document.getElementById('search-btn');
|
||||
@@ -76,6 +115,13 @@ const lightningStatus = document.getElementById('lightning-status');
|
||||
const windStatus = document.getElementById('wind-status');
|
||||
const dailyForecastLabel = document.getElementById('daily-forecast-label');
|
||||
const dailyForecastToggle = document.getElementById('daily-forecast-toggle');
|
||||
const pushTestBtn = document.getElementById('push-test-btn');
|
||||
const hourlySummaryEl = document.getElementById('hourly-summary');
|
||||
const defaultCityEl = document.getElementById('default-city');
|
||||
const contrastToggle = document.getElementById('contrast-toggle');
|
||||
const contrastStatus = document.getElementById('contrast-status');
|
||||
const radarOpacityEl = document.getElementById('radar-opacity');
|
||||
const lightningAgeEl = document.getElementById('lightning-age');
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const show = el => el.classList.remove('hidden');
|
||||
@@ -108,20 +154,17 @@ function formatClock(iso) {
|
||||
|
||||
function tempDisplay(v) {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
const n = state.units === 'us' ? v : ((v - 32) * 5) / 9;
|
||||
return Math.round(n);
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
function speedDisplay(v) {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
const n = state.units === 'us' ? v : v * 1.60934;
|
||||
return Math.round(n);
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
function precipDisplay(v) {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
const n = state.units === 'us' ? v : v * 25.4;
|
||||
return n.toFixed(1);
|
||||
return Number(v).toFixed(1);
|
||||
}
|
||||
|
||||
function visDisplay(vMeters) {
|
||||
@@ -149,22 +192,91 @@ function updateNotifyButton() {
|
||||
if (!('Notification' in window)) {
|
||||
notifyBtn.textContent = 'Notifications unsupported';
|
||||
notifyBtn.disabled = true;
|
||||
if (pushTestBtn) pushTestBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!window.isSecureContext) {
|
||||
notifyBtn.textContent = 'Needs HTTPS';
|
||||
notifyBtn.disabled = true;
|
||||
if (pushTestBtn) pushTestBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
notifyBtn.disabled = false;
|
||||
if (Notification.permission === 'granted') {
|
||||
notifyBtn.textContent = 'Notifications enabled';
|
||||
notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push';
|
||||
} else if (Notification.permission === 'denied') {
|
||||
notifyBtn.textContent = 'Notifications blocked';
|
||||
} else {
|
||||
notifyBtn.textContent = 'Enable notifications';
|
||||
}
|
||||
|
||||
if (pushTestBtn) {
|
||||
pushTestBtn.disabled = !(Notification.permission === 'granted' && state.pushEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
function applyHighContrast() {
|
||||
document.body.classList.toggle('high-contrast', state.highContrast);
|
||||
if (contrastStatus) contrastStatus.textContent = state.highContrast ? 'On' : 'Off';
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
async function ensurePushSubscription() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
|
||||
|
||||
const keyResp = await fetch('/api/push/public-key').then(r => r.json()).catch(() => null);
|
||||
const publicKey = keyResp?.publicKey;
|
||||
if (!publicKey) return false;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscription: sub,
|
||||
location: state.currentCity ? {
|
||||
latitude: state.currentCity.latitude,
|
||||
longitude: state.currentCity.longitude,
|
||||
} : null,
|
||||
}),
|
||||
});
|
||||
|
||||
state.pushEnabled = true;
|
||||
updateNotifyButton();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendTestPush() {
|
||||
const resp = await fetch('/api/push/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Weather push test',
|
||||
body: state.currentCity ? `Alerts enabled for ${cityLabel(state.currentCity) || 'your location'}` : 'Push notifications are active.',
|
||||
url: window.location.pathname,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Push test failed');
|
||||
}
|
||||
}
|
||||
|
||||
function moonPhase(dateLike) {
|
||||
@@ -196,6 +308,8 @@ function cityKey(city) {
|
||||
|
||||
function persistSavedCities() {
|
||||
localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities));
|
||||
const defaultEntry = state.savedCities.find(e => e.isDefault);
|
||||
localStorage.setItem(STORAGE_KEYS.defaultCity, defaultEntry ? cityKey(defaultEntry.city) : '');
|
||||
}
|
||||
|
||||
function persistLastCity(city) {
|
||||
@@ -211,6 +325,46 @@ function cityLabel(city) {
|
||||
return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts) {
|
||||
if (!ts) return 'Never opened';
|
||||
const mins = Math.max(1, Math.round((Date.now() - ts) / 60000));
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 48) return `${hrs}h ago`;
|
||||
const days = Math.round(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function getDefaultSavedEntry() {
|
||||
return state.savedCities.find(e => e.isDefault) || null;
|
||||
}
|
||||
|
||||
function updateDefaultCityLabel() {
|
||||
if (!defaultCityEl) return;
|
||||
const def = getDefaultSavedEntry();
|
||||
defaultCityEl.textContent = def?.city?.name || 'None';
|
||||
}
|
||||
|
||||
function setDefaultCityByKey(key) {
|
||||
state.savedCities = state.savedCities.map(e => ({
|
||||
...e,
|
||||
isDefault: cityKey(e.city) === key,
|
||||
}));
|
||||
persistSavedCities();
|
||||
updateDefaultCityLabel();
|
||||
renderSavedCities();
|
||||
}
|
||||
|
||||
function moveSavedCity(index, direction) {
|
||||
const next = index + direction;
|
||||
if (next < 0 || next >= state.savedCities.length) return;
|
||||
const arr = [...state.savedCities];
|
||||
[arr[index], arr[next]] = [arr[next], arr[index]];
|
||||
state.savedCities = arr;
|
||||
persistSavedCities();
|
||||
renderSavedCities();
|
||||
}
|
||||
|
||||
function updateShareUrl(city) {
|
||||
if (!city) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -230,10 +384,12 @@ function renderSavedCities() {
|
||||
savedLocationsEl.innerHTML = '';
|
||||
if (!state.savedCities.length) {
|
||||
savedLocationsEl.textContent = 'None yet';
|
||||
updateDefaultCityLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
state.savedCities.forEach(city => {
|
||||
state.savedCities.forEach((entry, index) => {
|
||||
const city = entry.city;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'saved-item';
|
||||
|
||||
@@ -241,7 +397,35 @@ function renderSavedCities() {
|
||||
chip.className = 'saved-chip';
|
||||
chip.type = 'button';
|
||||
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');
|
||||
remove.className = 'saved-remove';
|
||||
@@ -249,15 +433,25 @@ function renderSavedCities() {
|
||||
remove.textContent = 'x';
|
||||
remove.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city));
|
||||
state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city));
|
||||
persistSavedCities();
|
||||
renderSavedCities();
|
||||
});
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'saved-meta';
|
||||
meta.textContent = formatRelativeTime(entry.lastViewedAt);
|
||||
|
||||
wrap.appendChild(defaultBtn);
|
||||
wrap.appendChild(chip);
|
||||
wrap.appendChild(upBtn);
|
||||
wrap.appendChild(downBtn);
|
||||
wrap.appendChild(remove);
|
||||
wrap.appendChild(meta);
|
||||
savedLocationsEl.appendChild(wrap);
|
||||
});
|
||||
|
||||
updateDefaultCityLabel();
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||
@@ -294,7 +488,7 @@ async function reverseGeocode(lat, lon) {
|
||||
}
|
||||
|
||||
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');
|
||||
return r.json();
|
||||
}
|
||||
@@ -311,6 +505,13 @@ async function fetchAlerts(lat, lon) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function fetchLightning(lat, lon) {
|
||||
const ageMin = Number(state.lightningAgeMin || 15);
|
||||
const r = await fetch(`/api/lightning?lat=${lat}&lon=${lon}&age=${ageMin}`);
|
||||
if (!r.ok) throw new Error('Lightning fetch failed');
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function detectedCityFallback(lat, lon) {
|
||||
return {
|
||||
name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
|
||||
@@ -528,6 +729,11 @@ function createOverviewMap(mapEl) {
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
const radarLayer = L.tileLayer('https://tilecache.rainviewer.com/v2/radar/nowcast_0/256/{z}/{x}/{y}/2/1_1.png', {
|
||||
maxZoom: 15,
|
||||
opacity: Math.max(0.15, Math.min(0.9, state.radarOpacity / 100)),
|
||||
}).addTo(map);
|
||||
|
||||
let marker = null;
|
||||
|
||||
function panTo(lat, lon, zoom) {
|
||||
@@ -542,13 +748,20 @@ function createOverviewMap(mapEl) {
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
return { map, panTo };
|
||||
function setRadarOpacity(opacityPct) {
|
||||
const normalized = Math.max(0.15, Math.min(0.9, Number(opacityPct) / 100));
|
||||
radarLayer.setOpacity(normalized);
|
||||
}
|
||||
|
||||
return { map, panTo, setRadarOpacity };
|
||||
}
|
||||
|
||||
function clearLightningMarkers() {
|
||||
function clearLightningLayer() {
|
||||
if (!mapsOverview.map) return;
|
||||
state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker));
|
||||
state.lightningMarkers = [];
|
||||
if (state.lightningLayer) {
|
||||
mapsOverview.map.removeLayer(state.lightningLayer);
|
||||
state.lightningLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindArrows() {
|
||||
@@ -566,41 +779,81 @@ function refreshMapsOverview() {
|
||||
|
||||
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
|
||||
|
||||
clearLightningMarkers();
|
||||
clearLightningLayer();
|
||||
clearWindArrows();
|
||||
|
||||
if (state.showLightning) renderLightningStrikes(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;
|
||||
clearLightningMarkers();
|
||||
clearLightningLayer();
|
||||
|
||||
// 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;
|
||||
try {
|
||||
const data = await fetchLightning(city.latitude, city.longitude);
|
||||
const features = data?.features || [];
|
||||
if (!features.length) return;
|
||||
|
||||
for (let i = 0; i < strokeCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / strokeCount;
|
||||
const radius = 0.18 + i * 0.06;
|
||||
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 layers = [];
|
||||
features.forEach((feature) => {
|
||||
const geom = feature?.geometry;
|
||||
const event = feature?.properties?.event || 'Thunderstorm activity';
|
||||
const severity = feature?.properties?.severity || 'Unknown severity';
|
||||
if (!geom) return;
|
||||
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 4,
|
||||
fillColor: '#ffeb3b',
|
||||
fillOpacity: 0.8,
|
||||
color: '#ff6f00',
|
||||
weight: 1.5,
|
||||
className: 'lightning-marker',
|
||||
}).bindPopup('Sample lightning overlay');
|
||||
const polygonSets = [];
|
||||
if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) {
|
||||
polygonSets.push(geom.coordinates[0]);
|
||||
}
|
||||
if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates)) {
|
||||
geom.coordinates.forEach(poly => {
|
||||
if (Array.isArray(poly?.[0])) polygonSets.push(poly[0]);
|
||||
});
|
||||
}
|
||||
|
||||
marker.addTo(mapsOverview.map);
|
||||
state.lightningMarkers.push(marker);
|
||||
polygonSets.forEach((polyCoords, idx) => {
|
||||
const ring = polyCoords.map(([lon, lat]) => [lat, lon]);
|
||||
const poly = L.polygon(ring, {
|
||||
color: '#ff8b1a',
|
||||
weight: 1.5,
|
||||
opacity: 0.8,
|
||||
fillColor: '#ffd34d',
|
||||
fillOpacity: 0.18,
|
||||
}).bindPopup(`${event} • ${severity}`);
|
||||
layers.push(poly);
|
||||
|
||||
const center = circleCenter(polyCoords);
|
||||
if (center) {
|
||||
const strike = L.circleMarker(center, {
|
||||
radius: 5,
|
||||
fillColor: '#ffe066',
|
||||
fillOpacity: 0.95,
|
||||
color: '#ff6f00',
|
||||
weight: 2,
|
||||
}).bindPopup(`${event} area ${idx + 1}`);
|
||||
layers.push(strike);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!layers.length) return;
|
||||
state.lightningLayer = L.layerGroup(layers).addTo(mapsOverview.map);
|
||||
} catch (e) {
|
||||
console.warn('Lightning overlay unavailable', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
const c = data.current;
|
||||
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-icon">${hw.icon}</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>
|
||||
`;
|
||||
track.appendChild(cell);
|
||||
}
|
||||
|
||||
if (hourlySummaryEl) {
|
||||
hourlySummaryEl.textContent = summarizeHourly(h, startIdx);
|
||||
}
|
||||
|
||||
renderDailyForecast(d);
|
||||
|
||||
document.getElementById('updated-at').textContent =
|
||||
@@ -814,6 +1093,14 @@ function renderAlerts(data) {
|
||||
async function loadAll(city, source) {
|
||||
state.currentCity = city;
|
||||
if (source) state.currentSource = source;
|
||||
|
||||
const saved = state.savedCities.find(e => cityKey(e.city) === cityKey(city));
|
||||
if (saved) {
|
||||
saved.lastViewedAt = Date.now();
|
||||
persistSavedCities();
|
||||
renderSavedCities();
|
||||
}
|
||||
|
||||
clearErr();
|
||||
hide(suggestEl);
|
||||
hide(weatherDiv);
|
||||
@@ -974,8 +1261,13 @@ shareBtn.addEventListener('click', async () => {
|
||||
saveCurrentBtn.addEventListener('click', () => {
|
||||
if (!state.currentCity) return;
|
||||
const key = cityKey(state.currentCity);
|
||||
if (state.savedCities.some(c => cityKey(c) === key)) return;
|
||||
state.savedCities.push(state.currentCity);
|
||||
if (state.savedCities.some(c => cityKey(c.city) === key)) return;
|
||||
state.savedCities.push({
|
||||
city: state.currentCity,
|
||||
addedAt: Date.now(),
|
||||
lastViewedAt: Date.now(),
|
||||
isDefault: state.savedCities.length === 0,
|
||||
});
|
||||
persistSavedCities();
|
||||
renderSavedCities();
|
||||
});
|
||||
@@ -993,9 +1285,11 @@ notifyBtn.addEventListener('click', async () => {
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
updateNotifyButton();
|
||||
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) {
|
||||
const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] }));
|
||||
renderAlerts(alerts);
|
||||
@@ -1005,6 +1299,7 @@ notifyBtn.addEventListener('click', async () => {
|
||||
}
|
||||
} catch {
|
||||
showErr('Could not request notification permission.');
|
||||
} finally {
|
||||
updateNotifyButton();
|
||||
}
|
||||
});
|
||||
@@ -1019,7 +1314,7 @@ lightningToggle.addEventListener('change', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
||||
lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
||||
if (state.currentCity && mapsOverview.map) {
|
||||
clearLightningMarkers();
|
||||
clearLightningLayer();
|
||||
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() {
|
||||
clearInterval(state.autoRefreshTimer);
|
||||
state.autoRefreshTimer = setInterval(() => {
|
||||
@@ -1063,16 +1396,33 @@ function startAutoRefresh() {
|
||||
updateUnitsButton();
|
||||
updateNotifyButton();
|
||||
syncAutoDetectToggle();
|
||||
|
||||
// Sync lightning and wind toggle states
|
||||
|
||||
// Sync lightning/wind and UI polish toggles.
|
||||
lightningToggle.checked = state.showLightning;
|
||||
lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
||||
windToggle.checked = state.showWind;
|
||||
windStatus.textContent = state.showWind ? 'On' : 'Off';
|
||||
|
||||
if (contrastToggle) contrastToggle.checked = state.highContrast;
|
||||
if (lightningAgeEl) lightningAgeEl.value = String(state.lightningAgeMin || 15);
|
||||
if (radarOpacityEl) radarOpacityEl.value = String(state.radarOpacity || 65);
|
||||
applyHighContrast();
|
||||
mapsOverview.setRadarOpacity(state.radarOpacity);
|
||||
|
||||
renderSavedCities();
|
||||
startAutoRefresh();
|
||||
|
||||
// Restore push state if already subscribed.
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const existingSub = await reg.pushManager.getSubscription();
|
||||
state.pushEnabled = Boolean(existingSub);
|
||||
} catch {
|
||||
state.pushEnabled = false;
|
||||
}
|
||||
updateNotifyButton();
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const qLat = Number(qs.get('lat'));
|
||||
const qLon = Number(qs.get('lon'));
|
||||
@@ -1096,6 +1446,13 @@ function startAutoRefresh() {
|
||||
if (autoLocated) return;
|
||||
}
|
||||
|
||||
const defaultSaved = getDefaultSavedEntry();
|
||||
if (defaultSaved?.city) {
|
||||
searchInput.value = defaultSaved.city.name;
|
||||
await loadAll(defaultSaved.city, 'default-saved-city');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastCity = getLastCity();
|
||||
if (lastCity) {
|
||||
searchInput.value = lastCity.name;
|
||||
|
||||
+38
-2
@@ -122,6 +122,7 @@
|
||||
<span class="section-label">Hourly Forecast</span>
|
||||
<span class="section-note">next 24 hours</span>
|
||||
</div>
|
||||
<p id="hourly-summary" class="hourly-summary">--</p>
|
||||
<div id="hourly-scroll" class="hourly-scroll">
|
||||
<div id="hourly-track" class="hourly-track"></div>
|
||||
</div>
|
||||
@@ -166,6 +167,20 @@
|
||||
<div id="tab-maps" class="tab-panel hidden">
|
||||
<div class="maps-wrap">
|
||||
<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-card">
|
||||
<div class="stat-label">Data Source Location</div>
|
||||
@@ -176,7 +191,11 @@
|
||||
<div id="maps-wind" class="maps-value">--</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>
|
||||
|
||||
@@ -185,7 +204,10 @@
|
||||
<div id="alerts-wrap" class="alerts-wrap hidden">
|
||||
<div class="section-head">
|
||||
<span class="section-label">Weather Alerts</span>
|
||||
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
|
||||
<span>
|
||||
<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 id="alerts-list" class="alerts-list"></div>
|
||||
</div>
|
||||
@@ -200,6 +222,7 @@
|
||||
<span>Saved locations</span>
|
||||
<button id="save-current-btn" class="link-btn" type="button">Save current</button>
|
||||
</div>
|
||||
<p class="saved-help">Use star for default, arrows to reorder.</p>
|
||||
<div id="saved-locations" class="saved-locations"></div>
|
||||
</div>
|
||||
|
||||
@@ -216,6 +239,11 @@
|
||||
<span id="settings-location" class="settings-value">--</span>
|
||||
</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">
|
||||
<span class="settings-label">Units</span>
|
||||
<button id="units-btn-settings" class="settings-btn" type="button">US</button>
|
||||
@@ -236,6 +264,14 @@
|
||||
<span id="wind-status">Off</span>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
|
||||
+104
-5
@@ -168,13 +168,18 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.saved-locations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-mid);
|
||||
}
|
||||
|
||||
.saved-help {
|
||||
font-size: 0.62rem;
|
||||
color: var(--ink-light);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.auto-detect-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -210,13 +215,17 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
display: inline-flex;
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 28px 28px 28px auto;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper-dark);
|
||||
}
|
||||
|
||||
.saved-chip,
|
||||
.saved-remove {
|
||||
.saved-remove,
|
||||
.saved-default,
|
||||
.saved-move {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
@@ -225,8 +234,25 @@ a:hover { text-decoration: underline; }
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saved-remove {
|
||||
.saved-chip {
|
||||
text-align: left;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -440,6 +466,33 @@ a:hover { text-decoration: underline; }
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -459,6 +512,22 @@ a:hover { text-decoration: underline; }
|
||||
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-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; }
|
||||
@@ -607,6 +676,12 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.hourly-track { display: flex; min-width: max-content; }
|
||||
|
||||
.hourly-summary {
|
||||
font-size: 0.68rem;
|
||||
color: var(--ink-mid);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.hour-cell {
|
||||
min-width: 72px;
|
||||
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-icon { font-size: 1.4rem; margin-bottom: 0.3rem; }
|
||||
.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; }
|
||||
|
||||
/* ── Daily forecast ── */
|
||||
@@ -707,6 +783,26 @@ a:hover { text-decoration: underline; }
|
||||
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 ── */
|
||||
@media (max-width: 640px) {
|
||||
.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-value { text-align: left; }
|
||||
.map-overview { height: 280px; }
|
||||
.maps-controls,
|
||||
.maps-meta { grid-template-columns: 1fr; }
|
||||
.saved-item { grid-template-columns: 28px 1fr 28px 28px 28px; }
|
||||
.saved-meta { display: none; }
|
||||
}
|
||||
+36
-1
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const CACHE = 'weather-v1';
|
||||
const CACHE = 'weather-v2';
|
||||
|
||||
// App shell — cached on install for offline use
|
||||
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
@@ -1,19 +1,88 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const webpush = require('web-push');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
|
||||
// Lightweight request logging for observability.
|
||||
app.use((req, res, next) => {
|
||||
const started = Date.now();
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - started;
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) {
|
||||
let lastErr = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const r = await fetch(url, options);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (i < attempts - 1) {
|
||||
await sleep(initialDelayMs * Math.pow(2, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('Unknown upstream fetch error');
|
||||
}
|
||||
|
||||
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.get('/healthz', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
pushConfigured: pushConfigured(),
|
||||
subscriptionCount: subscriptions.size,
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Geocoding proxy
|
||||
app.get('/api/geocode', async (req, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q) return res.status(400).json({ error: 'Missing query' });
|
||||
try {
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
const data = await fetchJsonWithRetry(url);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
||||
@@ -35,9 +104,8 @@ app.get('/api/reverse-geocode', async (req, res) => {
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
const omResp = await fetch(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`);
|
||||
if (omResp.ok) {
|
||||
const omData = await omResp.json();
|
||||
const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
|
||||
if (omData) {
|
||||
const top = omData?.results?.[0];
|
||||
if (top) {
|
||||
return res.json({
|
||||
@@ -60,18 +128,13 @@ app.get('/api/reverse-geocode', async (req, res) => {
|
||||
addressdetails: '1',
|
||||
});
|
||||
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error(`Reverse geocoding failed with status ${r.status}`);
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
const address = data.address || {};
|
||||
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
|
||||
|
||||
@@ -117,8 +180,7 @@ 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();
|
||||
const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
|
||||
@@ -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(','),
|
||||
forecast_days: 1,
|
||||
});
|
||||
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
|
||||
const data = await r.json();
|
||||
const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
|
||||
@@ -150,24 +211,123 @@ 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 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: {
|
||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||
Accept: 'application/geo+json',
|
||||
},
|
||||
});
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
|
||||
app.get('/api/lightning', async (req, res) => {
|
||||
const { lat, lon, age } = req.query;
|
||||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||
const ageMinutes = Number(age || 15);
|
||||
|
||||
try {
|
||||
const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
||||
headers: {
|
||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||
Accept: 'application/geo+json',
|
||||
},
|
||||
});
|
||||
|
||||
const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
|
||||
const features = (data?.features || []).filter(f => {
|
||||
const p = f?.properties || {};
|
||||
const event = String(p.event || '').toLowerCase();
|
||||
const relevant = event.includes('thunderstorm') || event.includes('lightning');
|
||||
if (!relevant) return false;
|
||||
const ts = Date.parse(p.sent || p.onset || p.effective || '');
|
||||
if (Number.isNaN(ts)) return true;
|
||||
return ts >= cutoff;
|
||||
});
|
||||
|
||||
res.json({
|
||||
source: 'weather.gov alerts',
|
||||
count: features.length,
|
||||
features,
|
||||
ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/push/public-key', (_req, res) => {
|
||||
const { publicKey } = getVapidConfig();
|
||||
res.json({
|
||||
enabled: pushConfigured(),
|
||||
publicKey: publicKey || null,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/push/subscribe', (req, res) => {
|
||||
const { subscription, location } = req.body || {};
|
||||
if (!subscription?.endpoint) {
|
||||
return res.status(400).json({ error: 'Missing subscription endpoint' });
|
||||
}
|
||||
subscriptions.set(subscription.endpoint, {
|
||||
subscription,
|
||||
location: location || null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
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
|
||||
app.get('/api/radar/times', async (req, res) => {
|
||||
try {
|
||||
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json');
|
||||
const data = await r.json();
|
||||
const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Radar times failed', detail: e.message });
|
||||
|
||||
Reference in new issue
Block a user