Add installable PWA (manifest, service worker, icons) + per-type notification toggles
- 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
@ -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
|
After Width: | Height: | Size: 21 KiB |
BIN
dist/icons/icon-512.png
vendored
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
dist/icons/icon-maskable-512.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
232
dist/index.html
vendored
@ -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
@ -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
@ -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`)
|
||||||
@ -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
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
60
public/manifest.webmanifest
Normal 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
@ -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
@ -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`)
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
}
|
||||||
@ -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: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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'),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||