Add installable PWA (manifest, service worker, icons) + per-type notification toggles
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 1m1s
Test, Build & Deploy / deploy (push) Successful in 22s

- manifest.webmanifest + apple/maskable PNG icons; relative paths so the
  app works both at dev root and under /weather
- plain-JS service worker (sw.js) with notificationclick/push handlers and
  an SW-mediated showNotification() path for native Android notifications
- src/lib/pwa.js bootstraps registration and routes alerts to the SW
- Every alert kind (storm/precip/heat/cold/uv/wind) is now a per-type
  preference toggle in Settings; removed the old alertThresholds.thunderstorm
  boolean in favor of notificationTypes.storm
- Store dedups native notifications per forecast day and fires best-effort
  native notifications for newly-appeared alerts
- inline-assets keeps manifest/sw/icons in dist; deploy.yml uploads + verifies
  all PWA files via WebDAV
This commit is contained in:
hermes-explorigin 2026-09-08 00:59:19 +00:00
parent 2c678fd00d
commit 897f49ae22
22 changed files with 825 additions and 88 deletions

View File

@ -57,39 +57,61 @@ jobs:
- name: Verify build output - name: Verify build output
run: test -f dist/index.html run: test -f dist/index.html
# Publish dist/index.html to the WebDAV mirror. The pretty URL # Publish index.html + PWA companion files (manifest, service worker,
# (…/weather/index.html) 401s without auth; the file is the full # icons) to the WebDAV mirror. The pretty URL (…/weather/index.html) 401s
# www/static/weather/index.html path with basic auth. # without auth; files live at www/static/weather/<name> with basic auth.
- name: Publish to WebDAV (/weather) - name: Publish to WebDAV (/weather)
env: env:
WEBDAV_USER: ${{ secrets.WEBDAV_USER }} WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }} WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
WEBDAV_URL: https://files.thecookiejar.me/www/static/weather/index.html WEBDAV_BASE: https://files.thecookiejar.me/www/static/weather
run: | run: |
set -e
if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then
echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set." echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set."
echo "::error::Add them under Settings → Actions → Secrets, then re-run this job." echo "::error::Add them under Settings → Actions → Secrets, then re-run this job."
exit 1 exit 1
fi fi
# DELETE-then-PUT: the serving layer caches uploaded files, so a plain publish() {
# PUT over an existing file returns 201 but keeps serving the OLD copy. local src="$1" dst="$2" ctype="$3"
# Deleting first guarantees the new build is actually served. (Each curl # DELETE-then-PUT: the serving layer caches uploaded files, so a plain
# is a single line -- YAML block scalars don't convert backslash-nl.) # PUT over an existing file keeps serving the OLD copy.
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$WEBDAV_URL" || echo "(DELETE returned non-zero; file may not exist yet -- continuing)" curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$dst" || echo "(DELETE non-zero; may be a new file -- continuing)"
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T dist/index.html -H 'Content-Type: text/html' "$WEBDAV_URL" curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T "$src" -H "Content-Type: $ctype" "$dst"
echo "Published to $WEBDAV_URL" echo "Published $src -> $dst"
}
# Ensure the icons/ subfolder exists (PUT into a missing dir can 409/404
# depending on server). MKCOL returns 405 if it already exists — fine.
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X MKCOL "$WEBDAV_BASE/icons" -o /dev/null || echo "(icons dir exists or MKCOL unsupported -- continuing)"
publish dist/index.html "$WEBDAV_BASE/index.html" 'text/html'
publish dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest" 'application/manifest+json'
publish dist/sw.js "$WEBDAV_BASE/sw.js" 'application/javascript; charset=utf-8'
publish dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png" 'image/png'
publish dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png" 'image/png'
publish dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png" 'image/png'
# Pull the freshly uploaded file back and compare it to our build, so a # Pull the freshly uploaded files back and compare bytes, so a successful
# successful deploy isn't just "curl returned 0" but bytes actually match. # deploy isn't just "curl returned 0" but the served copy actually matches.
- name: Verify deployed file matches build - name: Verify deployed files match build
env: env:
WEBDAV_USER: ${{ secrets.WEBDAV_USER }} WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }} WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
WEBDAV_URL: https://files.thecookiejar.me/www/static/weather/index.html WEBDAV_BASE: https://files.thecookiejar.me/www/static/weather
run: | run: |
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$WEBDAV_URL" -o /tmp/deployed.html set -e
sha1sum dist/index.html /tmp/deployed.html verify() {
test "$(sha1sum dist/index.html | cut -d' ' -f1)" = \ local src="$1" dst="$2"
"$(sha1sum /tmp/deployed.html | cut -d' ' -f1)" \ curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$dst" -o "/tmp/deployed_$(basename "$src")"
&& echo "Deploy verified: bytes match" \ sha1sum "$src" "/tmp/deployed_$(basename "$src")"
|| { echo "::error::Deployed file does not match build"; exit 1; } test "$(sha1sum "$src" | cut -d' ' -f1)" = \
"$(sha1sum "/tmp/deployed_$(basename "$src")" | cut -d' ' -f1)" \
|| { echo "::error::Deployed $src does not match build"; exit 1; }
echo "Verified $src bytes match"
}
verify dist/index.html "$WEBDAV_BASE/index.html"
verify dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest"
verify dist/sw.js "$WEBDAV_BASE/sw.js"
verify dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png"
verify dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png"
verify dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png"
echo "All deployed files verified"

BIN
dist/icons/icon-192.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
dist/icons/icon-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
dist/icons/icon-maskable-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

232
dist/index.html vendored
View File

@ -2,8 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDQ4IDQ4Ij4KICA8IS0tIFN1biBjaXJjbGUgLS0+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIyMiIgcj0iMTIiIGZpbGw9IiNmYmJmMjQiIC8+CiAgPCEtLSBTdW4gcmF5cyAtLT4KICA8ZyBzdHJva2U9IiNmYmJmMjQiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgIDxsaW5lIHgxPSIyMiIgeTE9IjQiIHgyPSIyMiIgeTI9IjkiIC8+CiAgICA8bGluZSB4MT0iMjIiIHkxPSIzNSIgeDI9IjIyIiB5Mj0iNDAiIC8+CiAgICA8bGluZSB4MT0iNCIgeTE9IjIyIiB4Mj0iOSIgeTI9IjIyIiAvPgogICAgPGxpbmUgeDE9IjM1IiB5MT0iMjIiIHgyPSI0MCIgeTI9IjIyIiAvPgogICAgPGxpbmUgeDE9IjkuNSIgeTE9IjkuNSIgeDI9IjEzIiB5Mj0iMTMiIC8+CiAgICA8bGluZSB4MT0iMzEiIHkxPSIzMSIgeDI9IjM0LjUiIHkyPSIzNC41IiAvPgogICAgPGxpbmUgeDE9IjkuNSIgeTE9IjM0LjUiIHgyPSIxMyIgeTI9IjMxIiAvPgogICAgPGxpbmUgeDE9IjMxIiB5MT0iMTMiIHgyPSIzNC41IiB5Mj0iOS41IiAvPgogIDwvZz4KICA8IS0tIENsb3VkIG92ZXJsYXkgLS0+CiAgPHBhdGggZD0iTTIyIDM0IFExNiAzNCAxNCAzMSBRMTIgMjggMTUgMjcgUTEzIDIzIDE3IDIxIFEyMSAxOSAyNSAyMSBRMjkgMTcgMzQgMTkgUTM4IDIxIDM3IDI2IFE0MSAyNyAzOSAzMSBRMzcgMzUgMjIgMzRaIiBmaWxsPSIjNjBhNWZhIiBvcGFjaXR5PSIwLjkiIC8+Cjwvc3ZnPgo=" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="mobile-web-app-capable" content="yes" />
<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="WeatherLens" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDQ4IDQ4Ij4KICA8IS0tIFN1biBjaXJjbGUgLS0+CiAgPGNpcmNsZSBjeD0iMjIiIGN5PSIyMiIgcj0iMTIiIGZpbGw9IiNmYmJmMjQiIC8+CiAgPCEtLSBTdW4gcmF5cyAtLT4KICA8ZyBzdHJva2U9IiNmYmJmMjQiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgIDxsaW5lIHgxPSIyMiIgeTE9IjQiIHgyPSIyMiIgeTI9IjkiIC8+CiAgICA8bGluZSB4MT0iMjIiIHkxPSIzNSIgeDI9IjIyIiB5Mj0iNDAiIC8+CiAgICA8bGluZSB4MT0iNCIgeTE9IjIyIiB4Mj0iOSIgeTI9IjIyIiAvPgogICAgPGxpbmUgeDE9IjM1IiB5MT0iMjIiIHgyPSI0MCIgeTI9IjIyIiAvPgogICAgPGxpbmUgeDE9IjkuNSIgeTE9IjkuNSIgeDI9IjEzIiB5Mj0iMTMiIC8+CiAgICA8bGluZSB4MT0iMzEiIHkxPSIzMSIgeDI9IjM0LjUiIHkyPSIzNC41IiAvPgogICAgPGxpbmUgeDE9IjkuNSIgeTE9IjM0LjUiIHgyPSIxMyIgeTI9IjMxIiAvPgogICAgPGxpbmUgeDE9IjMxIiB5MT0iMTMiIHgyPSIzNC41IiB5Mj0iOS41IiAvPgogIDwvZz4KICA8IS0tIENsb3VkIG92ZXJsYXkgLS0+CiAgPHBhdGggZD0iTTIyIDM0IFExNiAzNCAxNCAzMSBRMTIgMjggMTUgMjcgUTEzIDIzIDE3IDIxIFEyMSAxOSAyNSAyMSBRMjkgMTcgMzQgMTkgUTM4IDIxIDM3IDI2IFE0MSAyNyAzOSAzMSBRMzcgMzUgMjIgMzRaIiBmaWxsPSIjNjBhNWZhIiBvcGFjaXR5PSIwLjkiIC8+Cjwvc3ZnPgo=" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>WeatherLens</title> <title>WeatherLens</title>
<script type="module" crossorigin>//#region \0rolldown/runtime.js <script type="module" crossorigin>//#region \0rolldown/runtime.js
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
@ -5125,13 +5132,20 @@ var init_db = __esmMin((() => {
refreshInterval: 30, refreshInterval: 30,
alertsEnabled: true, alertsEnabled: true,
showLightning: false, showLightning: false,
notificationTypes: {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true
},
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,
windGust: 40, windGust: 40,
uvIndex: 6, uvIndex: 6,
tempHigh: 35, tempHigh: 35,
tempLow: 0, tempLow: 0
thunderstorm: true
}, },
dismissedAlerts: [] dismissedAlerts: []
}; };
@ -5676,13 +5690,20 @@ var AppStore = class {
refreshInterval: 30, refreshInterval: 30,
alertsEnabled: true, alertsEnabled: true,
showLightning: false, showLightning: false,
notificationTypes: {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true
},
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,
windGust: 40, windGust: 40,
uvIndex: 6, uvIndex: 6,
tempHigh: 35, tempHigh: 35,
tempLow: 0, tempLow: 0
thunderstorm: true
} }
})); }));
get settings() { get settings() {
@ -6047,8 +6068,71 @@ function parseLocalDate(dateStr) {
return new Date(y, (m || 1) - 1, d || 1); return new Date(y, (m || 1) - 1, d || 1);
} }
//#endregion //#endregion
//#region src/lib/pwa.js
/** Promise-wrapped access to the active SW registration (if any). */
function activeRegistration() {
if (!("serviceWorker" in navigator)) return Promise.resolve(null);
return navigator.serviceWorker.getRegistration().catch(() => null);
}
/**
* Register the service worker. Called once at startup. Safe to call in dev,
* preview, and production alike (no-op where service workers are unavailable).
*/
async function registerServiceWorker() {
if (!("serviceWorker" in navigator) || !window.isSecureContext) return false;
try {
const reg = await navigator.serviceWorker.register("./sw.js");
try {
await reg.update();
} catch {}
return true;
} catch (e) {
console.warn("[pwa] service worker registration failed:", e);
return false;
}
}
/**
* Send a native notification (best-effort). Routes through the SW when one is
* active so it works from a background tab / installed app; otherwise falls
* back to an in-page Notification object.
*
* @param {object} n { title, body, icon?, tag? }
*/
async function notify({ title, body = "", icon, tag }) {
const reg = await activeRegistration();
if (reg?.active) try {
reg.active.postMessage({
type: "weather-notify",
title,
body,
icon,
tag
});
return true;
} catch {}
try {
if (typeof Notification === "function") {
new Notification(title, {
body,
icon,
tag
}).show();
return true;
}
} catch {}
return false;
}
//#endregion
//#region src/lib/stores/notifications.svelte.js //#region src/lib/stores/notifications.svelte.js
init_db(); init_db();
var APP_TITLES = {
storm: "⛈️ Severe weather",
precip: "🌧️ Rain expected",
heat: "🔥 Heat alert",
cold: "🥶 Cold alert",
uv: "☀️ High UV",
wind: "💨 Strong winds"
};
var NotificationStore = class { var NotificationStore = class {
#alerts = /* @__PURE__ */ state(proxy([])); #alerts = /* @__PURE__ */ state(proxy([]));
get alerts() { get alerts() {
@ -6066,6 +6150,15 @@ var NotificationStore = class {
} }
lastForecastDate = null; lastForecastDate = null;
_dismissalsLoaded = false; _dismissalsLoaded = false;
typeEnabled = {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true
};
_notifiedIds = /* @__PURE__ */ new Set();
analyze() { analyze() {
const data = app$1.forecastData; const data = app$1.forecastData;
const settings = app$1.settings; const settings = app$1.settings;
@ -6078,12 +6171,26 @@ var NotificationStore = class {
this._loadDismissed(); this._loadDismissed();
} }
const forecastDate = data.daily?.time?.[0] || null; const forecastDate = data.daily?.time?.[0] || null;
if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) this.resetDismissed(); if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) {
this.resetDismissed();
this._notifiedIds.clear();
}
this.lastForecastDate = forecastDate; this.lastForecastDate = forecastDate;
const types = settings.notificationTypes || {};
this.typeEnabled = {
storm: types.storm !== false,
precip: types.precip !== false,
heat: types.heat !== false,
cold: types.cold !== false,
uv: types.uv !== false,
wind: types.wind !== false
};
const t = settings.alertThresholds; const t = settings.alertThresholds;
const newAlerts = []; let newAlerts = [];
const typeEnabled = this.typeEnabled;
function add(id, type, severity, message, icon) { function add(id, type, severity, message, icon) {
if (!app$1.settings.alertsEnabled) return; if (!app$1.settings.alertsEnabled) return;
if (!typeEnabled[type]) return;
newAlerts.push({ newAlerts.push({
id, id,
type, type,
@ -6094,14 +6201,14 @@ var NotificationStore = class {
} }
const current = data.current; const current = data.current;
const daily = data.daily || {}; const daily = data.daily || {};
if (t.thunderstorm && isSevereWeather(current.weather_code)) add("storm-now", "storm", "danger", "⚡ Thunderstorm active — seek shelter if outdoors", "⛈️"); if (isSevereWeather(current.weather_code)) add("storm-now", "storm", "danger", "⚡ Thunderstorm active — seek shelter if outdoors", "⛈️");
if (daily.time) for (let i = 0; i < daily.time.length && i < 3; i++) { if (daily.time) for (let i = 0; i < daily.time.length && i < 3; i++) {
const day = daily.time[i]; const day = daily.time[i];
const dateLabel = i === 0 ? "Today" : i === 1 ? "Tomorrow" : parseLocalDate(day).toLocaleDateString("en-US", { weekday: "short" }); const dateLabel = i === 0 ? "Today" : i === 1 ? "Tomorrow" : parseLocalDate(day).toLocaleDateString("en-US", { weekday: "short" });
const precip = daily.precipitation_probability_max?.[i] || 0; const precip = daily.precipitation_probability_max?.[i] || 0;
if (precip >= t.precip) add(`rain-${i}`, "precip", "warning", `🌧️ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, "🌧️"); if (precip >= t.precip) add(`rain-${i}`, "precip", "warning", `🌧️ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, "🌧️");
const code = daily.weather_code?.[i]; const code = daily.weather_code?.[i];
if (t.thunderstorm && isSevereWeather(code)) add(`storm-${i}`, "storm", "danger", `⛈️ Thunderstorms expected ${dateLabel.toLowerCase()}`, "⛈️"); if (isSevereWeather(code)) add(`storm-${i}`, "storm", "danger", `⛈️ Thunderstorms expected ${dateLabel.toLowerCase()}`, "⛈️");
const tempHigh = daily.temperature_2m_max?.[i]; const tempHigh = daily.temperature_2m_max?.[i];
if (tempHigh != null && tempHigh > t.tempHigh) add(`heat-${i}`, "heat", "warning", `🔥 ${dateLabel} high of ${Math.round(tempHigh)}° — stay hydrated`, "🔥"); if (tempHigh != null && tempHigh > t.tempHigh) add(`heat-${i}`, "heat", "warning", `🔥 ${dateLabel} high of ${Math.round(tempHigh)}° — stay hydrated`, "🔥");
const tempMin = daily.temperature_2m_min?.[i]; const tempMin = daily.temperature_2m_min?.[i];
@ -6111,7 +6218,18 @@ var NotificationStore = class {
const wind = daily.wind_speed_10m_max?.[i]; const wind = daily.wind_speed_10m_max?.[i];
if (wind != null && wind >= t.windGust) add(`wind-${i}`, "wind", "warning", `💨 Strong winds (${Math.round(wind)} ${app$1.settings.units === "imperial" ? "mph" : "km/h"}) ${dateLabel.toLowerCase()}`, "💨"); if (wind != null && wind >= t.windGust) add(`wind-${i}`, "wind", "warning", `💨 Strong winds (${Math.round(wind)} ${app$1.settings.units === "imperial" ? "mph" : "km/h"}) ${dateLabel.toLowerCase()}`, "💨");
} }
this.alerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id)); newAlerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id));
this.alerts = newAlerts;
for (const a of this.alerts) {
if (this._notifiedIds.has(a.id)) continue;
this._notifiedIds.add(a.id);
notify({
title: APP_TITLES[a.type] || "Weather alert",
body: a.message,
icon: "./icons/icon-192.png",
tag: `weather-${a.type}`
});
}
} }
/** /**
* Dismiss a specific alert. Persists to IndexedDB. * Dismiss a specific alert. Persists to IndexedDB.
@ -6167,7 +6285,7 @@ function autofocus(node) {
init_db(); init_db();
var root$10 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9"></span>`); var root$10 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9"></span>`);
var root_1$9 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`); var root_1$9 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`);
var root_2$8 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`); var root_2$9 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`);
var root_3$7 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`); var root_3$7 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`);
var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location"></button></div></div>`); var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location"></button></div></div>`);
var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`); var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`);
@ -6237,7 +6355,7 @@ function LocationSidebar($$anchor, $$props) {
}); });
var node_1 = sibling(node, 2); var node_1 = sibling(node, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var div_2 = root_2$8(); var div_2 = root_2$9();
each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => { each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => {
var button = root_1$9(); var button = root_1$9();
var span_1 = child(button); var span_1 = child(button);
@ -6361,7 +6479,7 @@ function SourceTooltip($$anchor, $$props) {
//#region src/components/CurrentWeather.svelte //#region src/components/CurrentWeather.svelte
var root$8 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`); var root$8 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`);
var root_1$8 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`); var root_1$8 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`);
var root_2$7 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`); var root_2$8 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`);
var root_3$6 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`); var root_3$6 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`);
var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`); var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`); var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`);
@ -6497,7 +6615,7 @@ function CurrentWeather($$anchor, $$props) {
reset(span_9); reset(span_9);
var node_5 = sibling(span_9, 2); var node_5 = sibling(span_9, 2);
var consequent_4 = ($$anchor) => { var consequent_4 = ($$anchor) => {
var span_10 = root_2$7(); var span_10 = root_2$8();
var text_12 = child(span_10, true); var text_12 = child(span_10, true);
reset(span_10); reset(span_10);
template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High")); template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High"));
@ -6575,7 +6693,7 @@ function CurrentWeather($$anchor, $$props) {
//#region src/components/HourlyForecast.svelte //#region src/components/HourlyForecast.svelte
var root$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1); var root$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1);
var root_1$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1); var root_1$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1);
var root_2$6 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`); var root_2$7 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`); var root_3$5 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`);
function HourlyForecast($$anchor, $$props) { function HourlyForecast($$anchor, $$props) {
push($$props, true); push($$props, true);
@ -6615,7 +6733,7 @@ function HourlyForecast($$anchor, $$props) {
var div_1 = sibling(child(div), 2); var div_1 = sibling(child(div), 2);
each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => { each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => {
const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code)); const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code));
var div_2 = root_2$6(); var div_2 = root_2$7();
let classes; let classes;
var span = child(div_2); var span = child(div_2);
var text = child(span, true); var text = child(span, true);
@ -6682,7 +6800,7 @@ function HourlyForecast($$anchor, $$props) {
//#region src/components/DailyForecast.svelte //#region src/components/DailyForecast.svelte
var root$6 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`); var root$6 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`);
var root_1$6 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`); var root_1$6 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`);
var root_2$5 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`); var root_2$6 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`); var root_3$4 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`);
function DailyForecast($$anchor, $$props) { function DailyForecast($$anchor, $$props) {
push($$props, true); push($$props, true);
@ -6718,7 +6836,7 @@ function DailyForecast($$anchor, $$props) {
const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i])); const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i]));
const precip = /* @__PURE__ */ user_derived(() => get(daily).precipitation_probability_max?.[i] || 0); const precip = /* @__PURE__ */ user_derived(() => get(daily).precipitation_probability_max?.[i] || 0);
const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]); const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]);
var div_2 = root_2$5(); var div_2 = root_2$6();
set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 }); set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 });
var div_3 = child(div_2); var div_3 = child(div_2);
var span = child(div_3); var span = child(div_3);
@ -6806,7 +6924,7 @@ function DailyForecast($$anchor, $$props) {
//#region src/components/WeatherDetail.svelte //#region src/components/WeatherDetail.svelte
var root$5 = /* @__PURE__ */ from_html(`<span> </span>`); var root$5 = /* @__PURE__ */ from_html(`<span> </span>`);
var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`); var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`);
var root_2$4 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`); var root_2$5 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`);
function WeatherDetail($$anchor, $$props) { function WeatherDetail($$anchor, $$props) {
push($$props, true); push($$props, true);
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
@ -6869,7 +6987,7 @@ function WeatherDetail($$anchor, $$props) {
var fragment = comment(); var fragment = comment();
var node = first_child(fragment); var node = first_child(fragment);
var consequent_6 = ($$anchor) => { var consequent_6 = ($$anchor) => {
var div = root_2$4(); var div = root_2$5();
var div_1 = sibling(child(div), 2); var div_1 = sibling(child(div), 2);
var div_2 = child(div_1); var div_2 = child(div_1);
var div_3 = sibling(child(div_2), 2); var div_3 = sibling(child(div_2), 2);
@ -7182,7 +7300,7 @@ function tileY(lat, z) {
init_db(); init_db();
var root$4 = /* @__PURE__ */ from_html(`<button role="switch" title="Toggle lightning strike layer">⚡ Lightning</button>`); var root$4 = /* @__PURE__ */ from_html(`<button role="switch" title="Toggle lightning strike layer">⚡ Lightning</button>`);
var root_1$4 = /* @__PURE__ */ from_html(`<div class="radar-status radar-status-err svelte-tjvfz7"><span class="svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`); var root_1$4 = /* @__PURE__ */ from_html(`<div class="radar-status radar-status-err svelte-tjvfz7"><span class="svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
var root_2$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`); var root_2$4 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`);
var root_3$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-base svelte-tjvfz7" alt="" loading="lazy" draggable="false"/> <!>`, 1); var root_3$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-base svelte-tjvfz7" alt="" loading="lazy" draggable="false"/> <!>`, 1);
var root_4$3 = /* @__PURE__ */ from_html(`<span class="lightning-strike svelte-tjvfz7"></span>`); var root_4$3 = /* @__PURE__ */ from_html(`<span class="lightning-strike svelte-tjvfz7"></span>`);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`); var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`);
@ -7482,7 +7600,7 @@ function PrecipitationRadar($$anchor, $$props) {
var img = first_child(fragment_2); var img = first_child(fragment_2);
var node_4 = sibling(img, 2); var node_4 = sibling(img, 2);
var consequent_2 = ($$anchor) => { var consequent_2 = ($$anchor) => {
var img_1 = root_2$3(); var img_1 = root_2$4();
template_effect(($0) => { template_effect(($0) => {
set_attribute(img_1, "src", $0); set_attribute(img_1, "src", $0);
set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`); set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`);
@ -7679,7 +7797,7 @@ delegate([
//#region src/components/NotificationBell.svelte //#region src/components/NotificationBell.svelte
var root$3 = /* @__PURE__ */ from_html(`<span class="bell-icon ringing svelte-hpnwii">🔔</span> <span class="bell-badge svelte-hpnwii"> </span>`, 1); var root$3 = /* @__PURE__ */ from_html(`<span class="bell-icon ringing svelte-hpnwii">🔔</span> <span class="bell-badge svelte-hpnwii"> </span>`, 1);
var root_1$3 = /* @__PURE__ */ from_html(`<span class="bell-icon svelte-hpnwii">🔕</span>`); var root_1$3 = /* @__PURE__ */ from_html(`<span class="bell-icon svelte-hpnwii">🔕</span>`);
var root_2$2 = /* @__PURE__ */ from_html(`<button class="bell-dismiss-all svelte-hpnwii">Clear all</button>`); var root_2$3 = /* @__PURE__ */ from_html(`<button class="bell-dismiss-all svelte-hpnwii">Clear all</button>`);
var root_3$2 = /* @__PURE__ */ from_html(`<div><span class="bell-alert-icon svelte-hpnwii"> </span> <div class="bell-alert-content svelte-hpnwii"><span class="bell-alert-msg svelte-hpnwii"> </span> <span class="bell-alert-type svelte-hpnwii"> </span></div> <button class="bell-dismiss svelte-hpnwii"></button></div>`); var root_3$2 = /* @__PURE__ */ from_html(`<div><span class="bell-alert-icon svelte-hpnwii"> </span> <div class="bell-alert-content svelte-hpnwii"><span class="bell-alert-msg svelte-hpnwii"> </span> <span class="bell-alert-type svelte-hpnwii"> </span></div> <button class="bell-dismiss svelte-hpnwii"></button></div>`);
var root_4$2 = /* @__PURE__ */ from_html(`<div class="bell-empty svelte-hpnwii"><span class="bell-empty-icon svelte-hpnwii"></span> <span class="text-muted text-sm svelte-hpnwii">No weather alerts right now</span></div>`); var root_4$2 = /* @__PURE__ */ from_html(`<div class="bell-empty svelte-hpnwii"><span class="bell-empty-icon svelte-hpnwii"></span> <span class="text-muted text-sm svelte-hpnwii">No weather alerts right now</span></div>`);
var root_5$2 = /* @__PURE__ */ from_html(`<div class="bell-dropdown animate-slide-down svelte-hpnwii"><div class="bell-header svelte-hpnwii"><span class="bell-title svelte-hpnwii"> </span> <!></div> <div class="bell-body svelte-hpnwii"><!></div></div>`); var root_5$2 = /* @__PURE__ */ from_html(`<div class="bell-dropdown animate-slide-down svelte-hpnwii"><div class="bell-header svelte-hpnwii"><span class="bell-title svelte-hpnwii"> </span> <!></div> <div class="bell-body svelte-hpnwii"><!></div></div>`);
@ -7737,7 +7855,7 @@ function NotificationBell($$anchor, $$props) {
reset(span_2); reset(span_2);
var node_2 = sibling(span_2, 2); var node_2 = sibling(span_2, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var button_1 = root_2$2(); var button_1 = root_2$3();
delegated("click", button_1, handleDismissAll); delegated("click", button_1, handleDismissAll);
append($$anchor, button_1); append($$anchor, button_1);
}; };
@ -7809,13 +7927,23 @@ delegate(["click"]);
//#endregion //#endregion
//#region src/components/SettingsDialog.svelte //#region src/components/SettingsDialog.svelte
init_db(); init_db();
var root$2 = /* @__PURE__ */ from_html(`<div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Rain threshold</span> <select class="svelte-1koizbb"><option>50%</option><option>60%</option><option>70%</option><option>80%</option><option>90%</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Wind gust threshold</span> <select class="svelte-1koizbb"><option> </option><option> </option><option> </option><option> </option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">UV Index threshold</span> <select class="svelte-1koizbb"><option>4+ (Moderate)</option><option>6+ (High)</option><option>8+ (Very High)</option><option>11+ (Extreme)</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">High temp alert</span> <select class="svelte-1koizbb"><option>30°C / 86°F</option><option>35°C / 95°F</option><option>38°C / 100°F</option><option>42°C / 108°F</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Low temp alert</span> <select class="svelte-1koizbb"><option>5°C / 41°F</option><option>0°C / 32°F</option><option>-5°C / 23°F</option><option>-10°C / 14°F</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Thunderstorm alerts</span> <label class="toggle svelte-1koizbb"><input type="checkbox" class="svelte-1koizbb"/> <span class="toggle-slider svelte-1koizbb"></span></label></div>`, 1); var root$2 = /* @__PURE__ */ from_html(`<div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb"> </span> <label class="toggle svelte-1koizbb"><input type="checkbox" class="svelte-1koizbb"/> <span class="toggle-slider svelte-1koizbb"></span></label></div>`);
var root_1$2 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-1koizbb" role="presentation"><div class="modal svelte-1koizbb" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1"><div class="modal-header svelte-1koizbb"><h3>⚙️ Settings</h3> <button class="btn-icon" aria-label="Close"></button></div> <div class="settings-body svelte-1koizbb"><div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Data source</h4> <div class="setting-row svelte-1koizbb"><div class="setting-col svelte-1koizbb"><span class="svelte-1koizbb">Weather provider</span> <span class="setting-help svelte-1koizbb">NWS is US-only; falls back to Open-Meteo if unavailable.</span></div> <select class="svelte-1koizbb"><option>Open-Meteo (global)</option><option>National Weather Service (US)</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Auto-refresh</h4> <div class="setting-row svelte-1koizbb"><div class="setting-col svelte-1koizbb"><span class="svelte-1koizbb">Refresh interval</span> <span class="setting-help svelte-1koizbb">Automatically fetch updated weather on a schedule. Turn off to refresh only when you open the app.</span></div> <select class="svelte-1koizbb"><option>Off</option><option>Every 5 minutes</option><option>Every 10 minutes</option><option>Every 15 minutes</option><option>Every 30 minutes</option><option>Every hour</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Units</h4> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Measurement system</span> <select class="svelte-1koizbb"><option>Metric (°C, km/h)</option><option>Imperial (°F, mph)</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Weather Alerts</h4> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Enable Alerts</span> <label class="toggle svelte-1koizbb"><input type="checkbox" class="svelte-1koizbb"/> <span class="toggle-slider svelte-1koizbb"></span></label></div> <!></div> <div class="settings-group danger-zone svelte-1koizbb"><h4 class="svelte-1koizbb">Data</h4> <button class="btn btn-danger btn-sm">🗑 Reset All Data</button></div></div> <div class="modal-footer svelte-1koizbb"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`); var root_1$2 = /* @__PURE__ */ from_html(`<div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Rain threshold</span> <select class="svelte-1koizbb"><option>50%</option><option>60%</option><option>70%</option><option>80%</option><option>90%</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Wind gust threshold</span> <select class="svelte-1koizbb"><option> </option><option> </option><option> </option><option> </option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">UV Index threshold</span> <select class="svelte-1koizbb"><option>4+ (Moderate)</option><option>6+ (High)</option><option>8+ (Very High)</option><option>11+ (Extreme)</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">High temp alert</span> <select class="svelte-1koizbb"><option>30°C / 86°F</option><option>35°C / 95°F</option><option>38°C / 100°F</option><option>42°C / 108°F</option></select></div> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Low temp alert</span> <select class="svelte-1koizbb"><option>5°C / 41°F</option><option>0°C / 32°F</option><option>-5°C / 23°F</option><option>-10°C / 14°F</option></select></div> <!>`, 1);
var root_2$2 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-1koizbb" role="presentation"><div class="modal svelte-1koizbb" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1"><div class="modal-header svelte-1koizbb"><h3>⚙️ Settings</h3> <button class="btn-icon" aria-label="Close"></button></div> <div class="settings-body svelte-1koizbb"><div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Data source</h4> <div class="setting-row svelte-1koizbb"><div class="setting-col svelte-1koizbb"><span class="svelte-1koizbb">Weather provider</span> <span class="setting-help svelte-1koizbb">NWS is US-only; falls back to Open-Meteo if unavailable.</span></div> <select class="svelte-1koizbb"><option>Open-Meteo (global)</option><option>National Weather Service (US)</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Auto-refresh</h4> <div class="setting-row svelte-1koizbb"><div class="setting-col svelte-1koizbb"><span class="svelte-1koizbb">Refresh interval</span> <span class="setting-help svelte-1koizbb">Automatically fetch updated weather on a schedule. Turn off to refresh only when you open the app.</span></div> <select class="svelte-1koizbb"><option>Off</option><option>Every 5 minutes</option><option>Every 10 minutes</option><option>Every 15 minutes</option><option>Every 30 minutes</option><option>Every hour</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Units</h4> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Measurement system</span> <select class="svelte-1koizbb"><option>Metric (°C, km/h)</option><option>Imperial (°F, mph)</option></select></div></div> <div class="settings-group svelte-1koizbb"><h4 class="svelte-1koizbb">Weather Alerts</h4> <div class="setting-row svelte-1koizbb"><span class="svelte-1koizbb">Enable Alerts</span> <label class="toggle svelte-1koizbb"><input type="checkbox" class="svelte-1koizbb"/> <span class="toggle-slider svelte-1koizbb"></span></label></div> <div class="setting-row svelte-1koizbb"><div class="setting-col svelte-1koizbb"><span class="svelte-1koizbb">Notify on each alert type</span> <span class="setting-help svelte-1koizbb">Turn off a type to fully suppress that alert (banner and notification).</span></div></div> <!></div> <div class="settings-group danger-zone svelte-1koizbb"><h4 class="svelte-1koizbb">Data</h4> <button class="btn btn-danger btn-sm">🗑 Reset All Data</button></div></div> <div class="modal-footer svelte-1koizbb"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
function SettingsDialog($$anchor, $$props) { function SettingsDialog($$anchor, $$props) {
push($$props, true); push($$props, true);
const TYPE_LABELS = {
storm: "Severe weather / storms",
precip: "Rain chance",
heat: "High heat",
cold: "Cold snap",
uv: "High UV index",
wind: "Strong winds"
};
let localSettings = proxy({ let localSettings = proxy({
...app$1.settings, ...app$1.settings,
alertThresholds: { ...app$1.settings.alertThresholds } alertThresholds: { ...app$1.settings.alertThresholds },
notificationTypes: { ...app$1.settings.notificationTypes }
}); });
let saving = /* @__PURE__ */ state(false); let saving = /* @__PURE__ */ state(false);
async function handleSave() { async function handleSave() {
@ -7824,7 +7952,8 @@ function SettingsDialog($$anchor, $$props) {
app$1.settings = { ...localSettings }; app$1.settings = { ...localSettings };
await saveSettings({ await saveSettings({
...localSettings, ...localSettings,
alertThresholds: { ...localSettings.alertThresholds } alertThresholds: { ...localSettings.alertThresholds },
notificationTypes: { ...localSettings.notificationTypes }
}); });
await app$1.refreshForecast(); await app$1.refreshForecast();
$$props.onClose(); $$props.onClose();
@ -7846,7 +7975,7 @@ function SettingsDialog($$anchor, $$props) {
function handleBackdrop(e) { function handleBackdrop(e) {
if (e.target === e.currentTarget) $$props.onClose(); if (e.target === e.currentTarget) $$props.onClose();
} }
var div = root_1$2(); var div = root_2$2();
var div_1 = child(div); var div_1 = child(div);
var div_2 = child(div_1); var div_2 = child(div_1);
var button = sibling(child(div_2), 2); var button = sibling(child(div_2), 2);
@ -7904,9 +8033,9 @@ function SettingsDialog($$anchor, $$props) {
next(2); next(2);
reset(label); reset(label);
reset(div_11); reset(div_11);
var node = sibling(div_11, 2); var node = sibling(div_11, 4);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
var fragment = root$2(); var fragment = root_1$2();
var div_12 = first_child(fragment); var div_12 = first_child(fragment);
var select_3 = sibling(child(div_12), 2); var select_3 = sibling(child(div_12), 2);
var option_10 = child(select_3); var option_10 = child(select_3);
@ -7987,13 +8116,31 @@ function SettingsDialog($$anchor, $$props) {
var select_7_value; var select_7_value;
init_select(select_7); init_select(select_7);
reset(div_16); reset(div_16);
var div_17 = sibling(div_16, 2); each(sibling(div_16, 2), 16, () => [
var label_1 = sibling(child(div_17), 2); "storm",
var input_1 = child(label_1); "precip",
remove_input_defaults(input_1); "heat",
next(2); "cold",
reset(label_1); "uv",
reset(div_17); "wind"
], index, ($$anchor, key) => {
var div_17 = root$2();
var span = child(div_17);
var text_4 = child(span, true);
reset(span);
var label_1 = sibling(span, 2);
var input_1 = child(label_1);
remove_input_defaults(input_1);
next(2);
reset(label_1);
reset(div_17);
template_effect(() => {
set_text(text_4, TYPE_LABELS[key]);
set_checked(input_1, localSettings.notificationTypes[key]);
});
delegated("change", input_1, (e) => localSettings.notificationTypes[key] = e.target.checked);
append($$anchor, div_17);
});
template_effect(($0, $1, $2, $3, $4) => { template_effect(($0, $1, $2, $3, $4) => {
if (select_3_value !== (select_3_value = $0)) select_3.value = (select_3.__value = $0) ?? "", select_option(select_3, $0); if (select_3_value !== (select_3_value = $0)) select_3.value = (select_3.__value = $0) ?? "", select_option(select_3, $0);
set_text(text, `30 ${localSettings.windUnit ?? ""}`); set_text(text, `30 ${localSettings.windUnit ?? ""}`);
@ -8004,7 +8151,6 @@ function SettingsDialog($$anchor, $$props) {
if (select_5_value !== (select_5_value = $2)) select_5.value = (select_5.__value = $2) ?? "", select_option(select_5, $2); if (select_5_value !== (select_5_value = $2)) select_5.value = (select_5.__value = $2) ?? "", select_option(select_5, $2);
if (select_6_value !== (select_6_value = $3)) select_6.value = (select_6.__value = $3) ?? "", select_option(select_6, $3); if (select_6_value !== (select_6_value = $3)) select_6.value = (select_6.__value = $3) ?? "", select_option(select_6, $3);
if (select_7_value !== (select_7_value = $4)) select_7.value = (select_7.__value = $4) ?? "", select_option(select_7, $4); if (select_7_value !== (select_7_value = $4)) select_7.value = (select_7.__value = $4) ?? "", select_option(select_7, $4);
set_checked(input_1, localSettings.alertThresholds.thunderstorm);
}, [ }, [
() => String(localSettings.alertThresholds.precip), () => String(localSettings.alertThresholds.precip),
() => String(localSettings.alertThresholds.windGust), () => String(localSettings.alertThresholds.windGust),
@ -8017,7 +8163,6 @@ function SettingsDialog($$anchor, $$props) {
delegated("change", select_5, (e) => localSettings.alertThresholds.uvIndex = parseInt(e.target.value)); delegated("change", select_5, (e) => localSettings.alertThresholds.uvIndex = parseInt(e.target.value));
delegated("change", select_6, (e) => localSettings.alertThresholds.tempHigh = parseInt(e.target.value)); delegated("change", select_6, (e) => localSettings.alertThresholds.tempHigh = parseInt(e.target.value));
delegated("change", select_7, (e) => localSettings.alertThresholds.tempLow = parseInt(e.target.value)); delegated("change", select_7, (e) => localSettings.alertThresholds.tempLow = parseInt(e.target.value));
delegated("change", input_1, (e) => localSettings.alertThresholds.thunderstorm = e.target.checked);
append($$anchor, fragment); append($$anchor, fragment);
}; };
if_block(node, ($$render) => { if_block(node, ($$render) => {
@ -8030,7 +8175,7 @@ function SettingsDialog($$anchor, $$props) {
reset(div_3); reset(div_3);
var div_19 = sibling(div_3, 2); var div_19 = sibling(div_3, 2);
var button_2 = child(div_19); var button_2 = child(div_19);
var text_4 = child(button_2, true); var text_5 = child(button_2, true);
reset(button_2); reset(button_2);
var button_3 = sibling(button_2, 2); var button_3 = sibling(button_2, 2);
reset(div_19); reset(div_19);
@ -8042,7 +8187,7 @@ function SettingsDialog($$anchor, $$props) {
if (select_2_value !== (select_2_value = localSettings.units)) select_2.value = (select_2.__value = localSettings.units) ?? "", select_option(select_2, localSettings.units); if (select_2_value !== (select_2_value = localSettings.units)) select_2.value = (select_2.__value = localSettings.units) ?? "", select_option(select_2, localSettings.units);
set_checked(input, localSettings.alertsEnabled); set_checked(input, localSettings.alertsEnabled);
button_2.disabled = get(saving); button_2.disabled = get(saving);
set_text(text_4, get(saving) ? "Saving..." : "Save Settings"); set_text(text_5, get(saving) ? "Saving..." : "Save Settings");
}, [() => String(localSettings.refreshInterval || 0)]); }, [() => String(localSettings.refreshInterval || 0)]);
delegated("click", div, handleBackdrop); delegated("click", div, handleBackdrop);
delegated("click", div_1, (e) => e.stopPropagation()); delegated("click", div_1, (e) => e.stopPropagation());
@ -8647,7 +8792,8 @@ delegate([
]); ]);
//#endregion //#endregion
//#region src/main.js //#region src/main.js
console.info({ commit_hash: "f5488eaa983eaef1a39ca585192aa9b7b450a2c4" }); console.info({ commit_hash: "2c678fd00db6e0244334a9b6de10a6d379c9126c" });
registerServiceWorker();
mount(App, { target: document.getElementById("app") }); mount(App, { target: document.getElementById("app") });
//#endregion</script> //#endregion</script>
<style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */ <style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */

60
dist/manifest.webmanifest vendored Normal file
View File

@ -0,0 +1,60 @@
{
"name": "WeatherLens",
"short_name": "Weather",
"description": "Hyper-local weather forecasts, alerts, and radar for your saved locations.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"display_override": "standalone",
"orientation": "any",
"background_color": "#132744",
"theme_color": "#0ea5e9",
"categories": ["weather", "utilities", "productivity"],
"lang": "en",
"icons": [
{
"src": "./icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Now",
"short_name": "Now",
"url": "./#/current",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Hourly",
"short_name": "Hourly",
"url": "./#/hourly",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "7-Day Forecast",
"short_name": "Forecast",
"url": "./#/daily",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Radar",
"short_name": "Radar",
"url": "./#/radar",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}

81
dist/sw.js vendored Normal file
View File

@ -0,0 +1,81 @@
const VERSION = '1.0.0'
const APP_NAME = 'WeatherLens'
const APP_ICON = './icons/icon-192.png'
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', () => {
self.clients?.claim?.()
})
function showNotification({ title = APP_NAME, body = '', icon, tag } = {}) {
try {
const opts = {
body,
icon: icon || APP_ICON,
tag,
requireInteraction: false,
silent: false,
}
const reg = self.registration
if (reg && typeof reg.showNotification === 'function') {
return reg.showNotification(title, opts)
}
if (typeof Notification === 'function' || typeof Notification === 'object') {
new Notification(title, opts).show()
return true
}
} catch (e) {
console.warn('[sw] notification failed:', e)
}
return false
}
self.addEventListener('message', (event) => {
const data = event?.data || {}
if (data.type === 'weather-notify') {
showNotification(data)
}
})
self.addEventListener('push', (event) => {
let payload = {}
try {
payload = event?.data?.json?.() ?? {}
} catch {
/* not JSON */
}
event?.waitUntil?.(
Promise.resolve(showNotification(payload)).then(async () => {
const clients = self.clients
if (clients) {
for (const client of await clients.matchAll({ includeUncontrolled: true })) {
client.postMessage({ type: 'weather-push-refresh' })
}
}
})
)
})
self.addEventListener('notificationclick', (event) => {
event?.waitUntil?.(
(async () => {
const url = event?.notification?.data?.url || './'
const clients = self.clients
if (clients) {
for (const client of await clients.matchAll({ includeUncontrolled: true })) {
await client.navigate?.(url)
}
}
await clients?.openWindow?.(url)
})()
)
})
self.addEventListener('fetch', () => {
/* network-first */
})
console.log(`[sw] ${APP_NAME} ${VERSION} active`)

View File

@ -2,8 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0ea5e9" />
<meta name="mobile-web-app-capable" content="yes" />
<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="WeatherLens" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>WeatherLens</title> <title>WeatherLens</title>
</head> </head>
<body> <body>

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,60 @@
{
"name": "WeatherLens",
"short_name": "Weather",
"description": "Hyper-local weather forecasts, alerts, and radar for your saved locations.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"display_override": "standalone",
"orientation": "any",
"background_color": "#132744",
"theme_color": "#0ea5e9",
"categories": ["weather", "utilities", "productivity"],
"lang": "en",
"icons": [
{
"src": "./icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Now",
"short_name": "Now",
"url": "./#/current",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Hourly",
"short_name": "Hourly",
"url": "./#/hourly",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "7-Day Forecast",
"short_name": "Forecast",
"url": "./#/daily",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Radar",
"short_name": "Radar",
"url": "./#/radar",
"icons": [{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}

24
public/pwa-icon.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0ea5e9" />
<stop offset="1" stop-color="#132744" />
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)" />
<circle cx="256" cy="256" r="196" fill="#fbbf24" opacity="0.92" />
<g stroke="#fbbf24" stroke-width="12" stroke-linecap="round" opacity="0.9">
<line x1="256" y1="92" x2="256" y2="140" />
<line x1="256" y1="372" x2="256" y2="420" />
<line x1="92" y1="256" x2="140" y2="256" />
<line x1="372" y1="256" x2="420" y2="256" />
<line x1="142" y1="142" x2="184" y2="184" />
<line x1="328" y1="328" x2="370" y2="370" />
<line x1="142" y1="328" x2="184" y2="370" />
<line x1="328" y1="142" x2="370" y2="184" />
</g>
<path d="M256 332 Q214 332 196 306 Q184 276 192 256 Q188 232 214 218 Q206 204 238 196
Q246 190 262 194 Q268 178 296 190 Q300 208 314 216 Q328 226 326 238
Q342 246 344 268 Q348 288 336 300 Q330 318 316 330 Q310 344 256 332Z"
fill="#38bdf8" opacity="0.95" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

81
public/sw.js Normal file
View File

@ -0,0 +1,81 @@
const VERSION = '1.0.0'
const APP_NAME = 'WeatherLens'
const APP_ICON = './icons/icon-192.png'
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', () => {
self.clients?.claim?.()
})
function showNotification({ title = APP_NAME, body = '', icon, tag } = {}) {
try {
const opts = {
body,
icon: icon || APP_ICON,
tag,
requireInteraction: false,
silent: false,
}
const reg = self.registration
if (reg && typeof reg.showNotification === 'function') {
return reg.showNotification(title, opts)
}
if (typeof Notification === 'function' || typeof Notification === 'object') {
new Notification(title, opts).show()
return true
}
} catch (e) {
console.warn('[sw] notification failed:', e)
}
return false
}
self.addEventListener('message', (event) => {
const data = event?.data || {}
if (data.type === 'weather-notify') {
showNotification(data)
}
})
self.addEventListener('push', (event) => {
let payload = {}
try {
payload = event?.data?.json?.() ?? {}
} catch {
/* not JSON */
}
event?.waitUntil?.(
Promise.resolve(showNotification(payload)).then(async () => {
const clients = self.clients
if (clients) {
for (const client of await clients.matchAll({ includeUncontrolled: true })) {
client.postMessage({ type: 'weather-push-refresh' })
}
}
})
)
})
self.addEventListener('notificationclick', (event) => {
event?.waitUntil?.(
(async () => {
const url = event?.notification?.data?.url || './'
const clients = self.clients
if (clients) {
for (const client of await clients.matchAll({ includeUncontrolled: true })) {
await client.navigate?.(url)
}
}
await clients?.openWindow?.(url)
})()
)
})
self.addEventListener('fetch', () => {
/* network-first */
})
console.log(`[sw] ${APP_NAME} ${VERSION} active`)

View File

@ -30,11 +30,14 @@ if (existsSync(faviconPath)) {
console.log('[inline-assets] Inlined favicon.svg into index.html') console.log('[inline-assets] Inlined favicon.svg into index.html')
} }
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler) // Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
const iconsPath = join(distDir, 'icons.svg') // and the source pwa-icon.svg which is only needed to regenerate the PNGs).
if (existsSync(iconsPath)) { for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
rmSync(iconsPath) const p = join(distDir, leftover)
console.log('[inline-assets] Removed icons.svg') if (existsSync(p)) {
rmSync(p)
console.log(`[inline-assets] Removed ${leftover}`)
}
} }
// Remove assets directory if it exists // Remove assets directory if it exists
@ -44,4 +47,12 @@ if (existsSync(assetsDir)) {
console.log('[inline-assets] Removed assets/ directory') console.log('[inline-assets] Removed assets/ directory')
} }
console.log('[inline-assets] Done — dist/ contains only index.html') // Keep the PWA companion files that MUST ship alongside index.html so a user
// can install the app: the web manifest, the service worker, and the icon set.
// (These are public/ files Vite copied into dist/; do not delete them.)
for (const keep of ['manifest.webmanifest', 'sw.js', 'icons']) {
const p = join(distDir, keep)
if (existsSync(p)) console.log(`[inline-assets] Preserved PWA ${keep}: ${p}`)
}
console.log('[inline-assets] Done — dist/ contains index.html + PWA files')

View File

@ -4,9 +4,20 @@
let { onClose } = $props() let { onClose } = $props()
// Human labels for each notifiable alert type (used for the per-type toggles).
const TYPE_LABELS = {
storm: 'Severe weather / storms',
precip: 'Rain chance',
heat: 'High heat',
cold: 'Cold snap',
uv: 'High UV index',
wind: 'Strong winds',
}
let localSettings = $state({ let localSettings = $state({
...app.settings, ...app.settings,
alertThresholds: { ...app.settings.alertThresholds }, alertThresholds: { ...app.settings.alertThresholds },
notificationTypes: { ...app.settings.notificationTypes },
}) })
let saving = $state(false) let saving = $state(false)
@ -17,6 +28,7 @@
await saveSettings({ await saveSettings({
...localSettings, ...localSettings,
alertThresholds: { ...localSettings.alertThresholds }, alertThresholds: { ...localSettings.alertThresholds },
notificationTypes: { ...localSettings.notificationTypes },
}) })
// Refresh forecast with new unit if it changed // Refresh forecast with new unit if it changed
await app.refreshForecast() await app.refreshForecast()
@ -131,6 +143,13 @@
</label> </label>
</div> </div>
<div class="setting-row">
<div class="setting-col">
<span>Notify on each alert type</span>
<span class="setting-help">Turn off a type to fully suppress that alert (banner and notification).</span>
</div>
</div>
{#if localSettings.alertsEnabled} {#if localSettings.alertsEnabled}
<div class="setting-row"> <div class="setting-row">
<span>Rain threshold</span> <span>Rain threshold</span>
@ -198,17 +217,19 @@
</select> </select>
</div> </div>
<div class="setting-row"> {#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind'] as key}
<span>Thunderstorm alerts</span> <div class="setting-row">
<label class="toggle"> <span>{TYPE_LABELS[key]}</span>
<input <label class="toggle">
type="checkbox" <input
checked={localSettings.alertThresholds.thunderstorm} type="checkbox"
onchange={(e) => localSettings.alertThresholds.thunderstorm = e.target.checked} checked={localSettings.notificationTypes[key]}
/> onchange={(e) => localSettings.notificationTypes[key] = e.target.checked}
<span class="toggle-slider"></span> />
</label> <span class="toggle-slider"></span>
</div> </label>
</div>
{/each}
{/if} {/if}
</div> </div>

77
src/lib/pwa.js Normal file
View File

@ -0,0 +1,77 @@
/**
* PWA bootstrap + native-notification helper.
*
* Registration uses a *relative* URL so it works both in dev (served from /)
* and in production (deployed under /weather/). Registering the same relative
* path as the page keeps the SW scope pinned to the app's own directory.
*
* Notification delivery: the page cannot itself raise a native Android web
* notification while in a background tab; that requires the service worker.
* So `notify()` forwards the alert to the active SW controller, which calls
* reg.showNotification(). It falls back to an in-page Notification object when
* a SW isn't available yet. Everything is best-effort notification never
* blocks or breaks the app.
*/
let registrationReady = false
/** Promise-wrapped access to the active SW registration (if any). */
function activeRegistration() {
if (!('serviceWorker' in navigator)) return Promise.resolve(null)
return navigator.serviceWorker.getRegistration().catch(() => null)
}
/**
* Register the service worker. Called once at startup. Safe to call in dev,
* preview, and production alike (no-op where service workers are unavailable).
*/
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator) || !window.isSecureContext) return false
try {
// Relative URL: resolves against the current directory (works at / and
// under /weather/). An update() nudges browsers into replacing any older
// SW byte-for-byte instead of waiting for a scope change.
const reg = await navigator.serviceWorker.register('./sw.js')
registrationReady = true
try {
await reg.update()
} catch {
/* first install may have nothing to update — fine */
}
return true
} catch (e) {
console.warn('[pwa] service worker registration failed:', e)
return false
}
}
/**
* Send a native notification (best-effort). Routes through the SW when one is
* active so it works from a background tab / installed app; otherwise falls
* back to an in-page Notification object.
*
* @param {object} n { title, body, icon?, tag? }
*/
export async function notify({ title, body = '', icon, tag }) {
// 1) Prefer the service worker (the only path that works when not focused).
const reg = await activeRegistration()
if (reg?.active) {
try {
reg.active.postMessage({ type: 'weather-notify', title, body, icon, tag })
return true
} catch {
/* SW controller not reachable — fall through */
}
}
// 2) In-page Notification object as a fallback.
try {
if (typeof Notification === 'function') {
new Notification(title, { body, icon, tag }).show() // eslint-disable-line no-new
return true
}
} catch {
/* permission not granted or API unavailable — handled by caller */
}
return false
}

View File

@ -127,13 +127,20 @@ const DEFAULT_SETTINGS = {
refreshInterval: 30, // minutes; 0 = no auto-refresh refreshInterval: 30, // minutes; 0 = no auto-refresh
alertsEnabled: true, alertsEnabled: true,
showLightning: false, // toggleable lightning strike layer on the radar map showLightning: false, // toggleable lightning strike layer on the radar map
notificationTypes: {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true,
},
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,
windGust: 40, windGust: 40,
uvIndex: 6, uvIndex: 6,
tempHigh: 35, tempHigh: 35,
tempLow: 0, tempLow: 0,
thunderstorm: true,
}, },
dismissedAlerts: [], dismissedAlerts: [],
} }

View File

@ -36,13 +36,20 @@ export class AppStore {
refreshInterval: 30, refreshInterval: 30,
alertsEnabled: true, alertsEnabled: true,
showLightning: false, // toggleable lightning strike layer on the radar map showLightning: false, // toggleable lightning strike layer on the radar map
notificationTypes: {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true,
},
alertThresholds: { alertThresholds: {
precip: 70, precip: 70,
windGust: 40, windGust: 40,
uvIndex: 6, uvIndex: 6,
tempHigh: 35, tempHigh: 35,
tempLow: 0, tempLow: 0,
thunderstorm: true,
}, },
}) })

View File

@ -8,6 +8,16 @@ import { isSevereWeather } from '../api/weather-codes.js'
import { app } from './app.svelte.js' import { app } from './app.svelte.js'
import { saveSettings } from '../storage/db.js' import { saveSettings } from '../storage/db.js'
import { parseLocalDate } from '../dates.js' import { parseLocalDate } from '../dates.js'
import { notify } from '../pwa.js'
const APP_TITLES = {
storm: '⛈️ Severe weather',
precip: '🌧️ Rain expected',
heat: '🔥 Heat alert',
cold: '🥶 Cold alert',
uv: '☀️ High UV',
wind: '💨 Strong winds',
}
export class NotificationStore { export class NotificationStore {
/** @type {Array<{ id: string, type: string, severity: 'info'|'warning'|'danger', message: string, icon: string }>} */ /** @type {Array<{ id: string, type: string, severity: 'info'|'warning'|'danger', message: string, icon: string }>} */
@ -18,6 +28,19 @@ export class NotificationStore {
lastForecastDate = null lastForecastDate = null
/** Whether dismissals have been loaded from DB */ /** Whether dismissals have been loaded from DB */
_dismissalsLoaded = false _dismissalsLoaded = false
/** Per-type alert toggles. Defaults to all-on so existing saved settings
* (which predate this field) still alert for every kind. */
typeEnabled = {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true,
}
/** Alert ids already surfaced as a native notification this forecast day,
* so an auto-refresh doesn't re-notify the same condition every interval. */
_notifiedIds = new Set()
/** /**
* Check forecast data and generate appropriate alerts. * Check forecast data and generate appropriate alerts.
@ -41,14 +64,29 @@ export class NotificationStore {
const forecastDate = data.daily?.time?.[0] || null const forecastDate = data.daily?.time?.[0] || null
if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) { if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) {
this.resetDismissed() this.resetDismissed()
this._notifiedIds.clear() // new forecast day -> allow re-notifying
} }
this.lastForecastDate = forecastDate this.lastForecastDate = forecastDate
// Apply per-type toggles from settings, falling back to all-on when the
// preference is absent (so the change is backward compatible).
const types = settings.notificationTypes || {}
this.typeEnabled = {
storm: types.storm !== false,
precip: types.precip !== false,
heat: types.heat !== false,
cold: types.cold !== false,
uv: types.uv !== false,
wind: types.wind !== false,
}
const t = settings.alertThresholds const t = settings.alertThresholds
const newAlerts = [] let newAlerts = []
const typeEnabled = this.typeEnabled
function add(id, type, severity, message, icon) { function add(id, type, severity, message, icon) {
if (!app.settings.alertsEnabled) return if (!app.settings.alertsEnabled) return
if (!typeEnabled[type]) return
newAlerts.push({ id, type, severity, message, icon }) newAlerts.push({ id, type, severity, message, icon })
} }
@ -57,7 +95,7 @@ export class NotificationStore {
// --- Current weather alerts --- // --- Current weather alerts ---
if (t.thunderstorm && isSevereWeather(current.weather_code)) { if (isSevereWeather(current.weather_code)) {
add('storm-now', 'storm', 'danger', add('storm-now', 'storm', 'danger',
'⚡ Thunderstorm active — seek shelter if outdoors', '⛈️') '⚡ Thunderstorm active — seek shelter if outdoors', '⛈️')
} }
@ -76,7 +114,7 @@ export class NotificationStore {
} }
const code = daily.weather_code?.[i] const code = daily.weather_code?.[i]
if (t.thunderstorm && isSevereWeather(code)) { if (isSevereWeather(code)) {
add(`storm-${i}`, 'storm', 'danger', add(`storm-${i}`, 'storm', 'danger',
`⛈️ Thunderstorms expected ${dateLabel.toLowerCase()}`, '⛈️') `⛈️ Thunderstorms expected ${dateLabel.toLowerCase()}`, '⛈️')
} }
@ -107,7 +145,21 @@ export class NotificationStore {
} }
} }
this.alerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id)) newAlerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id))
this.alerts = newAlerts
// Lightweight native notifications: surface alerts that just appeared and
// haven't been notified this forecast day. Best-effort — never blocks.
for (const a of this.alerts) {
if (this._notifiedIds.has(a.id)) continue
this._notifiedIds.add(a.id)
notify({
title: APP_TITLES[a.type] || 'Weather alert',
body: a.message,
icon: './icons/icon-192.png',
tag: `weather-${a.type}`,
})
}
} }
/** /**

View File

@ -1,10 +1,15 @@
import { mount } from 'svelte' import { mount } from 'svelte'
import './styles/main.css' import './styles/main.css'
import App from './App.svelte' import App from './App.svelte'
import { registerServiceWorker } from './lib/pwa.js'
// Report the baked-in commit hash on startup (handy for verifying a deploy). // Report the baked-in commit hash on startup (handy for verifying a deploy).
console.info({ commit_hash: __WEATHER_COMMIT__ }) console.info({ commit_hash: __WEATHER_COMMIT__ })
// Register the PWA service worker (no-op in unsupported contexts). This must
// happen early so `notify()` can route through it later.
registerServiceWorker()
const app = mount(App, { const app = mount(App, {
target: document.getElementById('app'), target: document.getElementById('app'),
}) })

View File

@ -167,3 +167,79 @@ describe('NotificationStore', () => {
expect(precipAlerts.length).toBeLessThanOrEqual(3) expect(precipAlerts.length).toBeLessThanOrEqual(3)
}) })
}) })
describe('NotificationStore per-type toggles', () => {
let notifications
beforeEach(() => {
notifications = new NotificationStore()
notifications.alerts = []
notifications.dismissedIds.clear()
notifications._notifiedIds.clear()
})
function setForecastWith(overrides = {}) {
app.forecastData = {
current: { weather_code: 0, ...(overrides.current || {}) },
daily: {
time: ['2026-07-22', '2026-07-23', '2026-07-24'],
precipitation_probability_max: [85, 30, 10],
weather_code: [95, 0, 0],
temperature_2m_max: [38, 35, 32],
temperature_2m_min: [-5, 0, 5],
uv_index_max: [8, 5, 3],
wind_speed_10m_max: [50, 20, 15],
...(overrides.daily || {}),
},
}
app.settings = {
alertsEnabled: true,
units: 'metric',
alertThresholds: { precip: 70, windGust: 40, uvIndex: 6, tempHigh: 35, tempLow: 0 },
notificationTypes: {
storm: true, precip: true, heat: true, cold: true, uv: true, wind: true,
...(overrides.notificationTypes || {}),
},
}
}
it('fires all alert types when every toggle is on', () => {
setForecastWith()
notifications.analyze()
const types = new Set(notifications.alerts.map((a) => a.type))
expect(types).toEqual(new Set(['storm', 'precip', 'heat', 'cold', 'uv', 'wind']))
})
it('suppresses heat alert when heat toggle is off', () => {
setForecastWith({ notificationTypes: { heat: false } })
notifications.analyze()
expect(notifications.alerts.some((a) => a.type === 'heat')).toBe(false)
// Other types unaffected
expect(notifications.alerts.some((a) => a.type === 'precip')).toBe(true)
})
it('suppresses storm alert when storm toggle is off', () => {
setForecastWith({ notificationTypes: { storm: false } })
notifications.analyze()
expect(notifications.alerts.some((a) => a.type === 'storm')).toBe(false)
})
it('suppresses every type independently', () => {
const offs = ['storm', 'precip', 'heat', 'cold', 'uv', 'wind']
for (const off of offs) {
setForecastWith({ notificationTypes: { [off]: false } })
notifications.analyze()
expect(notifications.alerts.some((a) => a.type === off)).toBe(
false,
`expected ${off} alert to be suppressed`
)
}
})
it('suppresses all alerts when master alertsEnabled is off', () => {
setForecastWith()
app.settings.alertsEnabled = false
notifications.analyze()
expect(notifications.alerts).toHaveLength(0)
})
})