Merge branch 'fix/network-main-parity' into main (resolve conflicts)
This commit is contained in:
commit
49b54e0fe4
22 files changed
+11297
-2079
No files matched your search
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,405 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>NeCloud Weather</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon.svg">
|
||||||
|
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NeCloud Weather">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<h1>NeCloud Weather</h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="conditions">Conditions</button>
|
||||||
|
<button class="tab" data-tab="radar-full">Radar</button>
|
||||||
|
<button class="tab" data-tab="maps">Maps</button>
|
||||||
|
<button class="tab" data-tab="alerts">Alerts</button>
|
||||||
|
<button class="tab" data-tab="settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
<span class="tagline">Open-Meteo · RainViewer</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Search bar (shared, always visible) -->
|
||||||
|
<div class="search-wrap">
|
||||||
|
<div class="search-row">
|
||||||
|
<input id="search-input" type="text" placeholder="City, region, or country…" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="search-btn">Search</button>
|
||||||
|
<button id="locate-btn" title="Use my location">⌖</button>
|
||||||
|
<button id="units-btn" title="Toggle units">US</button>
|
||||||
|
</div>
|
||||||
|
<ul id="suggestions" class="suggestions hidden"></ul>
|
||||||
|
<p id="error-msg" class="error hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="loading" class="loading hidden">
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="loading-icon">🌤️</div>
|
||||||
|
<div class="loading-text">Fetching weather data…</div>
|
||||||
|
<div class="loading-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: CONDITIONS ── -->
|
||||||
|
<div id="tab-conditions" class="tab-panel">
|
||||||
|
<div id="weather" class="hidden">
|
||||||
|
|
||||||
|
<div class="loc-header">
|
||||||
|
<div>
|
||||||
|
<div id="loc-name" class="loc-name"></div>
|
||||||
|
<div id="loc-sub" class="loc-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loc-actions">
|
||||||
|
<div id="loc-time" class="loc-time"></div>
|
||||||
|
<button id="share-btn" class="link-btn" type="button">Share location</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── HERO ── -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-left">
|
||||||
|
<div id="cur-icon" class="hero-icon"></div>
|
||||||
|
<div id="cur-desc" class="hero-desc"></div>
|
||||||
|
<div id="cur-precip" class="hero-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-center">
|
||||||
|
<div class="hero-temp"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
||||||
|
<div class="feels">Feels like <span id="cur-feels">--</span>°F</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-right">
|
||||||
|
<div class="hero-hilo">
|
||||||
|
<div class="hilo-item">
|
||||||
|
<span class="hilo-arrow">↑</span>
|
||||||
|
<span id="cur-hi">--</span>°
|
||||||
|
<div class="hilo-label">High</div>
|
||||||
|
</div>
|
||||||
|
<div class="hilo-divider"></div>
|
||||||
|
<div class="hilo-item">
|
||||||
|
<span class="hilo-arrow">↓</span>
|
||||||
|
<span id="cur-lo">--</span>°
|
||||||
|
<div class="hilo-label">Low</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SUN & MOON TIMELINE ── -->
|
||||||
|
<div class="sun-section">
|
||||||
|
<div class="sun-timeline-wrap">
|
||||||
|
<div class="sun-endpoint">
|
||||||
|
<span class="sun-ep-icon">🌅</span>
|
||||||
|
<span id="sunrise-val" class="sun-ep-time">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="sun-track-wrap">
|
||||||
|
<div class="sun-track">
|
||||||
|
<div class="sun-progress" id="sun-progress"></div>
|
||||||
|
<div class="sun-dot" id="sun-dot">☀️</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sun-endpoint">
|
||||||
|
<span class="sun-ep-icon">🌇</span>
|
||||||
|
<span id="sunset-val" class="sun-ep-time">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="moon-row">
|
||||||
|
<span class="moon-emoji">🌙</span>
|
||||||
|
<span id="moon-phase-val">--</span>
|
||||||
|
<span class="daylight-info" id="daylight-info">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── WIND & ATMOSPHERE ── -->
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Wind & Atmosphere</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-group">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Wind</div>
|
||||||
|
<div class="stat-val"><span id="cur-wind">--</span> <span class="stat-unit wind-unit">mph</span></div>
|
||||||
|
<div class="stat-sub" id="cur-wind-dir">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Gusts</div>
|
||||||
|
<div class="stat-val"><span id="cur-gust">--</span> <span class="stat-unit gust-unit">mph</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Humidity</div>
|
||||||
|
<div class="stat-val"><span id="cur-hum">--</span><span class="stat-unit">%</span></div>
|
||||||
|
<div class="stat-sub" id="hum-feel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Dewpoint</div>
|
||||||
|
<div class="stat-val"><span id="cur-dew">--</span>°</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Pressure</div>
|
||||||
|
<div class="stat-val"><span id="cur-pres">--</span> <span class="stat-unit">hPa</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Visibility</div>
|
||||||
|
<div class="stat-val"><span id="cur-vis">--</span> <span class="stat-unit vis-unit">mi</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MORE DETAILS DRAWER ── -->
|
||||||
|
<details class="details-drawer">
|
||||||
|
<summary class="drawer-summary">More Details <span class="drawer-chevron">›</span></summary>
|
||||||
|
<div class="drawer-body">
|
||||||
|
<div class="card-group drawer-card-group">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">UV Index</div>
|
||||||
|
<div class="stat-val"><span id="cur-uv">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Cloud Cover</div>
|
||||||
|
<div class="stat-val"><span id="cur-cloud">--</span><span class="stat-unit">%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">US AQI</div>
|
||||||
|
<div class="stat-val"><span id="aqi-us">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">PM2.5</div>
|
||||||
|
<div class="stat-val"><span id="pm25">--</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Pollen</div>
|
||||||
|
<div class="stat-val"><span id="pollen">--</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- ── WHAT TO BRING ── -->
|
||||||
|
<div class="section-head" style="margin-top:1.5rem">
|
||||||
|
<span class="section-label">What to Bring</span>
|
||||||
|
</div>
|
||||||
|
<div id="recommendations" class="recs-grid"></div>
|
||||||
|
|
||||||
|
<!-- Embedded radar panel -->
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Radar</span>
|
||||||
|
<span class="section-note">
|
||||||
|
<button class="link-btn" id="open-radar-tab">Open full radar →</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="radar-embed-wrap">
|
||||||
|
<div id="radar-embed" class="radar-map"></div>
|
||||||
|
<div class="radar-controls">
|
||||||
|
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
|
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
||||||
|
<div class="radar-timeline" id="embed-timeline"></div>
|
||||||
|
<span id="embed-timestamp" class="radar-ts">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hourly -->
|
||||||
|
<div class="section-head" style="margin-top:1.5rem">
|
||||||
|
<span class="section-label">Hourly Forecast</span>
|
||||||
|
<span class="section-note">next 24 hours</span>
|
||||||
|
</div>
|
||||||
|
<div id="hourly-scroll" class="hourly-scroll">
|
||||||
|
<div id="hourly-track" class="hourly-track"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7-day -->
|
||||||
|
<div class="section-head">
|
||||||
|
<span id="daily-forecast-label" class="section-label">7-Day Forecast</span>
|
||||||
|
<button id="daily-toggle-btn" class="link-btn" type="button">Show 16-day</button>
|
||||||
|
</div>
|
||||||
|
<div id="daily-list" class="daily-list"></div>
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
|
||||||
|
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
|
||||||
|
Updated <span id="updated-at"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: FULL RADAR ── -->
|
||||||
|
<div id="tab-radar-full" class="tab-panel hidden">
|
||||||
|
<div class="radar-full-wrap">
|
||||||
|
<div id="radar-full" class="radar-map radar-map-full"></div>
|
||||||
|
<div class="radar-controls radar-controls-full">
|
||||||
|
<button id="full-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
|
<button id="full-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
||||||
|
<div class="radar-timeline" id="full-timeline"></div>
|
||||||
|
<span id="full-timestamp" class="radar-ts">--</span>
|
||||||
|
<div class="radar-legend">
|
||||||
|
<span class="legend-label">Light</span>
|
||||||
|
<div class="legend-bar"></div>
|
||||||
|
<span class="legend-label">Heavy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="radar-attribution">Radar tiles © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> · Map © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: MAPS ── -->
|
||||||
|
<div id="tab-maps" class="tab-panel hidden">
|
||||||
|
<div class="maps-wrap">
|
||||||
|
<div class="maps-header">
|
||||||
|
<h3>Live Weather Overlays</h3>
|
||||||
|
<div class="maps-quick-toggles">
|
||||||
|
<button id="maps-lightning-toggle" class="overlay-toggle lightning-btn" title="Toggle lightning strikes">⚡ Lightning</button>
|
||||||
|
<button id="maps-wind-toggle" class="overlay-toggle wind-btn active" title="Toggle wind overlay">💨 Wind</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="map-overview" class="radar-map map-overview"></div>
|
||||||
|
|
||||||
|
<div class="maps-legend">
|
||||||
|
<div class="legend-section">
|
||||||
|
<div class="legend-title">⚡ Lightning Strikes</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="5" fill="#ffeb3b" stroke="#ff6f00" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
<span>Recent strike location</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend-section">
|
||||||
|
<div class="legend-title">💨 Wind Flow</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<svg width="32" height="16" viewBox="0 0 32 16">
|
||||||
|
<line x1="2" y1="8" x2="24" y2="8" stroke="#4a90e2" stroke-width="2"/>
|
||||||
|
<polygon points="24,8 20,5 20,11" fill="#4a90e2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Wind direction & speed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="maps-data">
|
||||||
|
<div class="maps-card">
|
||||||
|
<div class="card-title">Location</div>
|
||||||
|
<div id="maps-location" class="maps-value">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="maps-card wind-card">
|
||||||
|
<div class="card-title">Wind Data</div>
|
||||||
|
<div class="wind-stats">
|
||||||
|
<div class="wind-stat">
|
||||||
|
<span class="stat-label">Speed</span>
|
||||||
|
<span id="wind-speed" class="stat-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wind-stat">
|
||||||
|
<span class="stat-label">Gust</span>
|
||||||
|
<span id="wind-gust" class="stat-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wind-stat">
|
||||||
|
<span class="stat-label">Direction</span>
|
||||||
|
<span id="wind-direction" class="stat-value">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: ALERTS ── -->
|
||||||
|
<div id="tab-alerts" class="tab-panel hidden">
|
||||||
|
<div id="alerts-wrap" class="alerts-wrap hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Weather Alerts</span>
|
||||||
|
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
|
||||||
|
</div>
|
||||||
|
<div id="alerts-list" class="alerts-list"></div>
|
||||||
|
</div>
|
||||||
|
<p id="alerts-empty" class="panel-note">No active alerts for this location.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: SETTINGS ── -->
|
||||||
|
<div id="tab-settings" class="tab-panel hidden">
|
||||||
|
<div class="settings-wrap">
|
||||||
|
<div class="saved-wrap">
|
||||||
|
<div class="saved-head">
|
||||||
|
<span>Saved locations</span>
|
||||||
|
<button id="save-current-btn" class="link-btn" type="button">Save current</button>
|
||||||
|
</div>
|
||||||
|
<div id="saved-locations" class="saved-locations"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Auto-detect location on startup</span>
|
||||||
|
<label class="switch-toggle">
|
||||||
|
<input id="auto-detect-toggle" type="checkbox" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Data source location</span>
|
||||||
|
<span id="settings-location" class="settings-value">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Units</span>
|
||||||
|
<button id="units-btn-settings" class="settings-btn" type="button">US</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Lightning strikes on map</span>
|
||||||
|
<label class="switch-toggle">
|
||||||
|
<input id="lightning-toggle" type="checkbox">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Wind overlay on map</span>
|
||||||
|
<label class="switch-toggle">
|
||||||
|
<input id="wind-toggle" type="checkbox">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- .app -->
|
||||||
|
|
||||||
|
<nav class="bottom-nav" aria-label="Mobile navigation">
|
||||||
|
<button class="bottom-tab active" data-tab="conditions" type="button">
|
||||||
|
<span class="bottom-icon">⛅</span>
|
||||||
|
<span class="bottom-label">Now</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-tab" data-tab="radar-full" type="button">
|
||||||
|
<span class="bottom-icon">📡</span>
|
||||||
|
<span class="bottom-label">Radar</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-tab" data-tab="maps" type="button">
|
||||||
|
<span class="bottom-icon">🗺️</span>
|
||||||
|
<span class="bottom-label">Maps</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-tab" data-tab="alerts" type="button">
|
||||||
|
<span class="bottom-icon">⚠️</span>
|
||||||
|
<span class="bottom-label">Alerts</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-tab" data-tab="settings" type="button">
|
||||||
|
<span class="bottom-icon">⚙️</span>
|
||||||
|
<span class="bottom-label">Settings</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const weatherCache = new Map();
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Prevent browser favicon requests from showing noisy 404s.
|
||||||
|
app.get('/favicon.ico', (_req, res) => {
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geocoding proxy
|
||||||
|
app.get('/api/geocode', async (req, res) => {
|
||||||
|
const { q } = req.query;
|
||||||
|
if (!q) return res.status(400).json({ error: 'Missing query' });
|
||||||
|
try {
|
||||||
|
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
|
||||||
|
|
||||||
|
let openMeteoData = null;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 9000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
|
||||||
|
openMeteoData = await r.json();
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (attempt === 2) {
|
||||||
|
console.warn('Open-Meteo geocode failed after retries:', err.message);
|
||||||
|
} else {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openMeteoData?.results?.length) {
|
||||||
|
return res.json(openMeteoData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
|
||||||
|
const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
|
||||||
|
const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nom.ok) {
|
||||||
|
throw new Error(`Nominatim geocode status ${nom.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomData = await nom.json();
|
||||||
|
const results = (nomData || []).map(item => {
|
||||||
|
const address = item.address || {};
|
||||||
|
return {
|
||||||
|
name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
|
||||||
|
latitude: Number(item.lat),
|
||||||
|
longitude: Number(item.lon),
|
||||||
|
country: address.country || '',
|
||||||
|
admin1: address.state || address.region || address.county || '',
|
||||||
|
timezone: 'auto',
|
||||||
|
};
|
||||||
|
}).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
|
||||||
|
|
||||||
|
return res.json({ results });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse geocoding proxy
|
||||||
|
app.get('/api/reverse-geocode', async (req, res) => {
|
||||||
|
const { lat, lon } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
format: 'jsonv2',
|
||||||
|
lat: String(lat),
|
||||||
|
lon: String(lon),
|
||||||
|
zoom: '10',
|
||||||
|
addressdetails: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await fetch(`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';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
name,
|
||||||
|
admin1: address.state || address.region || address.county || '',
|
||||||
|
country: address.country || '',
|
||||||
|
latitude: Number(lat),
|
||||||
|
longitude: Number(lon),
|
||||||
|
timezone: 'auto',
|
||||||
|
display_name: data.display_name || '',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weather proxy
|
||||||
|
app.get('/api/weather', async (req, res) => {
|
||||||
|
const { lat, lon, tz, units } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
const timezone = tz || 'auto';
|
||||||
|
const useMetric = units === 'metric';
|
||||||
|
const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: lat, longitude: lon, timezone,
|
||||||
|
current: [
|
||||||
|
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
||||||
|
'is_day','precipitation','weather_code','cloud_cover',
|
||||||
|
'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
|
||||||
|
].join(','),
|
||||||
|
hourly: [
|
||||||
|
'temperature_2m','precipitation_probability','weather_code',
|
||||||
|
'wind_speed_10m','apparent_temperature','rain','snowfall'
|
||||||
|
].join(','),
|
||||||
|
daily: [
|
||||||
|
'weather_code','temperature_2m_max','temperature_2m_min',
|
||||||
|
'precipitation_sum','precipitation_probability_max',
|
||||||
|
'wind_speed_10m_max','sunrise','sunset','uv_index_max'
|
||||||
|
].join(','),
|
||||||
|
forecast_days: 16,
|
||||||
|
wind_speed_unit: useMetric ? 'kmh' : 'mph',
|
||||||
|
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
|
||||||
|
precipitation_unit: useMetric ? 'mm' : 'inch'
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
const upstreamTimeoutMs = 12000;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const body = await r.text();
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
weatherCache.set(cacheKey, { data, at: Date.now() });
|
||||||
|
return res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = err;
|
||||||
|
const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
|
||||||
|
console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
|
||||||
|
if (attempt < 3) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Weather upstream failed');
|
||||||
|
} catch (e) {
|
||||||
|
const cached = weatherCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
|
||||||
|
res.setHeader('X-Weather-Cache', 'stale-fallback');
|
||||||
|
console.warn('Weather proxy using stale cache for', cacheKey);
|
||||||
|
return res.json(cached.data);
|
||||||
|
}
|
||||||
|
console.error('Weather proxy failed:', e.message);
|
||||||
|
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open-Meteo Air Quality proxy
|
||||||
|
app.get('/api/air-quality', async (req, res) => {
|
||||||
|
const { lat, lon, tz } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
timezone: tz || 'auto',
|
||||||
|
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
|
||||||
|
forecast_days: 1,
|
||||||
|
});
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Open-Meteo status ${r.status}`);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
return res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = err;
|
||||||
|
if (attempt < 2) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Air quality upstream failed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Air quality fetch error:', e);
|
||||||
|
res.setHeader('X-Air-Quality-Fallback', 'empty');
|
||||||
|
res.json({ current: {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NWS alerts proxy (US only)
|
||||||
|
app.get('/api/alerts', async (req, res) => {
|
||||||
|
const { lat, lon } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
|
|
||||||
|
const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||||
|
Accept: 'application/geo+json',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`NWS API returned ${r.status}`);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Alerts fetch error:', e.message);
|
||||||
|
res.setHeader('X-Alerts-Fallback', 'empty');
|
||||||
|
res.json({ features: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// RainViewer timestamps proxy
|
||||||
|
app.get('/api/radar/times', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 25000);
|
||||||
|
|
||||||
|
const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`RainViewer API returned ${r.status}`);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Radar times fetch error:', e.message);
|
||||||
|
res.status(500).json({ error: 'Radar times failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Disable offline caching so UI updates are always immediate.
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map(k => caches.delete(k)));
|
||||||
|
await self.clients.claim();
|
||||||
|
await self.registration.unregister();
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', () => {
|
||||||
|
// Intentionally no fetch interception.
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,322 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>NeCloud Weather</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NeCloud Weather">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="style.css?v=6">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<h1>NeCloud Weather</h1>
|
||||||
|
<span class="tagline">Open-Meteo · RainViewer</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Search bar (shared, always visible) -->
|
||||||
|
<div class="search-wrap">
|
||||||
|
<div class="search-row">
|
||||||
|
<input id="search-input" type="text" placeholder="City, region, or country…" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="search-btn">Search</button>
|
||||||
|
<button id="locate-btn" title="Use my location">⌖</button>
|
||||||
|
<button id="units-btn" title="Toggle units">US</button>
|
||||||
|
</div>
|
||||||
|
<ul id="suggestions" class="suggestions hidden"></ul>
|
||||||
|
<p id="error-msg" class="error hidden"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="loading" class="loading hidden">
|
||||||
|
<span class="loading-dot"></span>Fetching weather data…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: CONDITIONS ── -->
|
||||||
|
<div id="tab-conditions" class="tab-panel">
|
||||||
|
<div id="weather" class="hidden">
|
||||||
|
|
||||||
|
<div class="loc-header">
|
||||||
|
<div>
|
||||||
|
<div id="loc-name" class="loc-name"></div>
|
||||||
|
<div id="loc-sub" class="loc-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loc-actions">
|
||||||
|
<div id="loc-time" class="loc-time"></div>
|
||||||
|
<button id="share-btn" class="link-btn" type="button">Share location</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current hero -->
|
||||||
|
<div class="current-grid">
|
||||||
|
<div class="current-temp">
|
||||||
|
<div class="temp-big"><span id="cur-temp">--</span><span class="deg">°F</span></div>
|
||||||
|
<div class="feels">Feels like <span id="cur-feels">--</span>°F</div>
|
||||||
|
<div class="hi-lo">↑<span id="cur-hi">--</span>° · ↓<span id="cur-lo">--</span>°</div>
|
||||||
|
</div>
|
||||||
|
<div class="current-cond">
|
||||||
|
<div id="cur-icon" class="cond-icon"></div>
|
||||||
|
<div id="cur-desc" class="cond-desc"></div>
|
||||||
|
<div id="cur-precip" class="cond-sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat"><div class="stat-label">Humidity</div><div class="stat-val"><span id="cur-hum">--</span><span class="unit">%</span></div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Wind</div><div class="stat-val"><span id="cur-wind">--</span><span class="unit">mph</span></div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Pressure</div><div class="stat-val"><span id="cur-pres">--</span><span class="unit">hPa</span></div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Visibility</div><div class="stat-val"><span id="cur-vis">--</span><span class="unit">mi</span></div></div>
|
||||||
|
<div class="stat"><div class="stat-label">UV Index</div><div class="stat-val"><span id="cur-uv">--</span></div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Cloud Cover</div><div class="stat-val"><span id="cur-cloud">--</span><span class="unit">%</span></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="astro-grid">
|
||||||
|
<div class="astro-card"><div class="stat-label">Sunrise</div><div id="sunrise-val" class="astro-val">--</div></div>
|
||||||
|
<div class="astro-card"><div class="stat-label">Sunset</div><div id="sunset-val" class="astro-val">--</div></div>
|
||||||
|
<div class="astro-card"><div class="stat-label">Moon Phase</div><div id="moon-phase-val" class="astro-val">--</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="aq-grid">
|
||||||
|
<div class="aq-card"><div class="stat-label">US AQI</div><div id="aqi-us" class="aq-val">--</div></div>
|
||||||
|
<div class="aq-card"><div class="stat-label">PM2.5</div><div id="pm25" class="aq-val">--</div></div>
|
||||||
|
<div class="aq-card"><div class="stat-label">Pollen</div><div id="pollen" class="aq-val">--</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedded radar panel -->
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Radar</span>
|
||||||
|
<span class="section-note">
|
||||||
|
<button class="link-btn" id="open-radar-tab">Open full radar →</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="radar-embed-wrap">
|
||||||
|
<div id="radar-embed" class="radar-map"></div>
|
||||||
|
<div class="radar-controls">
|
||||||
|
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
|
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
||||||
|
<div class="radar-timeline" id="embed-timeline"></div>
|
||||||
|
<span id="embed-timestamp" class="radar-ts">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hourly -->
|
||||||
|
<div class="section-head" style="margin-top:1.5rem">
|
||||||
|
<span class="section-label">Hourly Forecast</span>
|
||||||
|
<span class="section-note">next 24 hours</span>
|
||||||
|
</div>
|
||||||
|
<p id="hourly-summary" class="hourly-summary">--</p>
|
||||||
|
<div id="hourly-scroll" class="hourly-scroll">
|
||||||
|
<div id="hourly-track" class="hourly-track"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7-day -->
|
||||||
|
<div class="section-head">
|
||||||
|
<span id="daily-forecast-label" class="section-label">7-Day Forecast</span>
|
||||||
|
<span class="section-note">
|
||||||
|
<button id="daily-forecast-toggle" class="link-btn hidden" type="button">Show full forecast</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="daily-list" class="daily-list"></div>
|
||||||
|
|
||||||
|
<div class="footer-note">
|
||||||
|
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
|
||||||
|
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
|
||||||
|
Updated <span id="updated-at"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: FULL RADAR ── -->
|
||||||
|
<div id="tab-radar-full" class="tab-panel hidden">
|
||||||
|
<div class="radar-full-wrap">
|
||||||
|
<div id="radar-full" class="radar-map radar-map-full"></div>
|
||||||
|
<div class="radar-controls radar-controls-full">
|
||||||
|
<button id="full-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
|
||||||
|
<button id="full-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
|
||||||
|
<div class="radar-timeline" id="full-timeline"></div>
|
||||||
|
<span id="full-timestamp" class="radar-ts">--</span>
|
||||||
|
<div class="radar-legend">
|
||||||
|
<span class="legend-label">Light</span>
|
||||||
|
<div class="legend-bar"></div>
|
||||||
|
<span class="legend-label">Heavy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="radar-attribution">Radar tiles © <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> · Map © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: MAPS ── -->
|
||||||
|
<div id="tab-maps" class="tab-panel hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Maps</span>
|
||||||
|
<span class="section-actions">
|
||||||
|
<button id="maps-fullscreen-btn" class="link-btn" type="button">Fullscreen map</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="maps-wrap">
|
||||||
|
<div id="map-overview" class="radar-map map-overview"></div>
|
||||||
|
<div class="maps-controls">
|
||||||
|
<label class="maps-control">
|
||||||
|
<span>Radar opacity</span>
|
||||||
|
<input id="radar-opacity" type="range" min="15" max="90" step="5" value="65">
|
||||||
|
</label>
|
||||||
|
<label class="maps-control">
|
||||||
|
<span>Lightning age</span>
|
||||||
|
<select id="lightning-age">
|
||||||
|
<option value="5">Last 5m</option>
|
||||||
|
<option value="15" selected>Last 15m</option>
|
||||||
|
<option value="30">Last 30m</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="maps-meta">
|
||||||
|
<div class="maps-card">
|
||||||
|
<div class="stat-label">Data Source Location</div>
|
||||||
|
<div id="maps-location" class="maps-value">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="maps-card">
|
||||||
|
<div class="stat-label">Current Wind</div>
|
||||||
|
<div id="maps-wind" class="maps-value">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ── TAB: ALERTS ── -->
|
||||||
|
<div id="tab-alerts" class="tab-panel hidden">
|
||||||
|
<div id="alerts-wrap" class="alerts-wrap hidden">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-label">Weather Alerts</span>
|
||||||
|
<span class="section-actions">
|
||||||
|
<button id="notify-btn" class="link-btn" type="button">Enable notifications</button>
|
||||||
|
<button id="push-test-btn" class="link-btn" type="button">Send test push</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="alerts-list" class="alerts-list"></div>
|
||||||
|
</div>
|
||||||
|
<p id="alerts-empty" class="panel-note">No active alerts for this location.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: SETTINGS ── -->
|
||||||
|
<div id="tab-settings" class="tab-panel hidden">
|
||||||
|
<div class="settings-wrap">
|
||||||
|
<div class="saved-wrap">
|
||||||
|
<div class="saved-head">
|
||||||
|
<span>Saved locations</span>
|
||||||
|
<button id="save-current-btn" class="link-btn" type="button">Save current</button>
|
||||||
|
</div>
|
||||||
|
<p class="saved-help">Use star for default, arrows to reorder.</p>
|
||||||
|
<div id="saved-locations" class="saved-locations"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Auto-detect location on startup</span>
|
||||||
|
<label class="auto-detect-toggle" for="auto-detect-toggle">
|
||||||
|
<input id="auto-detect-toggle" type="checkbox" checked>
|
||||||
|
<span>Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Data source location</span>
|
||||||
|
<span id="settings-location" class="settings-value">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Default city</span>
|
||||||
|
<span id="default-city" class="settings-value">None</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Units</span>
|
||||||
|
<button id="units-btn-settings" class="settings-btn" type="button">US</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Lightning strikes on map</span>
|
||||||
|
<label class="toggle-label" for="lightning-toggle">
|
||||||
|
<input id="lightning-toggle" type="checkbox">
|
||||||
|
<span id="lightning-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Wind overlay on map</span>
|
||||||
|
<label class="toggle-label" for="wind-toggle">
|
||||||
|
<input id="wind-toggle" type="checkbox">
|
||||||
|
<span id="wind-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">High contrast mode</span>
|
||||||
|
<label class="toggle-label" for="contrast-toggle">
|
||||||
|
<input id="contrast-toggle" type="checkbox">
|
||||||
|
<span id="contrast-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Primary navigation">
|
||||||
|
<button class="tab active" data-tab="conditions" aria-label="Conditions">
|
||||||
|
<span class="tab-icon" aria-hidden="true">🌤</span>
|
||||||
|
<span class="tab-text">Now</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="radar-full" aria-label="Radar">
|
||||||
|
<span class="tab-icon" aria-hidden="true">🛰</span>
|
||||||
|
<span class="tab-text">Radar</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="maps" aria-label="Maps">
|
||||||
|
<span class="tab-icon" aria-hidden="true">🗺</span>
|
||||||
|
<span class="tab-text">Maps</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="alerts" aria-label="Alerts">
|
||||||
|
<span class="tab-icon" aria-hidden="true">⚠️</span>
|
||||||
|
<span class="tab-text">Alerts</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="settings" aria-label="Settings">
|
||||||
|
<span class="tab-icon" aria-hidden="true">⚙️</span>
|
||||||
|
<span class="tab-text">Settings</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</div><!-- .app -->
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="app.js?v=4"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').then(reg => {
|
||||||
|
reg.update().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
let refreshing = false;
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- Persistent data storage ---------------
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
|
const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
|
||||||
|
const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
|
||||||
|
|
||||||
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
|
||||||
|
|
||||||
|
function loadSubscriptions() {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
|
||||||
|
const map = new Map();
|
||||||
|
for (const entry of arr) {
|
||||||
|
if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch (_) { return new Map(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubscriptions() {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
|
||||||
|
} catch (e) { console.error('Failed to save subscriptions:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = loadSubscriptions();
|
||||||
|
if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
|
||||||
|
|
||||||
|
function loadSentAlertIds() {
|
||||||
|
try {
|
||||||
|
return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
|
||||||
|
} catch (_) { return new Map(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSentAlertIds() {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
|
||||||
|
} catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentAlertIds = loadSentAlertIds();
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
|
||||||
|
app.use('/', (req, res, next) => {
|
||||||
|
if (req.path === '/' || req.path.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
} else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') {
|
||||||
|
// Short-lived cache — browser revalidates every 60 s but still gets a 304 fast
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
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 data = await fetchJsonWithRetry(url);
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse geocoding proxy
|
||||||
|
app.get('/api/reverse-geocode', async (req, res) => {
|
||||||
|
const { lat, lon } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prefer Open-Meteo reverse geocoding for stable city/region labels.
|
||||||
|
const omParams = new URLSearchParams({
|
||||||
|
latitude: String(lat),
|
||||||
|
longitude: String(lon),
|
||||||
|
count: '1',
|
||||||
|
language: 'en',
|
||||||
|
format: '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({
|
||||||
|
name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
|
||||||
|
admin1: top.admin1 || top.admin2 || '',
|
||||||
|
country: top.country || '',
|
||||||
|
latitude: Number(lat),
|
||||||
|
longitude: Number(lon),
|
||||||
|
timezone: top.timezone || 'auto',
|
||||||
|
display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
format: 'jsonv2',
|
||||||
|
lat: String(lat),
|
||||||
|
lon: String(lon),
|
||||||
|
zoom: '10',
|
||||||
|
addressdetails: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||||
|
Accept: 'application/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';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
name,
|
||||||
|
admin1: address.state || address.region || address.county || '',
|
||||||
|
country: address.country || '',
|
||||||
|
latitude: Number(lat),
|
||||||
|
longitude: Number(lon),
|
||||||
|
timezone: 'auto',
|
||||||
|
display_name: data.display_name || '',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weather proxy
|
||||||
|
app.get('/api/weather', async (req, res) => {
|
||||||
|
const { lat, lon, tz, units } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
const timezone = tz || 'auto';
|
||||||
|
const useMetric = units === 'metric';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: lat, longitude: lon, timezone,
|
||||||
|
current: [
|
||||||
|
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
||||||
|
'is_day','precipitation','weather_code','cloud_cover',
|
||||||
|
'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
|
||||||
|
].join(','),
|
||||||
|
hourly: [
|
||||||
|
'temperature_2m','precipitation_probability','weather_code',
|
||||||
|
'wind_speed_10m','apparent_temperature','rain','snowfall'
|
||||||
|
].join(','),
|
||||||
|
daily: [
|
||||||
|
'weather_code','temperature_2m_max','temperature_2m_min',
|
||||||
|
'precipitation_sum','precipitation_probability_max',
|
||||||
|
'wind_speed_10m_max','sunrise','sunset','uv_index_max'
|
||||||
|
].join(','),
|
||||||
|
forecast_days: 16,
|
||||||
|
wind_speed_unit: useMetric ? 'kmh' : 'mph',
|
||||||
|
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
|
||||||
|
precipitation_unit: useMetric ? 'mm' : 'inch'
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open-Meteo Air Quality proxy
|
||||||
|
app.get('/api/air-quality', async (req, res) => {
|
||||||
|
const { lat, lon, tz } = req.query;
|
||||||
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
timezone: tz || 'auto',
|
||||||
|
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
|
||||||
|
forecast_days: 1,
|
||||||
|
});
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NWS alerts proxy (US only)
|
||||||
|
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 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
saveSubscriptions();
|
||||||
|
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);
|
||||||
|
saveSubscriptions();
|
||||||
|
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++;
|
||||||
|
saveSubscriptions();
|
||||||
|
} else {
|
||||||
|
errors.push(String(e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------- Background NWS alert poller ---------------
|
||||||
|
// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
|
||||||
|
// Sends push notifications to all subscribers for any new active NWS alerts
|
||||||
|
// at their saved location. Deduplicates by alert ID (persisted to disk).
|
||||||
|
|
||||||
|
function cleanSentAlertIds() {
|
||||||
|
const cutoff = Date.now() - 48 * 60 * 60 * 1000;
|
||||||
|
for (const [id, ts] of sentAlertIds) {
|
||||||
|
if (ts < cutoff) sentAlertIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function alertPoller() {
|
||||||
|
if (!pushConfigured() || !ensurePushConfigured()) return;
|
||||||
|
if (subscriptions.size === 0) return;
|
||||||
|
|
||||||
|
cleanSentAlertIds();
|
||||||
|
|
||||||
|
// Group subscribers by rounded lat/lon so we don't spam the NWS API.
|
||||||
|
const byLocation = new Map();
|
||||||
|
for (const entry of subscriptions.values()) {
|
||||||
|
const loc = entry.location;
|
||||||
|
if (!loc?.latitude || !loc?.longitude) continue;
|
||||||
|
const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
|
||||||
|
if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
|
||||||
|
byLocation.get(key).entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedIds = false;
|
||||||
|
for (const { loc, entries } of byLocation.values()) {
|
||||||
|
try {
|
||||||
|
const data = await fetchJsonWithRetry(
|
||||||
|
`https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
|
||||||
|
{ headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
|
||||||
|
).catch(() => null);
|
||||||
|
if (!data?.features?.length) continue;
|
||||||
|
|
||||||
|
for (const feature of data.features) {
|
||||||
|
const id = feature?.properties?.id;
|
||||||
|
if (!id || sentAlertIds.has(id)) continue;
|
||||||
|
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const title = props.event || 'Weather Alert';
|
||||||
|
const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
|
||||||
|
const headline = props.headline || props.description?.slice(0, 120) || title;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title,
|
||||||
|
body: `${area}: ${headline}`,
|
||||||
|
url: '/',
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let anySuccess = false;
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(entry.subscription, payload);
|
||||||
|
anySuccess = true;
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.statusCode === 404 || e?.statusCode === 410) {
|
||||||
|
subscriptions.delete(entry.subscription.endpoint);
|
||||||
|
saveSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anySuccess) {
|
||||||
|
console.log(`Alert push sent: [${id}] ${title} → ${area}`);
|
||||||
|
sentAlertIds.set(id, Date.now());
|
||||||
|
savedIds = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Alert poller error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedIds) saveSentAlertIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
|
||||||
|
setInterval(alertPoller, POLL_INTERVAL_MS);
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
|
||||||
|
// RainViewer timestamps proxy
|
||||||
|
app.get('/api/radar/times', async (req, res) => {
|
||||||
|
try {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`));
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,123 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CACHE = 'weather-v4';
|
||||||
|
|
||||||
|
// App shell — cached on install for offline use
|
||||||
|
const PRECACHE = [
|
||||||
|
'/',
|
||||||
|
'/app.js?v=4',
|
||||||
|
'/style.css?v=4',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icons/icon.svg'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE)
|
||||||
|
.then(cache => cache.addAll(PRECACHE))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then(keys => Promise.all(
|
||||||
|
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
|
||||||
|
))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET, API calls, and map/radar tile requests
|
||||||
|
if (
|
||||||
|
request.method !== 'GET' ||
|
||||||
|
url.pathname.startsWith('/api/') ||
|
||||||
|
url.hostname.includes('rainviewer') ||
|
||||||
|
url.hostname.includes('openstreetmap') ||
|
||||||
|
url.hostname.includes('cartodb') ||
|
||||||
|
url.hostname.includes('basemaps')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAppShellRequest =
|
||||||
|
request.mode === 'navigate' ||
|
||||||
|
request.destination === 'document' ||
|
||||||
|
request.destination === 'script' ||
|
||||||
|
request.destination === 'style';
|
||||||
|
|
||||||
|
// Prefer fresh app shell assets so UI updates are visible immediately.
|
||||||
|
if (isAppShellRequest) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE).then(cache => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request).then(cached => cached || caches.match('/')))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then(cached => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then(response => {
|
||||||
|
// Only cache successful same-origin and trusted CDN responses
|
||||||
|
if (response.ok && (
|
||||||
|
url.origin === self.location.origin ||
|
||||||
|
url.hostname.includes('googleapis') ||
|
||||||
|
url.hostname.includes('gstatic') ||
|
||||||
|
url.hostname.includes('unpkg.com')
|
||||||
|
)) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE).then(cache => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1246
-694
File diff suppressed because it is too large.
Load diff
+972
-154
File diff suppressed because it is too large.
Load diff
+1908
-849
File diff suppressed because it is too large.
Load diff
+15
-48
@@ -1,12 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const CACHE = 'weather-v4';
|
self.addEventListener('message', event => {
|
||||||
|
if (event.data === 'skipWaiting') self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
// App shell — cached on install for offline use
|
const CACHE = 'weather-v5';
|
||||||
|
|
||||||
|
// App shell cached on install for offline resilience.
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/app.js?v=4',
|
'/app.js',
|
||||||
'/style.css?v=4',
|
'/style.css',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/icons/icon.svg'
|
'/icons/icon.svg'
|
||||||
];
|
];
|
||||||
@@ -22,9 +26,7 @@ self.addEventListener('install', event => {
|
|||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then(keys => Promise.all(
|
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
|
||||||
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
|
|
||||||
))
|
|
||||||
.then(() => self.clients.claim())
|
.then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -33,7 +35,7 @@ self.addEventListener('fetch', event => {
|
|||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Skip non-GET, API calls, and map/radar tile requests
|
// Never intercept API/tile traffic.
|
||||||
if (
|
if (
|
||||||
request.method !== 'GET' ||
|
request.method !== 'GET' ||
|
||||||
url.pathname.startsWith('/api/') ||
|
url.pathname.startsWith('/api/') ||
|
||||||
@@ -45,14 +47,14 @@ self.addEventListener('fetch', event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAppShellRequest =
|
const isShell =
|
||||||
request.mode === 'navigate' ||
|
request.mode === 'navigate' ||
|
||||||
request.destination === 'document' ||
|
request.destination === 'document' ||
|
||||||
request.destination === 'script' ||
|
request.destination === 'script' ||
|
||||||
request.destination === 'style';
|
request.destination === 'style';
|
||||||
|
|
||||||
// Prefer fresh app shell assets so UI updates are visible immediately.
|
if (isShell) {
|
||||||
if (isAppShellRequest) {
|
// Network-first keeps app updates fresh; cache as fallback.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -67,11 +69,11 @@ self.addEventListener('fetch', event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(request).then(cached => {
|
caches.match(request).then(cached => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return fetch(request).then(response => {
|
return fetch(request).then(response => {
|
||||||
// Only cache successful same-origin and trusted CDN responses
|
|
||||||
if (response.ok && (
|
if (response.ok && (
|
||||||
url.origin === self.location.origin ||
|
url.origin === self.location.origin ||
|
||||||
url.hostname.includes('googleapis') ||
|
url.hostname.includes('googleapis') ||
|
||||||
@@ -85,39 +87,4 @@ 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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
+185
-333
@@ -1,130 +1,30 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
|
||||||
const webpush = require('web-push');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const weatherCache = new Map();
|
||||||
|
|
||||||
app.use(express.json({ limit: '256kb' }));
|
// Keep shell assets fresh so SW/app versions do not drift.
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- Persistent data storage ---------------
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
|
||||||
const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
|
|
||||||
const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
|
|
||||||
|
|
||||||
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
|
|
||||||
|
|
||||||
function loadSubscriptions() {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
|
|
||||||
const map = new Map();
|
|
||||||
for (const entry of arr) {
|
|
||||||
if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
} catch (_) { return new Map(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSubscriptions() {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
|
|
||||||
} catch (e) { console.error('Failed to save subscriptions:', e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptions = loadSubscriptions();
|
|
||||||
if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
|
|
||||||
|
|
||||||
function loadSentAlertIds() {
|
|
||||||
try {
|
|
||||||
return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
|
|
||||||
} catch (_) { return new Map(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSentAlertIds() {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
|
|
||||||
} catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentAlertIds = loadSentAlertIds();
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
|
|
||||||
app.use('/', (req, res, next) => {
|
app.use('/', (req, res, next) => {
|
||||||
if (req.path === '/' || req.path.endsWith('.html')) {
|
if (req.path === '/' || req.path.endsWith('.html')) {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
} else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') {
|
} else if (
|
||||||
// Short-lived cache — browser revalidates every 60 s but still gets a 304 fast
|
req.path.endsWith('.css') ||
|
||||||
|
req.path.endsWith('.js') ||
|
||||||
|
req.path === '/sw.js' ||
|
||||||
|
req.path === '/manifest.json'
|
||||||
|
) {
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
app.get('/healthz', (_req, res) => {
|
// Prevent browser favicon requests from showing noisy 404s.
|
||||||
res.json({
|
app.get('/favicon.ico', (_req, res) => {
|
||||||
ok: true,
|
res.status(204).end();
|
||||||
uptimeSec: Math.round(process.uptime()),
|
|
||||||
pushConfigured: pushConfigured(),
|
|
||||||
subscriptionCount: subscriptions.size,
|
|
||||||
now: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Geocoding proxy
|
// Geocoding proxy
|
||||||
@@ -133,8 +33,58 @@ app.get('/api/geocode', async (req, res) => {
|
|||||||
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 data = await fetchJsonWithRetry(url);
|
|
||||||
res.json(data);
|
let openMeteoData = null;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 9000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
|
||||||
|
openMeteoData = await r.json();
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (attempt === 2) {
|
||||||
|
console.warn('Open-Meteo geocode failed after retries:', err.message);
|
||||||
|
} else {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openMeteoData?.results?.length) {
|
||||||
|
return res.json(openMeteoData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
|
||||||
|
const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
|
||||||
|
const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nom.ok) {
|
||||||
|
throw new Error(`Nominatim geocode status ${nom.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomData = await nom.json();
|
||||||
|
const results = (nomData || []).map(item => {
|
||||||
|
const address = item.address || {};
|
||||||
|
return {
|
||||||
|
name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
|
||||||
|
latitude: Number(item.lat),
|
||||||
|
longitude: Number(item.lon),
|
||||||
|
country: address.country || '',
|
||||||
|
admin1: address.state || address.region || address.county || '',
|
||||||
|
timezone: 'auto',
|
||||||
|
};
|
||||||
|
}).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
|
||||||
|
|
||||||
|
return res.json({ results });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
|
||||||
}
|
}
|
||||||
@@ -146,31 +96,6 @@ app.get('/api/reverse-geocode', async (req, res) => {
|
|||||||
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 {
|
||||||
// Prefer Open-Meteo reverse geocoding for stable city/region labels.
|
|
||||||
const omParams = new URLSearchParams({
|
|
||||||
latitude: String(lat),
|
|
||||||
longitude: String(lon),
|
|
||||||
count: '1',
|
|
||||||
language: 'en',
|
|
||||||
format: '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({
|
|
||||||
name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
|
|
||||||
admin1: top.admin1 || top.admin2 || '',
|
|
||||||
country: top.country || '',
|
|
||||||
latitude: Number(lat),
|
|
||||||
longitude: Number(lon),
|
|
||||||
timezone: top.timezone || 'auto',
|
|
||||||
display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
format: 'jsonv2',
|
format: 'jsonv2',
|
||||||
lat: String(lat),
|
lat: String(lat),
|
||||||
@@ -179,13 +104,18 @@ app.get('/api/reverse-geocode', async (req, res) => {
|
|||||||
addressdetails: '1',
|
addressdetails: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
const r = await fetch(`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';
|
||||||
|
|
||||||
@@ -209,13 +139,14 @@ app.get('/api/weather', async (req, res) => {
|
|||||||
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
|
||||||
const timezone = tz || 'auto';
|
const timezone = tz || 'auto';
|
||||||
const useMetric = units === 'metric';
|
const useMetric = units === 'metric';
|
||||||
|
const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
latitude: lat, longitude: lon, timezone,
|
latitude: lat, longitude: lon, timezone,
|
||||||
current: [
|
current: [
|
||||||
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
'temperature_2m','relative_humidity_2m','apparent_temperature',
|
||||||
'is_day','precipitation','weather_code','cloud_cover',
|
'is_day','precipitation','weather_code','cloud_cover',
|
||||||
'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
|
'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
|
||||||
].join(','),
|
].join(','),
|
||||||
hourly: [
|
hourly: [
|
||||||
'temperature_2m','precipitation_probability','weather_code',
|
'temperature_2m','precipitation_probability','weather_code',
|
||||||
@@ -231,9 +162,44 @@ 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 data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
|
|
||||||
res.json(data);
|
let lastError = null;
|
||||||
|
const upstreamTimeoutMs = 12000;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const body = await r.text();
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
weatherCache.set(cacheKey, { data, at: Date.now() });
|
||||||
|
return res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = err;
|
||||||
|
const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
|
||||||
|
console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
|
||||||
|
if (attempt < 3) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Weather upstream failed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const cached = weatherCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
|
||||||
|
res.setHeader('X-Weather-Cache', 'stale-fallback');
|
||||||
|
console.warn('Weather proxy using stale cache for', cacheKey);
|
||||||
|
return res.json(cached.data);
|
||||||
|
}
|
||||||
|
console.error('Weather proxy failed:', e.message);
|
||||||
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
|
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -250,10 +216,34 @@ app.get('/api/air-quality', async (req, res) => {
|
|||||||
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
|
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 data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
|
let lastError = null;
|
||||||
res.json(data);
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Open-Meteo status ${r.status}`);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
return res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
lastError = err;
|
||||||
|
if (attempt < 2) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Air quality upstream failed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
|
console.error('Air quality fetch error:', e);
|
||||||
|
res.setHeader('X-Air-Quality-Fallback', 'empty');
|
||||||
|
res.json({ current: {} });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,211 +252,73 @@ 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 data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
|
|
||||||
|
const r = await fetch(`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',
|
||||||
},
|
},
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`NWS API returned ${r.status}`);
|
||||||
|
}
|
||||||
|
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 });
|
console.error('Alerts fetch error:', e.message);
|
||||||
|
res.setHeader('X-Alerts-Fallback', 'empty');
|
||||||
|
res.json({ features: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
|
// Radar/satellite tile configuration
|
||||||
app.get('/api/lightning', async (req, res) => {
|
app.get('/api/radar/times', 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 {
|
try {
|
||||||
const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
|
const now = Math.floor(Date.now() / 1000);
|
||||||
headers: {
|
|
||||||
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
|
// IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation.
|
||||||
Accept: 'application/geo+json',
|
const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
|
||||||
},
|
const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
|
||||||
|
const radarPast = radarOffsetsMin.map(offset => {
|
||||||
|
const layer = offset === 0 ? 'nexrad-n0q-900913' : `nexrad-n0q-900913-m${String(offset).padStart(2, '0')}m`;
|
||||||
|
return {
|
||||||
|
time: now - (offset * 60),
|
||||||
|
url: `${radarLayerBase}/${layer}/{z}/{x}/{y}.png`,
|
||||||
|
isForecast: false,
|
||||||
|
maxNativeZoom: 12,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
|
// IEM GOES East cloud/satellite frames for animation.
|
||||||
const features = (data?.features || []).filter(f => {
|
const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0];
|
||||||
const p = f?.properties || {};
|
const satelliteInfrared = satOffsetsMin.map(offset => {
|
||||||
const event = String(p.event || '').toLowerCase();
|
const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`;
|
||||||
const relevant = event.includes('thunderstorm') || event.includes('lightning');
|
return {
|
||||||
if (!relevant) return false;
|
time: now - (offset * 60),
|
||||||
const ts = Date.parse(p.sent || p.onset || p.effective || '');
|
url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`,
|
||||||
if (Number.isNaN(ts)) return true;
|
isForecast: false,
|
||||||
return ts >= cutoff;
|
maxNativeZoom: 10,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
source: 'weather.gov alerts',
|
provider: 'iem-radar-rainviewer-sat',
|
||||||
count: features.length,
|
radar: {
|
||||||
features,
|
past: radarPast,
|
||||||
ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
|
nowcast: [],
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
infrared: satelliteInfrared,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
|
console.error('Radar config error:', 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(),
|
|
||||||
});
|
|
||||||
saveSubscriptions();
|
|
||||||
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);
|
|
||||||
saveSubscriptions();
|
|
||||||
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++;
|
|
||||||
saveSubscriptions();
|
|
||||||
} else {
|
|
||||||
errors.push(String(e.message || e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------------- Background NWS alert poller ---------------
|
|
||||||
// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
|
|
||||||
// Sends push notifications to all subscribers for any new active NWS alerts
|
|
||||||
// at their saved location. Deduplicates by alert ID (persisted to disk).
|
|
||||||
|
|
||||||
function cleanSentAlertIds() {
|
|
||||||
const cutoff = Date.now() - 48 * 60 * 60 * 1000;
|
|
||||||
for (const [id, ts] of sentAlertIds) {
|
|
||||||
if (ts < cutoff) sentAlertIds.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function alertPoller() {
|
|
||||||
if (!pushConfigured() || !ensurePushConfigured()) return;
|
|
||||||
if (subscriptions.size === 0) return;
|
|
||||||
|
|
||||||
cleanSentAlertIds();
|
|
||||||
|
|
||||||
// Group subscribers by rounded lat/lon so we don't spam the NWS API.
|
|
||||||
const byLocation = new Map();
|
|
||||||
for (const entry of subscriptions.values()) {
|
|
||||||
const loc = entry.location;
|
|
||||||
if (!loc?.latitude || !loc?.longitude) continue;
|
|
||||||
const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
|
|
||||||
if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
|
|
||||||
byLocation.get(key).entries.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
let savedIds = false;
|
|
||||||
for (const { loc, entries } of byLocation.values()) {
|
|
||||||
try {
|
|
||||||
const data = await fetchJsonWithRetry(
|
|
||||||
`https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
|
|
||||||
{ headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
|
|
||||||
).catch(() => null);
|
|
||||||
if (!data?.features?.length) continue;
|
|
||||||
|
|
||||||
for (const feature of data.features) {
|
|
||||||
const id = feature?.properties?.id;
|
|
||||||
if (!id || sentAlertIds.has(id)) continue;
|
|
||||||
|
|
||||||
const props = feature.properties || {};
|
|
||||||
const title = props.event || 'Weather Alert';
|
|
||||||
const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
|
|
||||||
const headline = props.headline || props.description?.slice(0, 120) || title;
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
title,
|
|
||||||
body: `${area}: ${headline}`,
|
|
||||||
url: '/',
|
|
||||||
ts: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let anySuccess = false;
|
|
||||||
for (const entry of entries) {
|
|
||||||
try {
|
|
||||||
await webpush.sendNotification(entry.subscription, payload);
|
|
||||||
anySuccess = true;
|
|
||||||
} catch (e) {
|
|
||||||
if (e?.statusCode === 404 || e?.statusCode === 410) {
|
|
||||||
subscriptions.delete(entry.subscription.endpoint);
|
|
||||||
saveSubscriptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anySuccess) {
|
|
||||||
console.log(`Alert push sent: [${id}] ${title} → ${area}`);
|
|
||||||
sentAlertIds.set(id, Date.now());
|
|
||||||
savedIds = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Alert poller error:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedIds) saveSentAlertIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
|
|
||||||
setInterval(alertPoller, POLL_INTERVAL_MS);
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
|
|
||||||
// RainViewer timestamps proxy
|
|
||||||
app.get('/api/radar/times', async (req, res) => {
|
|
||||||
try {
|
|
||||||
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 });
|
res.status(500).json({ error: 'Radar times failed', detail: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
weather:
|
weather:
|
||||||
image: ghcr.io/nmemmert/weather:latest
|
build:
|
||||||
|
context: ./app
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: weather-app
|
container_name: weather-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in new issue
Block a user