diff --git a/src/components/AlertsPanel.jsx b/src/components/AlertsPanel.jsx index 209cb34..0ddadb1 100644 --- a/src/components/AlertsPanel.jsx +++ b/src/components/AlertsPanel.jsx @@ -1,29 +1,32 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { SAFE_LIMITS } from "../constants/cities"; import { useLocalStorageSet } from "../hooks/useLocalStorageSet"; +import { useNotificationSettings } from "../hooks/useNotificationSettings"; +import NotificationSettings from "./NotificationSettings"; const HISTORY_KEY = "aqi-alert-history"; const MAX_HISTORY = 50; -const HAZARDOUS_AQI_THRESHOLD = 200; const PUSH_ALERTS_KEY = "push-alerts-enabled"; const PUSH_ALERTS_FLAG = "enabled"; -/** @param {any} current */ -function buildWarnings(current) { +/** + * @param {any} current + * @param {any} thresholds - per-pollutant thresholds from notification settings + */ +function buildWarnings(current, thresholds) { const warnings = []; - if (current.pm2_5 > SAFE_LIMITS.pm2_5) + if (current.pm2_5 > thresholds.pm2_5) warnings.push( "PM2.5 is high. Wear a certified mask and avoid heavy outdoor exercise.", ); - if (current.pm10 > SAFE_LIMITS.pm10) + if (current.pm10 > thresholds.pm10) warnings.push( "PM10 is elevated. Keep windows closed during peak traffic hours.", ); - if (current.nitrogen_dioxide > SAFE_LIMITS.nitrogen_dioxide) + if (current.nitrogen_dioxide > thresholds.nitrogen_dioxide) warnings.push( "NO2 levels are unsafe. Reduce roadside exposure if possible.", ); - if (current.ozone > SAFE_LIMITS.ozone) + if (current.ozone > thresholds.ozone) warnings.push( "Ozone levels are high. Limit outdoor activity during peak sunlight hours.", ); @@ -60,12 +63,20 @@ export default function AlertsPanel({ useLocalStorageSet(PUSH_ALERTS_KEY); const alertsEnabled = hasAlertFlag(PUSH_ALERTS_FLAG); + // Custom AQI threshold, per-pollutant thresholds, and quiet hours (localStorage-backed) + const { settings: notificationSettings, updateSettings, isWithinQuietHours } = + useNotificationSettings(); + const [showSettings, setShowSettings] = useState(false); + // Separate ref for history deduplication — does not affect notification behavior const lastHistorySignature = useRef(""); // Keep every hook call unconditional (Rules of Hooks). Guard `current` inside // the hooks and bail out before rendering the JSX further down. - const warnings = useMemo(() => (current ? buildWarnings(current) : []), [current]); + const warnings = useMemo( + () => (current ? buildWarnings(current, notificationSettings.pollutantThresholds) : []), + [current, notificationSettings.pollutantThresholds], + ); const lastNotified = useRef(""); useEffect(() => { @@ -101,19 +112,29 @@ export default function AlertsPanel({ } // Browser push notification: only fire when permission is granted, - // alerts are enabled by the user, and AQI exceeds the hazardous threshold. + // alerts are enabled by the user, AQI exceeds the user's custom threshold, + // and we're not inside their configured quiet hours. // Deduplication is enforced via lastNotified ref (same signature = no repeat). if ( permission === "granted" && alertsEnabled && - current.us_aqi > HAZARDOUS_AQI_THRESHOLD + current.us_aqi > notificationSettings.aqiThreshold && + !isWithinQuietHours() ) { new Notification("⚠️ Hazardous Pollution Alert", { body: `${cityName}: AQI ${current.us_aqi} — ${warnings[0]}`, }); lastNotified.current = signature; } - }, [warnings, cityName, current?.us_aqi, permission, alertsEnabled]); + }, [ + warnings, + cityName, + current?.us_aqi, + permission, + alertsEnabled, + notificationSettings.aqiThreshold, + isWithinQuietHours, + ]); const requestNotificationPermission = () => { if (!("Notification" in window)) return; @@ -135,11 +156,37 @@ export default function AlertsPanel({ return (
-
-

Alerts & Notifications

-

Health warnings based on safe pollutant thresholds

+
+
+

Alerts & Notifications

+

Health warnings based on safe pollutant thresholds

+
+ +
+ {showSettings && ( + setShowSettings(false)} + /> + )} + {permission === "default" && (
Receive a browser notification when AQI exceeds{" "} - {HAZARDOUS_AQI_THRESHOLD} (hazardous level) + {notificationSettings.aqiThreshold}
diff --git a/src/components/NotificationSettings.jsx b/src/components/NotificationSettings.jsx new file mode 100644 index 0000000..42cb261 --- /dev/null +++ b/src/components/NotificationSettings.jsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; + +const POLLUTANT_FIELDS = [ + { key: 'pm2_5', label: 'PM2.5' }, + { key: 'pm10', label: 'PM10' }, + { key: 'nitrogen_dioxide', label: 'NO₂' }, + { key: 'ozone', label: 'Ozone' }, + { key: 'carbon_monoxide', label: 'CO' }, +]; + +/** @param {any} params */ +export default function NotificationSettings({ settings, onUpdate, onClose }) { + const [aqiThreshold, setAqiThreshold] = useState(settings.aqiThreshold); + const [pollutantThresholds, setPollutantThresholds] = useState(settings.pollutantThresholds); + const [quietHoursEnabled, setQuietHoursEnabled] = useState(settings.quietHours.enabled); + const [quietStart, setQuietStart] = useState(settings.quietHours.start); + const [quietEnd, setQuietEnd] = useState(settings.quietHours.end); + + const handleSave = () => { + onUpdate({ + aqiThreshold: Number(aqiThreshold), + pollutantThresholds: Object.fromEntries( + Object.entries(pollutantThresholds).map(([k, v]) => [k, Number(v)]) + ), + quietHours: { enabled: quietHoursEnabled, start: quietStart, end: quietEnd }, + }); + onClose(); + }; + + return ( +
+ {/* Header: title + close */} +
+ Notification Settings + +
+ + {/* AQI threshold */} +
+ AQI Threshold + +
+ + {/* Per-pollutant thresholds */} +
+ Per-Pollutant Thresholds +
+ {POLLUTANT_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ + {/* Quiet Hours */} +
+ Quiet Hours + + {quietHoursEnabled && ( +
+ + +
+ )} +
+ + {/* Save */} + +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useNotificationSettings.js b/src/hooks/useNotificationSettings.js new file mode 100644 index 0000000..3aa840e --- /dev/null +++ b/src/hooks/useNotificationSettings.js @@ -0,0 +1,93 @@ +import { useState, useCallback } from 'react'; + +const NOTIFICATION_SETTINGS_KEY = 'notification-settings'; + +export const DEFAULT_NOTIFICATION_SETTINGS = { + aqiThreshold: 200, + pollutantThresholds: { + pm2_5: 15, + pm10: 45, + nitrogen_dioxide: 25, + ozone: 100, + carbon_monoxide: 4000, + }, + quietHours: { enabled: false, start: '22:00', end: '07:00' }, +}; + +function readSettings() { + try { + const raw = localStorage.getItem(NOTIFICATION_SETTINGS_KEY); + if (!raw) return DEFAULT_NOTIFICATION_SETTINGS; + const parsed = JSON.parse(raw); + return { + ...DEFAULT_NOTIFICATION_SETTINGS, + ...parsed, + pollutantThresholds: { + ...DEFAULT_NOTIFICATION_SETTINGS.pollutantThresholds, + ...(parsed.pollutantThresholds || {}), + }, + quietHours: { + ...DEFAULT_NOTIFICATION_SETTINGS.quietHours, + ...(parsed.quietHours || {}), + }, + }; + } catch { + return DEFAULT_NOTIFICATION_SETTINGS; + } +} + +/** + * Manages custom notification settings (AQI threshold, per-pollutant thresholds, + * quiet hours) persisted to localStorage under NOTIFICATION_SETTINGS_KEY. + * + * @returns {{ + * settings: typeof DEFAULT_NOTIFICATION_SETTINGS, + * updateSettings: (next: object) => void, + * isWithinQuietHours: () => boolean + * }} + */ +export function useNotificationSettings() { + const [settings, setSettings] = useState(() => readSettings()); + + const updateSettings = useCallback((next) => { + setSettings((prev) => { + const merged = { + ...prev, + ...next, + pollutantThresholds: { + ...prev.pollutantThresholds, + ...(next.pollutantThresholds || {}), + }, + quietHours: { + ...prev.quietHours, + ...(next.quietHours || {}), + }, + }; + try { + localStorage.setItem(NOTIFICATION_SETTINGS_KEY, JSON.stringify(merged)); + } catch (_e) { + // Quota exceeded — skip persist + } + return merged; + }); + }, []); + + const isWithinQuietHours = useCallback(() => { + const { enabled, start, end } = settings.quietHours; + if (!enabled) return false; + const now = new Date(); + const [startH, startM] = start.split(':').map(Number); + const [endH, endM] = end.split(':').map(Number); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const startMinutes = startH * 60 + startM; + const endMinutes = endH * 60 + endM; + if (startMinutes === endMinutes) return false; + if (startMinutes < endMinutes) { + return nowMinutes >= startMinutes && nowMinutes < endMinutes; + } + // Window wraps past midnight (e.g. 22:00 -> 07:00) + return nowMinutes >= startMinutes || nowMinutes < endMinutes; + }, [settings.quietHours]); + + return { settings, updateSettings, isWithinQuietHours }; +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 3be5a6a..3ab8cd6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; + .selected-file-container { display: flex; align-items: center; @@ -4428,8 +4430,6 @@ } } -@import "tailwindcss"; - /* ============================================================ Pollution Control Hub — Design Tokens ============================================================ */ @@ -10442,4 +10442,386 @@ a { .symptom-report-options { grid-template-columns: 1fr; } -} \ No newline at end of file +} + +/* ============================================================ + Notification Settings Panel — Issue #335 + Scoped .notif-* classes; uses existing design tokens. + ============================================================ */ + +/* ── Panel wrapper ───────────────────────────────────────────── */ +.notif-settings-panel { + margin-bottom: var(--sp-4); + padding: var(--sp-5); + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-md); + box-shadow: var(--shadow-sm); + animation: rise-in 250ms var(--ease) both; +} + +/* ── Header row (title + close button) ──────────────────────── */ +.notif-settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--sp-4); + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--line); +} + +.notif-settings-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--ink); + letter-spacing: 0.01em; +} + +.notif-settings-close { + display: flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r-sm); + cursor: pointer; + font-size: 1rem; + color: var(--muted); + line-height: 1; + transition: + background 180ms var(--ease), + color 180ms var(--ease), + border-color 180ms var(--ease); +} + +.notif-settings-close:hover { + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); +} + +.notif-settings-close:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; + border-radius: var(--r-sm); +} + +/* ── Section grouping ────────────────────────────────────────── */ +.notif-settings-section { + margin-bottom: var(--sp-4); +} + +.notif-settings-section + .notif-settings-section { + padding-top: var(--sp-3); + border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent); +} + +/* ── Section sub-label (uppercase eyebrow) ───────────────────── */ +.notif-section-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + margin-bottom: var(--sp-3); +} + +/* ── AQI threshold row ───────────────────────────────────────── */ +.notif-aqi-row { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; + font-size: 0.88rem; + font-weight: 500; + color: var(--ink); +} + +/* ── Shared input style (number + time inputs) ───────────────── */ +.notif-input { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0.3rem 0.6rem; + font: inherit; + font-size: 0.88rem; + width: 5.5rem; + transition: + border-color 180ms var(--ease), + box-shadow 180ms var(--ease); +} + +.notif-input:hover { + border-color: var(--brand); +} + +.notif-input:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 18%, transparent); +} + +/* ── Per-pollutant rows ──────────────────────────────────────── */ +.notif-pollutant-rows { + display: flex; + flex-direction: column; + gap: 0; +} + +.notif-pollutant-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.87rem; + color: var(--ink); + padding: 0.4rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 45%, transparent); +} + +.notif-pollutant-row:last-child { + border-bottom: none; +} + +.notif-pollutant-name { + font-weight: 500; + color: var(--ink); +} + +/* ── Quiet Hours section ─────────────────────────────────────── */ +.notif-quiet-label { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: 0.88rem; + font-weight: 600; + color: var(--ink); + cursor: pointer; + user-select: none; +} + +.notif-quiet-times { + display: flex; + gap: var(--sp-5); + flex-wrap: wrap; + margin-top: var(--sp-3); + font-size: 0.85rem; +} + +.notif-time-label { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--ink); + font-weight: 500; +} + +.notif-time-input { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0.3rem 0.5rem; + font: inherit; + font-size: 0.85rem; + transition: + border-color 180ms var(--ease), + box-shadow 180ms var(--ease); +} + +.notif-time-input:hover { + border-color: var(--brand); +} + +.notif-time-input:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 18%, transparent); +} + +/* ── Save Settings button ────────────────────────────────────── */ +.notif-save-btn { + display: inline-flex; + align-items: center; + padding: 0.52rem 1.25rem; + cursor: pointer; + background: var(--brand); + color: var(--panel); /* dark panel on light brand (dark mode) */ + border: none; + border-radius: var(--r-sm); + font: inherit; + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.02em; + transition: + filter 180ms var(--ease), + transform 180ms var(--ease), + box-shadow 180ms var(--ease); + box-shadow: var(--shadow-sm); +} + +/* In light mode the brand is a darker teal — white text fits better */ +:root[data-theme="light"] .notif-save-btn { + color: #ffffff; +} + +.notif-save-btn:hover { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.notif-save-btn:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 3px; +} + +.notif-save-btn:active { + transform: translateY(0); + filter: brightness(0.94); +} + +/* ── Gear / Settings toggle button (in AlertsPanel header) ───── */ +.notif-gear-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--r-sm); + padding: 0.35rem; + cursor: pointer; + color: var(--muted); + transition: + background 180ms var(--ease), + color 180ms var(--ease), + border-color 180ms var(--ease), + transform 240ms var(--ease); +} + +.notif-gear-btn:hover { + background: color-mix(in srgb, var(--brand) 12%, transparent); + color: var(--brand); + border-color: color-mix(in srgb, var(--brand) 35%, transparent); + transform: rotate(35deg); +} + +.notif-gear-btn:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; + color: var(--brand); +} + +/* ── High Contrast overrides ─────────────────────────────────── */ +:root[data-theme="high-contrast"] .notif-settings-panel { + background: var(--hc-surface) !important; + border: 2px solid var(--hc-border) !important; + box-shadow: none !important; +} + +:root[data-theme="high-contrast"] .notif-settings-header { + border-bottom: 2px solid var(--hc-border) !important; +} + +:root[data-theme="high-contrast"] .notif-settings-title { + color: var(--hc-ink) !important; + font-weight: 800 !important; +} + +:root[data-theme="high-contrast"] .notif-settings-close { + color: var(--hc-ink) !important; + border: 2px solid var(--hc-border) !important; +} + +:root[data-theme="high-contrast"] .notif-settings-close:hover { + background: var(--hc-surface-alt) !important; + border-color: var(--hc-brand) !important; + color: var(--hc-brand) !important; +} + +:root[data-theme="high-contrast"] .notif-section-label { + color: var(--hc-muted) !important; + font-weight: 700 !important; +} + +:root[data-theme="high-contrast"] .notif-settings-section + .notif-settings-section { + border-top-color: var(--hc-border) !important; +} + +:root[data-theme="high-contrast"] .notif-aqi-row, +:root[data-theme="high-contrast"] .notif-pollutant-name, +:root[data-theme="high-contrast"] .notif-quiet-label, +:root[data-theme="high-contrast"] .notif-time-label { + color: var(--hc-ink) !important; + font-weight: 600 !important; +} + +:root[data-theme="high-contrast"] .notif-pollutant-row { + color: var(--hc-ink) !important; + border-bottom-color: var(--hc-border-light) !important; +} + +:root[data-theme="high-contrast"] .notif-save-btn { + background-color: var(--hc-brand) !important; + color: #ffffff !important; + border: 2px solid var(--hc-brand) !important; + font-weight: 700 !important; + box-shadow: none !important; +} + +:root[data-theme="high-contrast"] .notif-save-btn:hover { + background-color: var(--hc-brand-hover) !important; + border-color: var(--hc-brand-hover) !important; + filter: none !important; + transform: none !important; + text-decoration: underline !important; +} + +:root[data-theme="high-contrast"] .notif-gear-btn { + color: var(--hc-ink) !important; + border: 2px solid var(--hc-border) !important; +} + +:root[data-theme="high-contrast"] .notif-gear-btn:hover { + background: var(--hc-surface-alt) !important; + border-color: var(--hc-brand) !important; + color: var(--hc-brand) !important; + transform: none !important; +} + +/* ── Responsive ──────────────────────────────────────────────── */ +@media (max-width: 540px) { + .notif-settings-panel { + padding: var(--sp-4); + } + + .notif-aqi-row { + flex-direction: column; + align-items: flex-start; + gap: var(--sp-2); + } + + .notif-input { + width: 100%; + max-width: 9rem; + } + + .notif-pollutant-row { + flex-wrap: wrap; + gap: var(--sp-2); + } + + .notif-quiet-times { + flex-direction: column; + gap: var(--sp-2); + } + + .notif-time-input { + width: 100%; + max-width: 9rem; + } +} \ No newline at end of file