From 52c96225b95b264e8da0baedacaf06145e26aa67 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 16 Mar 2026 13:10:34 -0400 Subject: [PATCH] Add real lightning overlay, push notifications, map controls, hourly summary, favorites enhancements, API resilience, UI polish --- app/package-lock.json | 145 +++++++++++++- app/package.json | 3 +- app/public/app.js | 455 +++++++++++++++++++++++++++++++++++++----- app/public/index.html | 40 +++- app/public/style.css | 109 +++++++++- app/public/sw.js | 37 +++- app/server.js | 198 ++++++++++++++++-- 7 files changed, 909 insertions(+), 78 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index afaa49f..34e1a58 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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" + } } } } diff --git a/app/package.json b/app/package.json index 2c615f6..52590b7 100644 --- a/app/package.json +++ b/app/package.json @@ -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" diff --git a/app/public/app.js b/app/public/app.js index a5059f1..bf8df08 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -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) {
${i === startIdx ? 'Now' : formatHour(h.time[i])}
${hw.icon}
${tempDisplay(h.temperature_2m[i])}°
+
Feels ${tempDisplay(h.apparent_temperature?.[i])}°
${pp > 0 ? `${pp}% ${ptype}` : ptype}
`; 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; diff --git a/app/public/index.html b/app/public/index.html index c1bd440..1c3c34e 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -122,6 +122,7 @@ Hourly Forecast next 24 hours +

--

@@ -166,6 +167,20 @@ @@ -185,7 +204,10 @@ @@ -200,6 +222,7 @@ Saved locations +

Use star for default, arrows to reorder.

@@ -216,6 +239,11 @@ -- +
+ Default city + None +
+
Units @@ -236,6 +264,14 @@ Off
+ +
+ High contrast mode + +
diff --git a/app/public/style.css b/app/public/style.css index bf1d62d..facffb8 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -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; } } diff --git a/app/public/sw.js b/app/public/sw.js index 8019805..05deeb3 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -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); + }) + ); +}); diff --git a/app/server.js b/app/server.js index c938732..bf12087 100644 --- a/app/server.js +++ b/app/server.js @@ -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 });