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
+905
-74
No files matched your search
Generated
+144
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||||
|
|||||||
Reference in new issue
Block a user