weather_app/src/components/SettingsDialog.svelte
hermes-explorigin 897f49ae22
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 1m1s
Test, Build & Deploy / deploy (push) Successful in 22s
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
2026-09-08 00:59:19 +00:00

424 lines
12 KiB
Svelte

<script>
import { app } from '../lib/stores/app.svelte.js'
import { saveSettings } from '../lib/storage/db.js'
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({
...app.settings,
alertThresholds: { ...app.settings.alertThresholds },
notificationTypes: { ...app.settings.notificationTypes },
})
let saving = $state(false)
async function handleSave() {
saving = true
try {
app.settings = { ...localSettings }
await saveSettings({
...localSettings,
alertThresholds: { ...localSettings.alertThresholds },
notificationTypes: { ...localSettings.notificationTypes },
})
// Refresh forecast with new unit if it changed
await app.refreshForecast()
onClose()
} catch (e) {
console.error('Failed to save settings:', e)
} finally {
saving = false
}
}
function handleReset() {
if (confirm('Reset all locations and stored data?')) {
import('../lib/storage/db.js').then(async (db) => {
await db.clearAllLocations()
app.locations = []
app.selectedLocationId = null
app.forecastData = null
onClose()
})
}
}
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose()
}
</script>
<div class="modal-overlay" onclick={handleBackdrop} role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="Settings"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<div class="modal-header">
<h3>⚙️ Settings</h3>
<button class="btn-icon" onclick={onClose} aria-label="Close"></button>
</div>
<div class="settings-body">
<!-- Data source -->
<div class="settings-group">
<h4>Data source</h4>
<div class="setting-row">
<div class="setting-col">
<span>Weather provider</span>
<span class="setting-help">NWS is US-only; falls back to Open-Meteo if unavailable.</span>
</div>
<select
value={localSettings.source || 'open-meteo'}
onchange={(e) => localSettings.source = e.target.value}
>
<option value="open-meteo">Open-Meteo (global)</option>
<option value="nws">National Weather Service (US)</option>
</select>
</div>
</div>
<!-- Auto-refresh -->
<div class="settings-group">
<h4>Auto-refresh</h4>
<div class="setting-row">
<div class="setting-col">
<span>Refresh interval</span>
<span class="setting-help">Automatically fetch updated weather on a schedule. Turn off to refresh only when you open the app.</span>
</div>
<select
value={String(localSettings.refreshInterval || 0)}
onchange={(e) => localSettings.refreshInterval = parseInt(e.target.value)}
>
<option value="0">Off</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every hour</option>
</select>
</div>
</div>
<!-- Units -->
<div class="settings-group">
<h4>Units</h4>
<div class="setting-row">
<span>Measurement system</span>
<select
value={localSettings.units}
onchange={(e) => localSettings.units = e.target.value}
>
<option value="metric">Metric (°C, km/h)</option>
<option value="imperial">Imperial (°F, mph)</option>
</select>
</div>
</div>
<!-- Alerts -->
<div class="settings-group">
<h4>Weather Alerts</h4>
<div class="setting-row">
<span>Enable Alerts</span>
<label class="toggle">
<input
type="checkbox"
checked={localSettings.alertsEnabled}
onchange={(e) => localSettings.alertsEnabled = e.target.checked}
/>
<span class="toggle-slider"></span>
</label>
</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}
<div class="setting-row">
<span>Rain threshold</span>
<select
value={String(localSettings.alertThresholds.precip)}
onchange={(e) => localSettings.alertThresholds.precip = parseInt(e.target.value)}
>
<option value="50">50%</option>
<option value="60">60%</option>
<option value="70">70%</option>
<option value="80">80%</option>
<option value="90">90%</option>
</select>
</div>
<div class="setting-row">
<span>Wind gust threshold</span>
<select
value={String(localSettings.alertThresholds.windGust)}
onchange={(e) => localSettings.alertThresholds.windGust = parseInt(e.target.value)}
>
<option value="30">30 {localSettings.windUnit}</option>
<option value="40">40 {localSettings.windUnit}</option>
<option value="50">50 {localSettings.windUnit}</option>
<option value="60">60 {localSettings.windUnit}</option>
</select>
</div>
<div class="setting-row">
<span>UV Index threshold</span>
<select
value={String(localSettings.alertThresholds.uvIndex)}
onchange={(e) => localSettings.alertThresholds.uvIndex = parseInt(e.target.value)}
>
<option value="4">4+ (Moderate)</option>
<option value="6">6+ (High)</option>
<option value="8">8+ (Very High)</option>
<option value="11">11+ (Extreme)</option>
</select>
</div>
<div class="setting-row">
<span>High temp alert</span>
<select
value={String(localSettings.alertThresholds.tempHigh)}
onchange={(e) => localSettings.alertThresholds.tempHigh = parseInt(e.target.value)}
>
<option value="30">30°C / 86°F</option>
<option value="35">35°C / 95°F</option>
<option value="38">38°C / 100°F</option>
<option value="42">42°C / 108°F</option>
</select>
</div>
<div class="setting-row">
<span>Low temp alert</span>
<select
value={String(localSettings.alertThresholds.tempLow)}
onchange={(e) => localSettings.alertThresholds.tempLow = parseInt(e.target.value)}
>
<option value="5">5°C / 41°F</option>
<option value="0">0°C / 32°F</option>
<option value="-5">-5°C / 23°F</option>
<option value="-10">-10°C / 14°F</option>
</select>
</div>
{#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind'] as key}
<div class="setting-row">
<span>{TYPE_LABELS[key]}</span>
<label class="toggle">
<input
type="checkbox"
checked={localSettings.notificationTypes[key]}
onchange={(e) => localSettings.notificationTypes[key] = e.target.checked}
/>
<span class="toggle-slider"></span>
</label>
</div>
{/each}
{/if}
</div>
<!-- Danger zone -->
<div class="settings-group danger-zone">
<h4>Data</h4>
<button class="btn btn-danger btn-sm" onclick={handleReset}>
🗑 Reset All Data
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</button>
<button class="btn btn-ghost" onclick={onClose}>Cancel</button>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 20px;
max-width: 460px;
width: 100%;
box-shadow: var(--shadow-lg);
max-height: 85vh;
overflow-y: auto;
animation: fadeInScale 0.2s ease-out;
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.settings-body {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 20px;
}
.settings-group {
padding: 16px;
background: var(--color-surface-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.settings-group h4 {
font-size: 0.9rem;
margin-bottom: 12px;
color: var(--color-text-secondary);
}
.danger-zone {
border-color: rgba(239, 68, 68, 0.3);
}
.danger-zone h4 {
color: var(--color-danger);
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 0.85rem;
}
.setting-row + .setting-row {
border-top: 1px solid var(--color-border);
}
.setting-col {
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 12px;
}
.setting-help {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.setting-row span {
color: var(--color-text);
}
.setting-row select {
width: auto;
max-width: 160px;
padding: 6px 10px;
font-size: 0.82rem;
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--color-border);
border-radius: 24px;
transition: background-color 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle input:checked + .toggle-slider {
background: var(--color-primary);
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.modal-footer {
display: flex;
gap: 8px;
padding-top: 4px;
}
@media (max-width: 1023px) {
.modal {
max-height: 90dvh;
max-height: calc(100dvh - 52px);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
margin-bottom: 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-width: none;
padding-bottom: max(20px, var(--safe-bottom));
animation: slideUp 0.25s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
}
</style>